#!/usr/bin/env python3 """Find REMAINING OB by correlating it with marked-fighter kills. The objective text settles what the counter is: Stage 02 phase 1 asks to "shoot down all invading enemy fighters", and the hints say red [OB] markers indicate the targets (mission-phase-objectives.md). So REMAINING OB must decrement when a marked fighter dies -- and the per-record craft strength already tells us exactly when that happens, for a named unit. Rather than scan for a value, intersect candidates across kill events: keep every word in the heap that fell by the same amount in the same interval as an e010 loss. Two or three events should leave very few. """ import os, sys, time, struct, collections sys.path.insert(0, __file__.rsplit('/', 1)[0]) import gmem, gworld, entities2 import numpy as np ROSTER_VT = struct.pack('>I', 0x820AF030) DELTA, WIN, LINK, HULL = 0x130, 0x400, 0x08, 0x154 LO, HI = 0xBD000000, 0xBE000000 WATCH = os.environ.get('SYLPH_OB_UNIT', 'e010') def scan_vt(fd, size, vt): out = [] for a, b in gmem.extents(fd, size): pos = a while pos < b: m = min(1 << 24, b - pos) blob = os.pread(fd, m, pos) i = blob.find(vt) while i != -1: if (pos + i) % 4 == 0: out.append(pos + i) i = blob.find(vt, i + 1) pos += m return sorted(out) def region(fd): lo, hi = gmem.va_to_off(LO), gmem.va_to_off(HI) out, pos = bytearray(), lo while pos < hi: n = min(1 << 24, hi - pos); out += os.pread(fd, n, pos); pos += n return np.frombuffer(bytes(out), dtype='>u4').astype(np.int64), lo def strengths(fd, defs, want): lo, hi = gmem.va_to_off(entities2.ENT_VA_LO), gmem.va_to_off(entities2.ENT_VA_HI) per = collections.Counter(); pos = lo while pos < hi: m = min(1 << 24, hi - pos) blob = os.pread(fd, m, pos) for needle, nm in defs.items(): i = blob.find(needle) while i != -1: if (pos + i) % 4 == 0: base = pos + i - DELTA try: alive = struct.unpack('>f', os.pread(fd, 4, base + HULL))[0] > 0 except Exception: alive = False if alive: head = os.pread(fd, WIN, base) for j in range(0, len(head) - 3, 4): (p,) = struct.unpack_from('>I', head, j) if p in want: per[(want[p], nm)] += 1; break i = blob.find(needle, i + 1) pos += m return per def main(): secs = int(sys.argv[1]) if len(sys.argv) > 1 else 300 every = int(sys.argv[2]) if len(sys.argv) > 2 else 20 import json w = gworld.World(); fd = w.fd defs = entities2.definitions(w) roster = scan_vt(fd, w.size, ROSTER_VT) want = {} for o in roster: va = gmem.primary_va(o) if va is not None: want[va + LINK] = o print('roster %d, definitions %d, watching "%s"' % (len(roster), len(defs), WATCH), flush=True) # A run with no losses reads exactly like a frozen guest. The attach that # followed this probe's first run logged 535 s of zero losses with no way to # tell which it was. Carry the witness. r0, _ = region(fd); time.sleep(3.0); r1, _ = region(fd) d = r1 - r0 rates = collections.Counter(int(x // 3) for x in d[(d > 15) & (d < 600)][:200000]) band = [r for r in rates if 8 <= r <= 40] pick = max(band, key=lambda r: rates[r]) if band else None ticks = list(np.nonzero((d // 3 == pick))[0][:32]) if pick else [] print('tick witnesses: %d at %s/s' % (len(ticks), pick), flush=True) prev_s = strengths(fd, defs, want) prev_r, base = region(fd) last_t = prev_r[ticks] if ticks else None # Persist candidates so a chained attach can keep intersecting on the SAME # mission -- one call rarely catches enough kill events on its own. CAND = '/tmp/ob_candidates.json' cand = None; events = 0 if os.environ.get('SYLPH_OB_RESUME') == '1' and os.path.exists(CAND): cand = set(json.load(open(CAND))) print('resumed %d candidates from a previous call' % len(cand), flush=True) t0 = time.time() while time.time() - t0 < secs: time.sleep(every) cur_s = strengths(fd, defs, want) cur_r, _ = region(fd) el = round(time.time() - t0) lost = sum(max(0, prev_s[k] - cur_s.get(k, 0)) for k in prev_s if WATCH in k[1]) allo = sum(max(0, prev_s[k] - cur_s.get(k, 0)) for k in prev_s) if lost: events += 1 # Filter on VALUE plausibility, not just on the delta. Without this # the survivors are float bit patterns whose integer representations # happen to differ by `lost` -- the first run's 1187 "candidates" # were things like 1044450858 (~0.1f) and 3212461993 (a negative # float). A remaining-target counter is a small non-negative integer. d = prev_r - cur_r # positive where a word FELL plausible = (cur_r >= 0) & (cur_r < 1000) & (prev_r < 1000) hit = np.nonzero((d == lost) & plausible)[0] s = set(hit.tolist()) cand = s if cand is None else (cand & s) json.dump(sorted(cand), open(CAND, 'w')) # save NOW: the previous # version saved only at the end and a turn timeout destroyed 1056 # hard-won candidates -- the same "report at the end" mistake already # recorded once in guest-stalls.md. print(' t=%4ds %s losses=%d (all=%d) words falling by %d: %d -> candidates %d (saved)' % (el, WATCH, lost, allo, lost, len(s), len(cand)), flush=True) else: st = '' if ticks: now_t = cur_r[ticks] if int((now_t > last_t).sum()) == 0: st = ' *** GUEST STALLED ***' last_t = now_t print(' t=%4ds no %s loss (all losses=%d)%s' % (el, WATCH, allo, st), flush=True) prev_s, prev_r = cur_s, cur_r if cand is not None: json.dump(sorted(cand), open(CAND, 'w')) print('saved %d candidates for the next call' % len(cand), flush=True) print('\nevents: %d' % events) if cand: print('surviving candidates: %d' % len(cand)) for i in sorted(cand)[:12]: off = base + i * 4 va = gmem.primary_va(off) print(' va %s value %d' % (('%#010x' % va) if va else '?', int(prev_r[i]))) else: print('no candidate survived -- either no kills, or OB is not a plain u32 here') return 0 if __name__ == '__main__': sys.exit(main())