re: probe the runtime phase state — tables are resident, phase counter is not there

Static reading had gone as far as it could: the stage record splits a mission
into Phase_1..3 and every arrival route is phase-tagged, but nothing in the data
says what ends a phase. So this took it to the oracle -- one Stage 02 flight,
160 s under the survival pilot.

Confirmed, and this is the useful half: every string the static decode predicts
is present in live guest memory -- Phase_1, Phase_2, Route_ADN101_p1F,
SUBOBJ_010, AI_ADAN_CraftSquadron_Veteran, UnitGroup_S02.tbl. 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. That is the first dynamic
confirmation of the whole static table layer.

Refuted: the phase state is not adjacent to those strings. The probe reported
862 changed words around the anchors, which looks like a signal until you read
the values -- each word takes its predecessor's previous value and every value
points into the same region. It is one block shifted down four bytes, a single
memmove in a pointer list, occurring once between t=66s and t=89s. Diffing
around a string anchor was the cheap thing to try and it did not work.

Also recorded: a defect in my own probe. It scraped hit addresses with
0x([0-9a-f]{8}), but gmem.py find prints both the backing-file offset and the
guest VA, so half the anchors were file offsets read as addresses. Fixed to
match the va column only. It did not change the conclusion -- the anchor that
produced the shift was a real VA -- but a negative result from one of those
junk anchors would have been worthless.

Not settled: what advances a phase. Next handles are watching Route_ADN101_p1F
fire against entity positions, or working back from the SUBOBJ_*_Mes_L1 HUD
strings; the phase state is more likely near the known mutable REMAINING OB
counter than near the tables.
This commit is contained in:
Sylpheed RE agent
2026-08-24 11:31:20 +00:00
parent 4441ca79ec
commit a0a0214b24
4 changed files with 192 additions and 1 deletions

View File

@@ -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:

View File

@@ -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.

74
tools/re-capture/phase_probe.py Executable file
View File

@@ -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<NN>.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 "<file offset> va <guest 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())

View File

@@ -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" </dev/null >/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"