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:
121
tools/re-capture/wave3_probe.py
Executable file
121
tools/re-capture/wave3_probe.py
Executable 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())
|
||||
15
tools/re-capture/wave3_session.sh
Executable file
15
tools/re-capture/wave3_session.sh
Executable 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"
|
||||
Reference in New Issue
Block a user