diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index 62bd843..e5d582d 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -310,7 +310,17 @@ search cannot find a *schedule*. and 14 manoeuvre weights per profile). šŸ”“ Refuted: the 8-value frame is *not* universal — `Formation_Fleet_01/02` use 136 and 4 values per frame. -**First step (next):** find what *advances a phase* — the stage declares +* šŸ”“ **Probed 2026-08-24 and refuted the cheap hypothesis** — the phase state is + NOT adjacent to the loaded table strings; see + [`mission-phase-runtime.md`](mission-phase-runtime.md). The run did confirm + dynamically that every table the static decode predicts is resident in guest + RAM and findable by name, which validates the whole static layer against a + running mission. Next handles: watch `Route_ADN101_p1F` actually fire against + entity positions, or work back from the `SUBOBJ_*_Mes_L1` HUD strings; the + phase state is more likely near the known mutable `REMAINING OB` counter at + `0xbdb59668` than near the tables. + +**Superseded first step:** find what *advances a phase* — the stage declares `Phase_1..3` and routes are phase-tagged, but nothing static found so far says what ends a phase. This is the point where the oracle should be measured rather than reasoned about: fly Stage 02 and watch for the phase transition. Also open: diff --git a/docs/re/mission-phase-runtime.md b/docs/re/mission-phase-runtime.md new file mode 100644 index 0000000..c06e031 --- /dev/null +++ b/docs/re/mission-phase-runtime.md @@ -0,0 +1,87 @@ +# Where the runtime phase state is *not* + +Status: āœ… the stage tables are resident in guest RAM, so the static decode +matches what the game actually loads; šŸ”“ the "phase state sits next to the +loaded table strings" hypothesis is refuted; ā” what advances a phase is still +unknown. + +Follows [structures/stage-mission-tables.md](structures/stage-mission-tables.md), +which found that a stage is divided into `Phase_1..3` and that every arrival +route is tagged with a phase — but nothing static says what *ends* one. That +made it an oracle question. + +Tools: `tools/re-capture/phase_session.sh` (boot → Stage 02 flight → fly → +probe) and `tools/re-capture/phase_probe.py`. + +## āœ… The tables are resident, and findable by name + +One Stage 02 flight, 160 s under the survival pilot. Every string the static +decode predicted is present in live guest memory: + +| needle | hits | +|---|---| +| `Phase_1` | 3 | +| `Phase_2` | 5 | +| `Route_ADN101_p1F` | 2 | +| `SUBOBJ_010` | 2 | +| `AI_ADAN_CraftSquadron_Veteran` | 7 | +| `UnitGroup_S02.tbl` | 1 | + +This is worth stating plainly because it is the first *dynamic* confirmation of +the whole static table layer: the game loads exactly the tables the stage record +names, under exactly the names we resolved, and they can be located in RAM by +content. Anything decoded there can now be cross-checked against a running +mission. + +## šŸ”“ Refuted: the phase counter is not adjacent to the tables + +The probe captured 512 words around each anchor, then re-diffed every 20 s. The +naive reading of the result is "862 words changed, something is happening here". +That reading is wrong, and the raw values say so: + +``` +va=0xbd8e4ec0 bd8e4ea4 -> bd8e4ea0 +va=0xbd8e4ec4 bd8e4ea8 -> bd8e4ea4 +va=0xbd8e4ec8 bd8e4eac -> bd8e4ea8 +va=0xbd8e4ecc bd8e4eb0 -> bd8e4eac +``` + +Every word takes its *predecessor's* previous value, and each value is a pointer +into the same region. That is a block being shifted down by four bytes — one +`memmove` in a pointer list — not 862 independent counters. It happened once, +between t = 66 s and t = 89 s, and nothing changed before or after. + +So: **the runtime phase state is not stored next to the loaded table strings.** +The tables sit in a region that is effectively read-only for the mission's +duration; the mission's mutable state lives elsewhere. Diffing around a string +anchor was the cheap thing to try and it did not work. + +## šŸ”“ A defect in my own probe, recorded + +`phase_probe.py` originally scraped hit addresses with `0x([0-9a-f]{8})`. But +`gmem.py find` prints two columns — the backing-file offset *and* the guest VA — +so that pattern matched both, and half of the twelve "anchors" were file offsets +being read as addresses. The counts in the table above are the corrected ones +(the run reported doubles). The tool now matches `va 0x…` only. + +This did not change the conclusion — the one anchor that produced the shift, +`0xbd8e4e…`, was a genuine VA — but it means roughly half the probe's work that +run was spent reading meaningless addresses, and a "no hits there" from those +anchors would have been worthless evidence. + +## What to try next + +Not another string-anchored diff. The better handles: + +* **Watch a route fire.** `Route_ADN101_p1F` is a 3-frame path at t = 0, 20, 30 + from (-2500, -100, -2500) to (4241.8, 100, -1017.7). Squadron `ADN101` should + therefore appear along that path early in phase 1. Confirming that against + entity positions would tie the static route table to observed spawns, and a + route that fires only after some event is direct evidence of the trigger. +* **The sub-objective HUD strings.** `SUBOBJ_010_Mes_L1` is displayed when an + objective becomes active; finding *that* string's consumer is a path into the + objective state machine. +* The `REMAINING OB` HUD counter at `0xbdb59668` + ([structures/mission-objective-counter.md](structures/mission-objective-counter.md)) + is already known to live in the mutable region — the phase state is more + likely near it than near the tables. diff --git a/tools/re-capture/phase_probe.py b/tools/re-capture/phase_probe.py new file mode 100755 index 0000000..2f26bb0 --- /dev/null +++ b/tools/re-capture/phase_probe.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Locate the loaded stage tables in guest RAM and watch for a phase counter. + +The stage record splits a mission into Phase_1..3 and Route_S.tbl tags every +arrival path with a phase (docs/re/structures/stage-mission-tables.md). Nothing +static says what *advances* a phase, so this looks for the runtime side: find +the table strings in RAM, then diff the words around them over time. +""" +import subprocess, sys, time, re, collections + +SD = __file__.rsplit('/', 1)[0] + +def gmem(*args): + r = subprocess.run([sys.executable, SD + '/gmem.py'] + list(args), + capture_output=True, text=True, timeout=300) + return r.stdout + +def find(pat): + # gmem prints " va " per hit. Match the va column + # only -- a bare 0x[0-9a-f]{8} also catches the offset, which is not an + # address and silently doubles the anchor list with junk. + out = gmem('find', pat) + return [int(m, 16) for m in re.findall(r'va 0x([0-9a-f]{8})', out)] + +def words(va, n): + out = gmem('words', hex(va), str(n)) + return [int(m, 16) for m in re.findall(r'\b([0-9a-f]{8})\b', out)] + +NEEDLES = ['Phase_1', 'Phase_2', 'Route_ADN101_p1F', 'SUBOBJ_010', + 'AI_ADAN_CraftSquadron_Veteran', 'UnitGroup_S02.tbl'] + +def main(): + secs = int(sys.argv[1]) if len(sys.argv) > 1 else 180 + hits = {} + for n in NEEDLES: + v = find(n) + hits[n] = v + print('%-32s %d hit(s) %s' % (n, len(v), [hex(x) for x in v[:4]])) + anchors = [] + for n, v in hits.items(): + for va in v[:2]: + anchors.append((n, va)) + if not anchors: + print('NO TABLE STRINGS IN RAM -- the stage data is not resident, or the ' + 'run never reached flight'); return 2 + base = {} + for n, va in anchors: + lo = (va - 0x400) & ~3 + base[(n, va)] = words(lo, 512) + print('\nbaseline captured for %d anchors; watching %ds' % (len(anchors), secs)) + t0 = time.time() + changed = collections.Counter() + while time.time() - t0 < secs: + time.sleep(20) + for n, va in anchors: + lo = (va - 0x400) & ~3 + now = words(lo, 512) + b = base[(n, va)] + for i, (x, y) in enumerate(zip(b, now)): + if x != y: + changed[(n, lo + i * 4, x, y)] += 1 + base[(n, va)] = now + print(' t=%4ds distinct changing words so far: %d' + % (time.time() - t0, len(changed))) + print('\n--- words that changed near a stage-table string ---') + for (n, va, x, y), c in changed.most_common(40): + print(' %-32s va=0x%08x %08x -> %08x (%d times)' % (n, va, x, y, c)) + if not changed: + print(' none -- the loaded tables sit in read-only memory, so the ' + 'runtime phase state is NOT adjacent to them') + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/re-capture/phase_session.sh b/tools/re-capture/phase_session.sh new file mode 100755 index 0000000..8812f51 --- /dev/null +++ b/tools/re-capture/phase_session.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# One task: boot -> Stage 02 flight -> fly -> hunt the runtime phase state. +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}" +CFG=/tmp/nav-phase.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" /tmp/phase-pilot.log 2>&1 & + PILOT=$! + echo "--- pilot flying" +else + PILOT=""; echo "--- BIND FAILED, unattended craft (expect a short run)" +fi +python3 "$SD/phase_probe.py" "$SECS" +rc=$? +[ -n "$PILOT" ] && kill "$PILOT" 2>/dev/null +echo "PHASE SESSION DONE rc=$rc"