Tracing Stage 02 phase 1 by control flow (following op 12 jumps and the six
conditional branches) gives the whole chain: timer5 @ 4.0s -> trigger 0x2D30
(which also does timer_set/timer_resume) -> start_coroutine at 0x2FE8 ->
coroutine 0xBB9C, whose body is wait_s(0.2) ; yield -> ... -> 0xF524, the
ADN110/111/112 polls that latch set_flag(8).
Exactly one of phase 1's 79 coroutine entries reaches 0xF524 under a real
control-flow walk, and it is a 0.2s polling loop. So the condition is checked at
5 Hz from 4 seconds into the phase, and my 'the condition coroutine is not
polling' explanation -- which I had promoted to real evidence after the poke
experiments -- is withdrawn.
Two weaker methods gave wrong answers first and are named: linear decode fell
through into unrelated code and implicated 30-odd entries; 'nearest preceding
entry' implicated the right one only by proximity luck, 0x3988 bytes away. And I
matched start_coroutine operands across phases at first, which is meaningless
since the operand is relative to [phase+232] -- the same per-phase base trap that
broke isl.py's jump targets, hit again three iterations later.
The poke results now need a different explanation. The likeliest is that neither
poke produced the tested state: +16 is never read by built-in 69, and +4 = 0
takes the early-exit path an UNDEPLOYED unit also takes. Recorded as a hypothesis
with the experiment that would settle it.
ssb_watch.py polls the .ssb header, a symbol string and find_mission from the
moment flight is detected. On a normal run all three are present at the FIRST
sample (t=0.0, mission 0xBC79C960), so 'the script loads later than assumed' and
'the probe raced a load' are both out -- there is no window in which a healthy
mission flies without its script in memory.
A detail worth more than it looks: at that sample screen_id reported 'other',
not 'flight'. The script is loaded and the ScriptMission locatable BEFORE the
flight HUD appears, so residency is not gated on the HUD, and a run showing the
HUD without the script is in a state a healthy run never passes through.
That leaves the third candidate: the anomalous run's mission never loaded and
its IN FLIGHT was a misdetection -- consistent with it being frozen on a black
screen when checked afterwards. Not proven, but now the only surviving
explanation rather than one of three.
From Canary's own source (x64_emitter.cc:881) GetContextReg() returns rsi, so at
any JIT instruction %rsi is the PPCContext* -- which is also why the faulting
instruction read 0x110(%rsi), a guest register load. That is the way past the
watchpoint's ceiling: the guest register file is available at the write, and a
0x82xxxxxx word picked out of it resolves against sylpheed.db to name the caller.
trigger_watch.sh now dumps x/128wx instead of a useless host backtrace.
The re-run then failed for an unrelated and unexplained reason: it reached
flight, the pilot bound, the guest was animating, and find_mission returned
NOTFOUND. Narrowed: the .ssb header is absent from guest memory (0 hits where
earlier runs hit immediately), ADN110 is absent too, but the manifest string
'Stage02.ssb' IS present at 0xBDA6C50B. So memory is readable and the manifest
is loaded while the script is not, in a mission that is flying.
No explanation offered. The cheap discriminator for next time is to poll for the
header from the moment flight starts and record when it appears, instead of
sampling once.
Hammering settles what a single write could not: hull 0x44BB8000 (1500.0f),
944,387 writes of 1 over 15s, and afterwards the value HELD at 1 -- the game
stopped rewriting it. The screen left 'flight', the HUD is gone, the ship is
burning, and Natalie radios 'I've lost contact with Rhino 3!', the player's own
callsign. The game read the poked value and killed the player.
Established: writes to /dev/shm reach the running guest; hull at pos+0x154 is
authoritative, not a readout; and a single write loses a race against the game's
own continuous writes.
This upgrades two earlier results from inconclusive to genuine negatives. The
unit-record pokes were downgraded because I could not tell 'ignored' from 'never
arrived'. The write arrives -- and those pokes persisted untouched for 60s, so
the game genuinely saw state=4 and handle=0 on all three objective squadrons and
did nothing. That is real evidence the phase-1 condition coroutine is not polling
and its checks run only when a trigger starts them.
Withdrawn: last iteration's claim that the pilot's hull= is a different field or
scale. I read 1000.0f at pos+0x154 and inferred a mismatch with the logged 1500;
this run reads 1500.0f at the same offset. Same field, different value per run.
poke_control.sh (self-retrying, succeeded on attempt 1 with no freeze) set the
player's hull at pos+0x154 to 1. Twelve seconds later the game had put
0x447A0000 back.
That establishes an asymmetry worth having: the hull word is continuously
rewritten by the game, while the unit-record fields held our value untouched for
60s. It separates fields the game maintains from fields nobody writes.
But it is not yet a control. The after-frame shows a red WARNING banner -- and
the before-frame already shows MISSILE ALERT, so the ship was under attack in
both and the banner is not attributable to the poke. A value being overwritten
proves the game writes that address, not that it read ours.
Correction: hull at pos+0x154 is 0x447A0000, a FLOAT 1000.0, not the 1500 the
pilot logs -- those are different fields or scales and should not be conflated.
The settling refinement: poke in a tight loop for several seconds so the value is
low whenever the game samples it. If hull is authoritative the ship dies and the
screen goes to GAME OVER; if it survives, the field is a readout.
The reusable part is the harness: boot -> verify animating -> locate -> act, with
a freeze at any step costing one retry rather than the iteration.
rec+4 = 0 (the early-exit path built-in 69 actually tests) sticks for 60s with
all three squadrons poked, and neither the phase nor +16 changes. +16 stayed 2
while the handle it derives from read zero.
Leading explanation: the phase-1 condition coroutine is not polling. Both pokes
persist untouched and nothing recomputes anything, which fits the polls at
0xF524 living in a coroutine that only runs when a trigger starts it
(built-in 1, fed from [phase+272]).
But the honest problem is bigger: two pokes have now produced no observable
effect and I cannot tell 'the game ignored the write' from 'the write never
reached the game'. The stick test only proves the value persisted in the
shared-memory FILE, not that Canary's guest sees it. That control should have
come first, so both null results are downgraded to INCONCLUSIVE rather than
evidence about the condition.
Next: poke something with a visible effect (player hull, a HUD counter) and
confirm it on screen before trusting any further null result. What still stands
from these runs is only what was observed rather than poked -- the arrivals,
ADN111's destruction, and the mission-over branch.
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.
Two more attempts. Recording three things rather than another flat negative.
The pilot's gun-fire rate is 1.6% (81 of 4986 frames), but that is not the
blocker: the nose gun is Power 15 unguided while the main mount is Power 200
guided, and ~70 missiles went out in ~500s. The log's fire= field tracks only
the gun and invites the wrong conclusion.
SYLPH_KILL_TURRETS=1 was tried to align DEFEND with the objective and is
REFUTED as an improvement: 3387 of 11112 samples (30%) chased targets over
20,000 units away, because turrets are static and spread out, so the pilot
commits to distant ones and defends nothing. Escort still fell to 48.5% and no
additional objective squadron died.
The bounded scan delays freezes but does not remove them -- one run clean to
694s, one frozen at ~682s, against 3-of-3 inside 4 minutes unbounded. So 'the
sweeps were the cause' is too strong; they were a cost.
Six attempts, no phase advance. Names the cheaper experiment: guest memory is
writable, so set the two surviving squadrons' +16 to 4 with gpoke and watch
whether finished goes to 1 and the ordinal steps to 2. That tests the condition
directly, and a null result is equally informative.
Bounding the pointer scan to 0xBC000000-0xBD000000 (with a full-sweep fallback)
drops find_mission from a ~371MB walk to 0.7s. The run then went 694s with the
probe attached and NO freeze, against 3-of-3 frozen inside ~4 minutes with the
unbounded version. n=1, but the first probe-attached run to survive.
State encoding pinned to three points: 1 = not yet deployed, 2 = active,
4 = destroyed. ADN111 caught going 2 -> 4 at 433s while the active count fell
36 -> 27.
The phase ended at 694.9s WITHOUT the ordinal advancing, and every field matches
the branch read statically from sub_82260710: [phase+300]=2 (last-phase flag),
[mission+20]=0 (mission-over state), [phase+196]=1 (finished), [mission+40]=1
(unchanged). The static state machine is confirmed on the live oracle for the
mission-over half.
But this was a LOSS, not a clear: GAME OVER on screen, escort at 35.7%, pilot
DEAD at 676s, and two of the three objective squadrons still at state 2. So the
'destroy all three clears phase 1' prediction remains untested. What is
established is that the else-branch is the only route to phase 2 and needs
[phase+300] != 2 when the phase ends.
Five attempts, still no phase advance observed -- the obstacle is now keeping the
escort alive, not the freeze or the instrument.
Watching [ScriptMission+40] and the three phase-1 objective squadrons together:
all of ADN110/111/112 flip state 1 -> 2 at ~143s, while records in state 2 climb
24 -> 35 over four minutes.
So state 1 means 'not yet deployed' for these, not 'gone'. The built-in table's
'1/3/4 = gone/dead/invalid' shorthand is incomplete, and reading state != 2 as
destroyed would have been wrong exactly as flagged last iteration.
This also answers a much older question: mission-arrival-watch.md and the wave
work recorded '0 confirmed arrivals' across many runs by watching the CRAFT
population. The script's own unit table shows arrivals plainly -- eleven records
enter state 2 within four minutes. The old negative measured the wrong
structure; craft counts conflate deployment with attrition, the per-unit state
field does not.
Both attempts froze (at ~70s and ~253s), so no phase advance was reached. The
freeze witness caught both immediately, which is why the truncation is visible
instead of a silently flat line.
New harness tools/re-capture/phase_watch.py.
Unblocks the phase experiment, which was stuck because '38 enemies died' could
not say whether the right ones did. Chasing craft->squadron was the wrong angle:
the script VM keeps that table itself, indexed by the .ssb symbol-table-2 index.
Route: find the .ssb header in guest memory (0xAB840010 for a Stage 02 run),
code base = filebase + 0x24, scan for a word equal to it, then VALIDATE
arithmetically -- [ScriptMission+44] must equal filebase + symtab1 offset + 4.
Measured 0xAB874C94, predicted 0xAB874C94, exact. A second candidate that also
pointed at the code base failed that check and was discarded; without it either
would have looked plausible.
ScriptPhase+324 -> +4 is an array of 122 per-unit records -- exactly the size of
Stage 02's symbol table 2, an independent confirmation of the index space.
[ScriptMission+40] reads 1 in a phase-1 mission. The mirror at
[*(0x828F35F8)+236] that three earlier runs polled reads 0, because ChangePhase
only posts once the ordinal exceeds 1 -- so +40 is the real counter and is
reachable from /dev/shm with no debugger.
Flagged rather than asserted: the three objective squadrons read state=1 with a
LIVE object pointer in a mission where nothing has been shot, which does not fit
the built-in table's '1 = gone'. Reading state != 2 as destroyed would be a
plausible-but-wrong inference; the encoding needs pinning first.
New tool tools/re-capture/squadron_state.py, verified end to end against the
manual reading.