This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/tools/re-capture/ob_probe2.py
Sylpheed RE agent f6f8930ee7 re: REMAINING OB by correlation — method works, run unfinished, two repeat defects
The objective text settles what the counter is, so the hunt can be a correlation
rather than a value scan: keep every word in the 32 MB heap that fell by the same
amount, in the same interval, as a named e010 loss. One event cut roughly eight
million words to 1056, so two or three more should leave a handful.

The run did not get them. The turn's timeout fired at t=219 s and the probe saved
its candidate set only at the end, so the 1056 were discarded and the follow-up
attach started from nothing. That is the same mistake already recorded in
guest-stalls.md, where an earlier probe deferred all analysis to the end and a
timeout killed it with 240 s of data in memory and nothing written. The lesson
was written down and then repeated in a new script four iterations later.

The attach had a second gap: 535 s with zero losses of any kind, which is
indistinguishable from a freeze, and ob_probe2 carried no stall witness so the
run cannot say which it was.

Both are fixed. Candidates are written after every event and SYLPH_OB_RESUME=1
reloads them so a chained attach keeps intersecting on the same mission, and the
witness from wave7_probe is carried here.

The underlying pattern is worth naming: each new probe starts from scratch and
re-earns the same lessons about saving incrementally and validating liveness. A
shared probe harness would stick where written-down lessons have not.

The hunt itself is unfinished. No address is identified, and finishing needs a
run that catches two or three marked-fighter kills, which is the same
combat-effectiveness limit already recorded -- about two per five minutes against
a dozen turrets.
2026-08-24 21:50:10 +00:00

144 lines
6.1 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
d = prev_r - cur_r # positive where a word FELL
hit = np.nonzero(d == lost)[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())