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.
This commit is contained in:
Sylpheed RE agent
2026-08-24 21:50:10 +00:00
parent 6bb8b14536
commit f6f8930ee7
4 changed files with 256 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
# Hunting REMAINING OB by correlation — method works, run did not finish
Status: ✅ the correlation method is sound and demonstrated; 🔴 the hunt is
**unfinished**; 🔴 two self-inflicted defects, one a repeat.
## The method
[mission-phase-objectives.md](mission-phase-objectives.md) 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. So `REMAINING OB`
must fall when a marked fighter dies — and the per-record craft strength already
says exactly when that happens, for a *named* unit.
So instead of scanning for a value, intersect: keep every word in the 32 MB heap
that fell by the same amount, in the same interval, as an `e010` loss. Each kill
event should cut the survivor set hard.
`tools/re-capture/ob_probe2.py`.
## ✅ It works — one event cut 8 million words to 1056
```
t= 219s e010 losses=2 (all=4) words falling by 2: 1056 -> candidates 1056
```
From ~8 M candidate words to **1056** on a single event. Two or three more should
leave a handful.
## 🔴 The run did not finish, and the candidates were lost
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. The follow-up attach then
started from nothing.
**This is the same mistake already recorded in
[guest-stalls.md](guest-stalls.md)**: "the first attempt deferred all analysis to
the end of the run, and the turn's timeout killed it with 240 s of collected data
still in memory and nothing written." I wrote that lesson down and then repeated
it in a new script four iterations later.
Fixed: candidates are now written to `/tmp/ob_candidates.json` **after every
event**, and `SYLPH_OB_RESUME=1` loads them so a chained attach keeps
intersecting on the same mission.
## 🔴 The attach could not tell a quiet mission from a frozen one
The follow-up attach logged **535 s with zero losses of any kind**. That is
exactly what a freeze looks like, and `ob_probe2` had no stall witness, so the
run cannot say which it was. Also fixed — the witness from `wave7_probe` is now
carried here.
Two defects in one iteration, both of them things this corpus had already
learned. The pattern is that each new probe starts from scratch and re-earns the
same lessons; the fix that would actually stick is a shared probe harness rather
than a family of one-off scripts.
## What is still open
The hunt itself. The method is demonstrated but no address is identified. What it
needs is a run that catches **two or three** `e010` kill events, which is the same
combat-effectiveness limit recorded in
[mission-objectives-text.md](mission-objectives-text.md) — the preference knob
gets about two marked-fighter kills per five minutes against a dozen turrets.

19
tools/re-capture/ob2_attach.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
pgrep -x xenia_canary >/dev/null || { echo "NO EMULATOR"; exit 1; }
CFG=/tmp/nav-ob2.json
for try in 1 2 3; do
python3 "$SD/pad.py" set "rt=1" >/dev/null 2>&1 || true; sleep 3
python3 "$SD/pad.py" clear >/dev/null 2>&1 || true
if python3 "$SD/entities2.py" self 0x130 "$CFG" >/dev/null 2>&1; then
SYLPH_HUNT=1 SYLPH_KILL_TURRETS=1 SYLPH_KEEPOUT=1400 SYLPH_PREFER=e010 \
nohup python3 "$SD/pilot.py" "$CFG" "${1:-500}" </dev/null >/tmp/ob2-pilot.log 2>&1 &
P=$!; echo "--- pilot attached"; break
fi
done
SYLPH_OB_RESUME=1 python3 "$SD/ob_probe2.py" "${1:-500}" "${2:-20}"
[ -n "${P:-}" ] && kill "$P" 2>/dev/null
echo "OB2 ATTACH DONE"

31
tools/re-capture/ob2_session.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
SECS="${1:-180}"; EVERY="${2:-10}"; HUNT="${3:-1}"
CFG=/tmp/nav-live.json
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
# The bind is intermittent and a failed bind means no pilot, no kills and a
# completely uninformative run. Retry before giving up, and abort if it never
# takes rather than silently flying an unattended craft.
# entities2 self finds the player by MOTION between two samples, so a craft that
# is sitting still at mission start is invisible and the bind fails -- three
# times in a row on the run that added this retry. Nudge the throttle first so
# there is something to see, then bind.
BOUND=0
for try in 1 2 3; do
python3 "$SD/pad.py" set "rt=1" >/dev/null 2>&1 || true
sleep 3
python3 "$SD/pad.py" clear >/dev/null 2>&1 || true
if python3 "$SD/entities2.py" self 0x130 "$CFG" >/dev/null 2>&1; then BOUND=1; break; fi
echo "--- bind attempt $try failed, retrying"; sleep 5
done
if [ "$BOUND" = 1 ]; then
SYLPH_HUNT="$HUNT" SYLPH_KILL_TURRETS=1 SYLPH_KEEPOUT="${SYLPH_KEEPOUT:-1400}" SYLPH_HZ="${SYLPH_HZ:-8.0}" SYLPH_PREFER="${SYLPH_PREFER:-}" nohup python3 "$SD/pilot.py" "$CFG" "$SECS" \
</dev/null >/tmp/live-pilot.log 2>&1 &
PILOT=$!; echo "--- pilot (SYLPH_HUNT=$HUNT)"
else echo "BIND FAILED after 3 attempts -- aborting, an unattended run tests nothing"; exit 4; fi
python3 "$SD/ob_probe2.py" "$SECS" "$EVERY"; rc=$?
[ -n "$PILOT" ] && kill "$PILOT" 2>/dev/null
echo "LIVENESS DONE rc=$rc"

143
tools/re-capture/ob_probe2.py Executable file
View File

@@ -0,0 +1,143 @@
#!/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())