#!/usr/bin/env python3 """Diff the 116 spawned-entity records over a mission to find the arrival flag. wave2 follows mission-wave-arrivals.md: the record COUNT is flat at 116 (one per UnitGroup roster member), so an arrival must flip a field *inside* a record rather than create one. Route_S02 predicts phase-1 arrivals at t = 90, 120, 170, 210, 240 in groups of 3, 3, 3, 2, 1 -- in units that are still unmeasured. A per-record word that transitions at ~90 s supports seconds; everything landing inside the first ~8 s supports frames at 30 Hz. """ 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) RECLEN = 0x200 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 offs def read_at(f, off, n): f.seek(off); return f.read(n) def label(f, size, off): """unit id string: object+0x04 -> name record, +0x10 -> char*""" try: nr = struct.unpack('>I', read_at(f, off + 4, 4))[0] o = gmem.va_to_off(nr) if o is None or o + 0x14 > size: return '?' sp = struct.unpack('>I', read_at(f, o + 0x10, 4))[0] so = gmem.va_to_off(sp) if so is None: return '?' b = read_at(f, so, 48) return b.split(b'\x00')[0].decode('latin-1') or '?' except Exception: return '?' def main(): secs = int(sys.argv[1]) if len(sys.argv) > 1 else 260 every = int(sys.argv[2]) if len(sys.argv) > 2 else 5 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 ids = [label(f, size, o) for o in offs] print('%d records; distinct unit ids: %d' % (len(offs), len(set(ids)))) print(' ', collections.Counter(ids).most_common(8)) prev = [read_at(f, o, RECLEN) for o in offs] t0 = time.time() # Stream every transition to disk. The first version of this probe deferred # ALL analysis to the end and a timeout destroyed the whole run's evidence. jl = open('/tmp/wave2-trans.tsv', 'w') jl.write('t\trec\tfield\told\tnew\n') # transitions[(field_off)] -> list of (t, rec_index, old, new) trans = collections.defaultdict(list) while time.time() - t0 < secs: time.sleep(every) el = round(time.time() - t0) nch = 0 for k, o in enumerate(offs): cur = read_at(f, o, RECLEN) if cur == prev[k]: continue for w in range(0, RECLEN, 4): a = prev[k][w:w+4]; b = cur[w:w+4] if a != b: ov, nv = struct.unpack('>I', a)[0], struct.unpack('>I', b)[0] trans[w].append((el, k, ov, nv)) jl.write('%d\t%d\t0x%03x\t%08x\t%08x\n' % (el, k, w, ov, nv)) nch += 1 prev[k] = cur print(' t=%4ds changed words this tick: %d' % (el, nch), flush=True) jl.flush() if el % 60 < every: # partial ranking, timeout-proof r = sorted(trans.items(), key=lambda kv: -len({x[1] for x in kv[1]})) print(' partial: ' + '; '.join( '+0x%03x:%drec' % (w, len({x[1] for x in ev})) for w, ev in r[:6]), flush=True) jl.close() print('\n--- fields by how many records ever changed them ---') rank = sorted(trans.items(), key=lambda kv: -len({x[1] for x in kv[1]})) for w, ev in rank[:18]: recs = {x[1] for x in ev} times = collections.Counter(x[0] for x in ev) print(' +0x%03x %3d records, %3d events; busiest ticks %s' % (w, len(recs), len(ev), times.most_common(5))) print('\n--- fields that changed for only a FEW records (arrival-like) ---') for w, ev in rank: recs = {x[1] for x in ev} if not (1 <= len(recs) <= 12): continue print(' +0x%03x records=%s' % (w, sorted(recs)[:12])) for t, k, a, b in ev[:10]: print(' t=%4ds rec %3d (%s) %08x -> %08x' % (t, k, ids[k], a, b)) return 0 if __name__ == '__main__': sys.exit(main())