Files
Sylpheed/tools/re-capture/ob_probe2.py
Sylpheed RE agent 3be3628468 re: OB hunt second attempt — saving verified, filter fixed, attach was frozen
The incremental-save fix is verified. A fresh mission caught one e010 event at
t=241 s and wrote 1187 candidates to disk immediately; the turn timeout then
fired exactly as before, but this time the data survived. The session also
clears the candidate file at launch, since candidate offsets are only meaningful
within one emulator instance and resuming across launches would intersect
unrelated addresses.

The correlation itself was wrong though. It matched on the delta alone, so any
two float bit patterns whose integer representations differ by the loss count
qualified, and in a heap full of positions and velocities that is thousands of
words. The 1187 survivors were things like 1044450858, about 0.1f, and
3212461993, a negative float. Candidates must now also look like a counter --
a small non-negative integer in both samples -- which removes the noise by
construction instead of hoping the intersection washes it out.

The follow-up attach logged zero events across 520 s, which reads like the
combat-effectiveness limit again. It was not: 25 of its 26 samples were flagged
GUEST STALLED, so the guest was frozen for essentially the whole window. The
witness added last iteration did its job, and the lesson is about reading it --
the run summary quoted "0 events" first and the stall count only surfaced on a
deliberate check. A run's witness result should be the first thing looked at,
before any interpretation of what the run showed.

Still unfinished, with no address identified. What is needed is unchanged, two
or three e010 kill events in non-stalled samples, and the two obstacles are now
clearly separate: the freeze rate, and a pilot that manages about two
marked-fighter kills per five minutes.
2026-08-24 22:15:15 +00:00

150 lines
6.6 KiB
Python
Executable File

#!/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())