re: fix record labelling and measure the stride; confirm 116 records == roster

Both defects from the previous iteration are fixed by measuring instead of
assuming, and the fix immediately promotes a 🟡 result to .

Labelling: the previous probe assumed object+0x04 -> name_record+0x10 -> char*
and resolved 0 of 116. wave3_probe.py searches for the chain per record instead,
the way unit_discover.py does, and resolves 116 of 116 -- every one through the
pointer at +0x04 with the string at delta 0x00, not 0x10. The 0x10 belongs to
the definition object (vtable 0x820af844); the spawned-entity record
(0x820af030) uses 0x00. Carrying one over to the other cost the last run.

Stride: measured, not assumed. Gaps between consecutive records are min 32,
median 800, with common values 800, 640, 608, 576, 416 and 32. There is no fixed
record size, so the old RECLEN=0x200 window truncated large records and overran
small ones -- which is why its busiest fields were the last words of the window.
Future diffs must bound each record by the next record's address.

With labels available, the "116 records == 116 roster members" claim was tested
properly and is promoted from 🟡 to . The multiset of unit types matches the
static roster exactly: Turret 21/21, e106 Destroyer 19/19, f106 Destroyer 14/14,
f105 Cruiser 11/11, ASFrigate 9/9, ISCMissile 9/9, Attacker_S 9/9, e105 Cruiser
7/7, DeltaSaber_T 7/7, ArrowHead 6/6 -- 10 of 10 exact. A coincidental total is
possible; a coincidental distribution over ten unit types is not. The game
allocates one record per roster member at mission load.

Not settled: REMAINING OB at 0xbdb59668 held 95748078 unchanged all run. That
address is known to be run-dependent, and this was one of the misses, so the run
cannot say whether the pilot killed anything. Re-hunting it is a precondition
for the kill-versus-no-kill test, not an optional extra.
This commit is contained in:
Sylpheed RE agent
2026-08-24 12:35:02 +00:00
parent 1d3ebdaa05
commit 1a7f8f190c
3 changed files with 205 additions and 1 deletions

121
tools/re-capture/wave3_probe.py Executable file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Label the spawned-entity records, and MEASURE their stride.
Fixes the two defects recorded in mission-wave-arrivals.md:
* the previous probe assumed the id chain object+0x04 -> name+0x10 -> char*
and got '?' for all 116 records. This searches for the chain per record
instead of assuming one, the way unit_discover.py does.
* the previous probe assumed RECLEN = 0x200. This measures the gap
distribution between consecutive records and reports it.
Also samples REMAINING OB so a run can say whether the pilot killed anything --
which is what separates the clock-driven and event-gated wave models.
"""
import os, sys, time, struct, collections, importlib.util
SD = __file__.rsplit('/', 1)[0]
spec = importlib.util.spec_from_file_location('gmem', SD + '/gmem.py')
gmem = importlib.util.module_from_spec(spec); spec.loader.exec_module(gmem)
VT = struct.pack('>I', 0x820AF030)
OB_VA = 0xBDB59668 # REMAINING OB, structures/mission-objective-counter.md
PRINTABLE = set(range(0x20, 0x7F))
def find_records(f, fd, size):
offs = []
for start, end in gmem.extents(fd, size):
pos = start
while pos < end:
f.seek(pos); buf = f.read(min(1 << 24, end - pos))
if not buf: break
i = buf.find(VT)
while i != -1:
if (pos + i) % 4 == 0: offs.append(pos + i)
i = buf.find(VT, i + 1)
pos += len(buf)
return sorted(offs)
def rd(f, off, n):
f.seek(off); return f.read(n)
def cstr(b):
e = b.find(b'\x00')
s = b[:e if e >= 0 else len(b)]
return s.decode('latin-1') if s and all(c in PRINTABLE for c in s) else None
def resolve_id(f, size, base, scan_words=24, deltas=range(0, 0x41, 4)):
"""Search, don't assume: any pointer in the record's first words, chased
through one optional indirection, that lands on a UN_/NP_-looking name."""
head = rd(f, base, scan_words * 4)
for w in range(0, len(head) - 3, 4):
(p,) = struct.unpack_from('>I', head, w)
if not (0x80000000 <= p < 0xC0000000): continue
o1 = gmem.va_to_off(p)
if o1 is None or o1 + 0x80 > size: continue
blk = rd(f, o1, 0x80)
s = cstr(blk)
if s and (s.startswith('UN_') or s.startswith('NP_')):
return s, ('direct', w, 0)
for d in deltas: # one indirection
if d + 4 > len(blk): break
(q,) = struct.unpack_from('>I', blk, d)
if not (0x80000000 <= q < 0xC0000000): continue
o2 = gmem.va_to_off(q)
if o2 is None or o2 + 0x40 > size: continue
s = cstr(rd(f, o2, 0x40))
if s and (s.startswith('UN_') or s.startswith('NP_')):
return s, ('indirect', w, d)
return None, None
def ob(f):
o = gmem.va_to_off(OB_VA)
if o is None: return None
return struct.unpack('>I', rd(f, o, 4))[0]
def main():
secs = int(sys.argv[1]) if len(sys.argv) > 1 else 120
every = int(sys.argv[2]) if len(sys.argv) > 2 else 10
path = gmem.mem_path()
fd = os.open(path, os.O_RDONLY); size = os.fstat(fd).st_size
f = os.fdopen(os.dup(fd), 'rb')
offs = find_records(f, fd, size)
if not offs:
print('NO ENTITY RECORDS -- not in a mission'); return 2
print('records: %d' % len(offs))
gaps = [offs[i+1] - offs[i] for i in range(len(offs) - 1)]
hist = collections.Counter(gaps)
print('\n--- MEASURED stride (gap between consecutive records) ---')
print(' min=%d median=%d most common: %s'
% (min(gaps), sorted(gaps)[len(gaps)//2],
[(g, c) for g, c in hist.most_common(6)]))
print('\n--- label resolution (searched, not assumed) ---')
labels, how = {}, collections.Counter()
for k, o in enumerate(offs):
s, h = resolve_id(f, size, o)
labels[k] = s
how[h[0] if h else 'FAILED'] += 1
if h: how[('chain', h[1], h[2])] += 1
named_n = sum(1 for v in labels.values() if v)
print(' resolved %d/%d' % (named_n, len(offs)))
print(' by method:', [(str(a), b) for a, b in how.most_common(6)])
if named_n:
print(' unit id histogram:',
collections.Counter(v for v in labels.values() if v).most_common(10))
else:
print(' STILL UNRESOLVED -- the id is not reachable from the record head')
print('\n--- REMAINING OB over %ds (does the pilot kill anything?) ---' % secs)
t0 = time.time(); seen = []
while time.time() - t0 < secs:
v = ob(f); seen.append((round(time.time() - t0), v))
print(' t=%4ds OB=%s' % seen[-1], flush=True)
time.sleep(every)
vals = [v for _, v in seen if v is not None]
print(' distinct OB values: %s' % sorted(set(vals))[:10])
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,15 @@
#!/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:-300}"; EVERY="${2:-10}"
CFG=/tmp/nav-wave.json
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
if python3 "$SD/entities2.py" self 0x130 "$CFG" >/dev/null 2>&1; then
nohup python3 "$SD/pilot.py" "$CFG" "$SECS" </dev/null >/tmp/wave3-pilot.log 2>&1 &
PILOT=$!; echo "--- pilot flying"
else PILOT=""; echo "--- BIND FAILED, unattended craft"; fi
python3 "$SD/wave3_probe.py" "$SECS" "$EVERY"; rc=$?
[ -n "$PILOT" ] && kill "$PILOT" 2>/dev/null
echo "WAVE SESSION DONE rc=$rc"