re: poking all three squadrons to state 4 does NOT end phase 1

Ran the direct test instead of a seventh attempt at winning. All three objective
squadrons were live (state 2); the write to +16 sticks, and 60s later with all
three reading 4 -- the value a naturally-destroyed squadron takes, measured on
ADN111 -- [ScriptPhase+196] is still 0 and the ordinal still 1.

So 'phase 1 clears when ADN110/111/112 are destroyed' is not confirmed and its
simplest form is refuted. The bytecode reading (three unit_state polls then
set_flag(8)) stands; what does not follow is that flipping the field equals the
kill.

The persistence is the clue: built-in 69 normalises +16 when it polls, so a
running condition coroutine should have overwritten the poke within a frame. It
did not, which points at the condition being evaluated only when a trigger fires.

Also corrects the per-unit record layout: +4 is 26/27/28 for the three
squadrons -- small consecutive integers, NOT the 'live object pointer' the
built-in summary describes (an undeployed squadron has +4=0). +20 = 9 is exactly
their member count n from the roster, so the record is per-squadron and carries
its strength. My own probe printed 'obj=yes' by testing that word for non-zero
rather than pointer-ness, which made an index look like an object.
This commit is contained in:
Sylpheed RE agent
2026-08-25 16:53:30 +00:00
parent d2ef1848ca
commit 852cdc1e54
2 changed files with 152 additions and 0 deletions

View File

@@ -225,3 +225,58 @@ writable (`tools/re-capture/gpoke.py`), so **set the two surviving squadrons'
autopilot is not good enough to win, and a wrong answer is as informative as a
right one — if nothing happens, the condition is not what the bytecode reading
says.
## 🔴 2026-08-25 — poking all three squadrons to "destroyed" does NOT end the phase
The direct test, run instead of a seventh attempt at winning. All three
objective squadrons were live (state 2) when the poke went in.
```
ADN110 rec=0xBCA48BC0 +4=0x0000001A +16=2
ADN111 rec=0xBCA48C60 +4=0x0000001B +16=2
ADN112 rec=0xBCA48D00 +4=0x0000001C +16=2
STICK TEST on ADN110 +16: was=2 wrote=4 after2s=4 -> STICKS
poked all 3
[+ 5s .. +60s] phase=1 finished=0 states={ADN110:4, ADN111:4, ADN112:4}
```
**The write sticks — and nothing happens.** Sixty seconds with all three reading
state 4 (the value a naturally-destroyed squadron takes, measured earlier on
ADN111), and `[ScriptPhase+196]` stayed 0 and the ordinal stayed 1.
**So "phase 1 clears when ADN110/111/112 are destroyed" is not confirmed, and
the simplest form of it is refuted.** The bytecode reading — three `unit_state`
polls then `set_flag(8)` — is solid; what does not follow is that flipping this
field is equivalent to the kill.
### 🟡 Why it probably did nothing: the poll was not running
That the poke **persisted for 60 s** is itself the clue. Built-in 69 is
documented as *normalising* `+16` when it polls, so if the condition coroutine
were running its `unit_state` polls, it should have overwritten the value within
a frame. It did not — which points at the condition being evaluated **only when
a trigger fires**, not on every frame. Poking state without firing the trigger
changes a value nobody reads.
### 🔴 The per-unit record layout is not what the built-in summary says
Dumping `ADN110`'s record contradicts *"+4 live object (NULL = absent)"*:
```
+0 = 2 +12 = 0x42480000 (50.0f) +20 = 9
+4 = 26 +16 = 4 (state) +128 = 0x3F733333 (0.95f)
```
`+4` is **26/27/28 for the three squadrons — small consecutive integers, not
pointers** (an undeployed squadron, `ADN201`, has `+4 = 0` and `+16 = 0`). And
**`+20 = 9` is exactly these squadrons' member count `n`**, which the roster
gives independently — so the record is per-squadron and carries its strength.
Earlier readings printed `obj=yes` because the probe tested that word for
non-zero, not for pointer-ness. That is a reporting bug in my own tool, and it
made a small index look like a live object.
**Not settled:** what `+4` indexes (a route or symtab-1 index is the obvious
guess, given the values), and how to make the condition actually re-evaluate.
Firing the trigger — built-in 100 pushes onto `[phase+272]` — is the next thing
to look at.

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Test the phase-1 clear condition by KILLING squadrons from outside the game.
Six flown attempts failed to reach a phase advance, because the autopilot cannot
win Stage 02 (script-runtime-probe.md). The condition itself is cheap to test
directly: the script polls `unit_state` on ADN110/111/112, so make those records
read as gone and watch whether `[ScriptPhase+196]` goes to 1 and
`[ScriptMission+40]` steps to 2.
Two candidate writes, and the FIRST THING THIS DOES IS CHECK WHICH ONE STICKS:
+16 state -- but built-in 69 "normalises" this field when it polls, so
it may be a cache that the game rewrites within a frame.
+4 live object -- built-ins 69/70/24 all test `[rec+4]` first and take an
early exit when it is NULL, so NULLing it is the more
faithful "this squadron is gone".
A write that reverts is itself a result: it means the field is derived, not
authoritative, and the poke route is closed.
Usage: poke_squadron.py <Stage02.ssb> [--field 16|4] [--value N] [names...]
"""
import os
import struct
import sys
import time
sys.path.insert(0, __file__.rsplit('/', 1)[0])
import gmem
import isl
import squadron_state as S
def poke32(f, va, value):
off = gmem.va_to_off(va)
f.seek(off)
f.write(struct.pack('>I', value & 0xFFFFFFFF))
f.flush()
def main():
ssb = isl.load(sys.argv[1])
sym2 = isl.symbols(ssb, 2)
field = 16
value = 4
if '--field' in sys.argv:
field = int(sys.argv[sys.argv.index('--field') + 1], 0)
if '--value' in sys.argv:
value = int(sys.argv[sys.argv.index('--value') + 1], 0)
names = [a for a in sys.argv[2:] if not a.startswith('--') and not a.isdigit()][1:] \
or ['ADN110', 'ADN111', 'ADN112']
path = gmem.mem_path()
size = os.path.getsize(path)
idx = {n: i for i, (_t, n) in sym2.items()}
with open(path, 'r+b', buffering=0) as f:
m, fb = S.find_mission(f, size, ssb)
if m is None:
print('ScriptMission not located'); return 1
ph = S.u32(f, m + 4)
arr = S.u32(f, ph + 324)
base = S.u32(f, arr + 4)
print('ScriptMission 0x%08X ScriptPhase 0x%08X' % (m, ph))
print('before: phase=%s finished=%s' % (S.u32(f, m + 40), S.u32(f, ph + 196)))
recs = {}
for n in names:
rec = S.u32(f, base + idx[n] * 4)
recs[n] = rec
print(' %-8s rec=0x%08X +4=0x%08X +16=%s' % (
n, rec, S.u32(f, rec + 4), S.u32(f, rec + 16)))
# --- does the write even stick? ---
probe = recs[names[0]]
was = S.u32(f, probe + field)
poke32(f, probe + field, value)
imm = S.u32(f, probe + field)
time.sleep(2.0)
later = S.u32(f, probe + field)
print('STICK TEST on %s +%d: was=%s wrote=%s immediately=%s after2s=%s -> %s' % (
names[0], field, was, value, imm, later,
'STICKS' if later == value else 'REVERTED (field is derived)'))
if later != value:
poke32(f, probe + field, was)
return 2
for n in names[1:]:
poke32(f, recs[n] + field, value)
print('poked all %d' % len(names))
for t in range(0, 60, 5):
time.sleep(5)
print(' [+%2ds] phase=%s finished=%s states=%s' % (
t + 5, S.u32(f, m + 40), S.u32(f, ph + 196),
{n: S.u32(f, recs[n] + 16) for n in names}), flush=True)
return 0
if __name__ == '__main__':
sys.exit(main())