15 is the biggest unnamed built-in: 1360 sites across 27 of 28 stages, with
an invariant (unit, double) operand shape. Joining every call site's unit to
its craft type through stage\UnitGroup_S<NN>.tbl resolves 1360 of 1360, none
unknown, and the values stratify hard:
capital hulls (Destroyer, Carrier, CruiserEX, Cruiser, Acropolis,
BattleshipEX, ASFrigate) 0-100
mobile craft (Elan 500, ArrowHead 400, DeltaSaber_T 400,
DeltaSaber_W 600, Attacker_S 250-400,
Phantom 300) 250-600
asteroid (mn040_Asteroid_Big, 74 sites) 0, only 0
Capital ships crawl, fighters run, the player's craft is fastest at 600, and
a rock never moves. That is a speed's signature.
Except turrets break it. UN_e007_ADAN_Turret carries 400 x89 / 280 x17 /
380 x4 / 250 x2 and UN_e008_ADAN_TurretPlus 450 x62 / 500 x2 -- 176 of the
1360 sites, 13%, at fighter-class values on something that does not move.
The asteroid is what makes this sharp rather than dismissible: a genuinely
immobile object gets 0 every single time, so "immobile things get junk" does
not explain the turrets.
So the operand is a craft-class-dependent scalar with speed's range and
ordering, but set_speed is not a sound reading of it. Left UNNAMED. The
handler writes [unit+196], initialised from [unit+192] at spawn; reading its
consumer is what would settle it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
96, 97, 98 and 104 were unnamed. Measured over all 28 stages they form a
family that appears nowhere outside the six tutorials:
96 8 sites S18-S23 only
97 38 sites S18-S23 only, followed by start_coroutine 27/38
98 0 sites never called anywhere
104 39 sites S18-S23 only, followed by wait_s 39/39,
preceded by end_coroutine 37/39
104's adjacency is a textbook poll loop with no exceptions: coroutine starts,
tests the predicate, waits, repeats.
96's operand is the giveaway. Its eight payloads in stage order are
S18 -> 101, 102, 103; S19 -> 201; S20 -> 301; S21 -> 401; S22 -> 501;
S23 -> 601, i.e. (stage - 17) * 100 + section. Tutorial 1 has three sections
and the other five have one each, and the tutorial index is encoded in the
argument.
Names are deliberately NOT applied. tutorial_begin / tutorial_end /
tutorial_message_pending fit the shape, and [phase+340]/[phase+344] are said
to carry the state and payload, but I have not read that handler myself and
this file has already had to withdraw five names guessed from shape. The
distribution and the argument encoding are established and are what a port
needs; the labels can wait for someone to read the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
77, 78, 81, 82 and 135 were unnamed. The engine has five contiguous strings
-- MISSION_START_PRT at 0x820A83F0, then _END_, _UPDATE_, _FAILED_,
_RESTART_ -- and five sequential ScriptPhase fields at +388/+392/+396/+400/
+404, stored in ascending order by one constructor region. Five names, five
fields, five unnamed built-ins.
Which is which is decided by call-site structure, measured over all 28
stages, and it is exact:
39 MARK_LAST_PHASE 89 sites -> 82 in 89/89
82 banner_mission_failed <- 39 in 89/89, then wait_s 89/89
40 mark_not_last 50 sites -> 78 (27) + 81 (17) + END_PHASE (6) = 50
78 banner_mission_complete <- 40 in 27/27
81 banner_objective_update <- 40 in 17/17
77 banner_mission_start 22 sites in 22 stages, one per stage,
after play_bgm
135 banner_mission_restart 16 sites, after play_bgm, phase >= 2
39 -> 82 is a perfect pairing and 40's sites partition exactly three ways.
Stated as inferred rather than read: the string-to-field pairing itself comes
from both sequences ascending in the same order; my operand tracker did not
catch the string loads in that constructor. The ROLES above do not depend on
it.
76 is left unnamed on purpose. It has 38 sites = 22 + 16, exactly 77's count
plus 135's, and precedes them; its body sets [phase+332] = 1 and nothing in
the image reads that field. Suggestive arithmetic is not a name.
Flagged as a consequence: MARK_LAST_PHASE is followed by the FAILED banner in
89 of 89 sites and mark_not_last by END or UPDATE. So [phase+300] = 2 reads
less like "this is the last phase" than "end the mission now,
unsuccessfully" -- the existing names for that pair may be mis-framing it.
Artifact regenerated: docs/re/data/isl-stage02.txt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
to_xma_riffs now emits the leading headerless segment when it sits at a whole
number of XMA1 packets and carries a non-zero byte. VOICE_D_453 goes from a
0.14 s trailing fragment to a 45116-byte leading sub-wave that dominates it.
I withdrew this exact change earlier for two reasons. Both are now answered
rather than argued away:
* "It recovers no audio" -- it used the STEREO format. At two channels every
bank yields exactly 1792 bytes, one frame, whatever its size. Mono yields up
to 113x more.
* "It matches 1524 of 8021 RIFF-bearing entries" -- the byte-level reach is
still 1524, but the audible reach is not. Across the 84 movie-bound banks
the segment adds >1 s to exactly 7, the hokyu_*_H tankers on D_453/D_454 --
precisely the broken ones -- and <=0.25 s to 66 of the rest. The largest
non-resupply addition is S04A at +0.66 s on a 256 s movie.
The safety oracle is recorded with its limits: 8 of the 84 banks ALREADY
exceed their movie's duration before the change, by hundredths of a second,
so it cannot resolve differences at that scale. It establishes scoping, not
correctness. Callers clamp to the movie length regardless.
VOICE_D_451's all-zero leading region is skipped by the non-zero guard, so
the rule cannot prepend silence to a bank that does not need it. Pinned, as
is the packet arithmetic (n = 8, 1, 7, 22, 29) which has no tunable.
slb_disc, movie_subtitle_disc and movie_manifest_disc all still pass.
NOT verified by ear -- that needs a human.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The last open question was whether the leading XMA1-mono region duplicates the
RIFF sub-wave, which would make the earlier totals double-count. It does not.
Decoding both parts of every bank to mono PCM and measuring energy:
bank leading secs / RMS riff secs / RMS
VOICE_D_450 0.49 / 158 2.82 / 9898
VOICE_D_451 0.01 / 0 1.58 / 9128
VOICE_D_452 0.31 / 301 2.18 / 9061
VOICE_D_453 2.12 / 9770 0.14 / 14462
VOICE_D_454 3.07 / 10428 0.43 / 11639
Two shapes, and no bank holds the same content twice. In 450/451/452 the
leading region is silence or near-silence (RMS 0-301 against ~9000 for
speech) and the RIFF holds the line. In 453/454 the leading region holds the
line and the RIFF is a short loud tail fragment. Sequential segments of one
clip, so the totals stand and with them the 48 kHz fit.
This also closes the mystery that started the whole thread. The corpus
recorded 450 = 2.8 s, 451 = 1.6 s, 452 = 2.2 s as plausible but 453 = 0.14 s
and 454 = 0.43 s as "far too short". The decoder skips everything before the
first RIFF: for the first three that discards only silence, so they looked
fine; for the last two it discards the line itself and leaves the trailing
fragment. One rule, two outcomes, depending on which segment holds the
speech.
The fix is now well-posed in a way the withdrawn attempt was not: emit the
leading region only when it carries signal. That also avoids the 1524-bank
blast radius that sank the earlier version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The load-bearing error of the whole voice-bank thread, and it is mine. It
stood for three iterations across two write-ups that each called the result
proven.
I treated a subtitle cue as a timestamp that must fall INSIDE the voice clip,
and concluded a 0.07 s clip could not host a cue at 4.70 s. A cue is when the
line STARTS. The voice plays from the cue, so the clip only has to fit the
window between the cue and the end of the movie. Under that reading every
bank fits at plain 48 kHz:
bank samples @48kHz cue window
D_450 158967 3.31 4.00 5.30
D_451 76084 1.59 3.70 5.60
D_452 119562 2.49 0.00 8.34
D_453 108608 2.26 4.70 4.60
D_454 167828 3.50 0.00 9.50
2-3.5 s is also the right length for the lines. Nothing is missing, and the
17091-20563 Hz window from the previous commit is void with it -- its lower
bound came from the same misreading.
What survives, because it was measured rather than interpreted: the leading
region is XMA1 mono, the decode runs to the final frame, and cue values are
seconds.
Separately settled, and it is what exposed the error: each shared bank holds
ONE generic line. The 3-5 movies bound to a bank have IDENTICAL subtitle
text, 5 banks out of 5 -- "Rhino 3 has landed. Commencing resupply.",
"Resupply complete. You are cleared for take-off!", and so on.
That also explains the historical in-game rejection of hokyu_DS_s13A ->
VOICE_D_452 that started this whole thread. The line is generic, identical
for s02A/s07A/s08A/s13A. Someone expecting a stage-13-specific line would
hear the generic one and call it wrong -- while the binding is exactly right.
The disc said so; the subtitle text now says so independently.
Still open: whether the leading mono region is additional audio or an
alternate take, since the totals above add it to the RIFF sub-waves.
Artifact: examples/shared_bank_takes.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Two things settled, one of them a correction of my own claim from the last
iteration.
The cue unit is verified rather than assumed. parse_timing computes mm*60+ss,
but only if the token really is mm:ss.cc, so I checked against an independent
oracle: the movies are on the disc and a cue must land inside its own movie.
66 English movies with subtitle tracks, 0 cues land after the movie ends.
Centiseconds would have overflowed essentially all 66. The seconds reading
stands and the verdicts built on it survive.
"The sample rate does not converge" does not. I reported implied rates of
39742 / 20563 / 23108 Hz as irreconcilable. They are not estimates of the
same quantity -- each is a ONE-SIDED BOUND. The audio must be at least as
long as the last cue, so samples/cue is an UPPER bound; it cannot outlast its
movie, so samples/movie is a LOWER bound. Intersecting:
bank samples cue movie lower Hz upper Hz
VOICE_D_450 158967 4.00 9.30 17091 39742
VOICE_D_451 76084 3.70 9.30 8180 20563
VOICE_D_453 108608 4.70 9.30 11677 23108
=> 17091-20563 Hz, non-empty. A single rate IS consistent.
I had been comparing them as competing point estimates, which is why they
looked contradictory.
What is still open, and stated as such: that window contains no standard XMA
rate. The lower bound assumes a whole bank plays inside one movie, and each of
these banks is bound to 3-5 movie slots -- so if a bank holds several takes
the lower bound is void, leaving rate <= 20563, which 22050 nearly meets.
Next step recorded: establish whether a shared bank is one line or several.
Artifact: examples/cue_unit_check.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
I had been discarding FFmpeg's stderr with Stdio::null(). It says exactly
what happens: an unimplemented "Reserved bit", then a negative bit-skip --
and the failing frame index is always the last one.
bank packets frames fr/packet fails at
VOICE_D_450 8 45.7 5.71 44
VOICE_D_452 7 29.4 4.21 28
VOICE_D_453 22 198.9 9.04 198
VOICE_D_454 29 287.5 9.92 287
So the previous entry's "the decodes are visibly partial" is wrong, and it
was mine. I read "samples per input byte ranges 2.10-4.96" as truncation; it
is ordinary XMA1 variable bitrate. Only the final frame of each stream is
lost. The decode is essentially complete.
The sample rate still does not converge. I tried the obvious repair --
counting the whole bank, leading region plus RIFF sub-waves, since the two
split the audio very differently per bank. Two banks then agreed at a tidy
~2.1x ratio pointing near 22 kHz, and the third refuted it: implied rates are
39742, 20563 and 23108 Hz.
So the container is identified, the decode is essentially complete, and the
duration still does not reconcile -- which moves suspicion to the other side
of the comparison. movie_subtitle::track_voice_cues returns (u32, f32) and I
have been reading that f32 as SECONDS on the strength of the format notes
describing mm:ss.cc cue text. If it is centiseconds, a frame index or a
per-page offset, every "audio missing" verdict inherits the error. Recorded
as the next thing to check, and to be checked BEFORE any more audio work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Retried the format probe with the fmt chunk built to synth_xma1_fmt's exact
byte layout, and with the bank's own RIFF sub-wave decoded through the same
pipe as a CONTROL so a broken harness cannot masquerade as a result.
The channel count is the whole story:
bank lead B channels=2 channels=1
VOICE_D_450 16384 1792 46756
VOICE_D_451 2048 1792 896 (all-zero region: control)
VOICE_D_452 14336 1792 30154
VOICE_D_453 45056 1792 203648
VOICE_D_454 59392 1792 294440
channels=2 yields EXACTLY 1792 bytes for every bank regardless of size -- one
frame, then it stops. That constant is the tell. At channels=1 the same data
yields up to 113x more, and the control sub-wave decodes to 13568, so the
pipe works.
Why the previous probe got 0 bytes everywhere is now named: I read
synth_xma1_fmt(2, 2, 48000)'s second argument as a STREAM COUNT when it is a
CHANNEL MASK, and built the WAVEFORMATEX around that misreading.
Also recorded as a refutation, because it was tempting: solving for the
sample rate as decoded-samples / last-subtitle-cue does NOT converge. D_453
implies 21665 Hz -- close enough to 22050 that I nearly wrote it down -- but
D_450 implies 5844 Hz. No single rate explains both, and the decodes are
visibly partial (samples per input byte ranges 2.10-4.96 where a clean decode
would be near-constant).
So the container is identified and the duration is not. Next step recorded:
find why FFmpeg stops early, likely the hardcoded packet/subframe fields.
Artifact: examples/slb_fmt_probe.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The corpus said 0.14 s is "far too short for the spoken line". That is a
judgement about audio, and audio judgements cannot be made in this container.
The subtitle tracks settle it without listening: each carries cue START
times, and a subtitle that appears at t seconds cannot sit inside a clip
shorter than t.
FFmpeg-measured (not estimated from a compression ratio -- the first version
of this example used an 8:1 guess, which is not good enough to hang a
conclusion on):
hokyu_LS_s02A D_450 cue 4.00 s audio 1.41 s MISSING
hokyu_LS_s09A D_451 cue 3.70 s audio 1.81 s MISSING
hokyu_LS_s02H D_453 cue 4.70 s audio 0.07 s MISSING
hokyu_DS_s13A D_452 cue 0.00 s audio 1.21 s no signal
hokyu_DS_s07H D_454 cue 0.00 s audio 0.21 s no signal
Three of five are decisive; the other two have their only cue at 0.0 s and
say nothing either way. So something is genuinely missing from these banks --
established independently of the leading-region work, and measured rather
than felt.
The fmt-variation probe I recorded as the next step is INCONCLUSIVE and is
written up as such: 36 combinations over VOICE_D_453's 22-packet leading
region all produced 0 PCM bytes, including ones that should be equivalent to
the crate's own synth_xma1_fmt, which does parse. So the probe tested my
hand-built fmt chunk, not the hypothesis, and it is NOT evidence that the
region is non-XMA. The retry should use the crate's helper.
Artifact: examples/voice_len_vs_subs.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The corpus recorded that some unit fields the disc leaves defaulted inherit
from a sibling: Size_Y from Size_X, FCSRange from RadarRange, DefencePoint
from AttackVesselPoint. Size_Y was marked the one to trust, on 9/9 support
across 7 independent ships, and it is restated in INDEX.md.
The premise is false. These fields are not defaulted -- they are on disc for
113-114 of 114 unit tables -- and Size_Y DIFFERS from Size_X in 90 of them.
The mechanism, cross-tabulating "legacy reader missed it" against "equal on
disc":
pair seen+differ seen+equal miss+differ miss+equal
Size_Y / Size_X 90 0 0 24
FCSRange / RadarRange 54 0 1 58
DefencePoint / AttackVesselPoint 51 0 1 61
seen+equal is 0 for all three: a value shared with a sibling is ALWAYS
invisible to the string-pool reader, because the pool stores each distinct
string once. And the reader almost never misses a value that differs. So
"the missing value equals the sibling's" was true BY CONSTRUCTION -- the rule
re-derived the very condition that made the field go missing. That is why the
support looked perfect: it could not fail on the cases it was fitted to.
The two miss+differ cells are its real wrong predictions, both named:
UN_e104_ADAN_Carrier DefencePoint is 0.2 (rule says 0.003), and
UN_e011_ADAN_Attacker_B_HF_Wayne FCSRange is 3000.0 (rule says 6000.0).
Retracted in unit-struct-runtime.md (original reasoning kept below the
correction), live-unit-definitions.md and INDEX.md. Pinned by a disc test
that asserts the seen+equal cells stay zero, so the mechanism itself is
guarded, not just the counts. Artifact: examples/sibling_rule_check.rs.
This one was found by my own check after the subagent assigned to it stalled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The structure is exact. In all five resupply banks the first RIFF sits at
HEADERLESS_DATA_OFFSET + n*2048, where 1392 is a constant this crate already
had and 2048 is the XMA1 packet size: n = 8, 1, 7, 22, 29. No free parameter
to tune, and the raw bytes agree -- high entropy from offset 0, then a zero
run immediately before the RIFF. VOICE_D_451 is the control, its single
packet being all zeros.
So I made the obvious fix, emitting that region as a sub-wave, and then
withdrew it on two measurements:
* It does not recover audio. Coverage went 5.4% -> 89.9% for VOICE_D_453, but
the emitted stream decodes through FFmpeg to 1792 PCM bytes -- silence --
while the RIFF sub-waves from the same banks decode to 150-270 KB. Byte
coverage was the wrong success metric and it looked like progress.
* It is not narrow. The rule matches 1524 of the 8021 RIFF-bearing entries in
sound.pak, including RT* movie banks that decode correctly today. Landing
it would have risked a wide regression in order to not-fix five banks.
to_xma_riffs is back to its previous behaviour, verified by re-measuring:
coverage is 5.4% / 9.7% again. The refuted attempt is recorded in the code
beside the branch it would have changed, so the next person does not
re-derive the arithmetic and re-make the change.
XMA1_PACKET is kept as a named constant because the blast-radius scan uses
it. Artifacts: examples/voice_bank_shape.rs (structure), voice_bank_dump.rs
(sub-waves for decoding), slb_hybrid_scan.rs (the 1524 count).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
movie_manifest::parse now reads BASE_INFO's positional field keys (the game's
own cutscene ids, stage*100 + slot) and follows each to its record, instead of
scraping the string pool. The pool stores each distinct string once, so a
REPEAT reference produced no token and read as "no binding".
That single cause explains every wrong cell: 13 later references to
VOICE_D_450..454, two to SUBTITLE_hokyu_LS_s11A.tbl, and MS01A's share of
pwterop_s01a.prt. All 18 hokyu movies are bound, not five.
Counts, verified independently by me against the disc before recording:
104 cutscene SLOTS binding 101 distinct MOVIES; 99 slots / 96 movies with a
voice track, 99 / 96 with a subtitle, 22 / 22 with a telop. The docs' old
94 / 83 / 21 are exactly the counts of DISTINCT POOL STRINGS -- not wrong
measurements, measurements of the wrong thing. Three denominators were being
conflated; the new test pins all three.
Two assertions in movie_manifest_disc.rs were false and are corrected:
hokyu_DS_s13A binds VOICE_D_452 and resolves to eng\etc\VOICE_D_452.slb. The
in-game verdict that rejected that value tested an INFERENCE from a shared
demo id, on a decoder that discards 85-87% of banks in this class -- see
voice-bank-leading-region.md, committed earlier today.
The ~104 script ids are no longer open: they are literal positional keys,
each naming its record, and all 104 resolve. The old "counts differ by three,
positional pairing does not work" has a concrete cause -- three resupply
movies are bound by TWO slots each.
Also corrected: the naming convention has 3 subtitle exceptions (s24A/s27A
borrow s11A's track) and 18 voice exceptions, not one and five.
The legacy scraper is kept as a fallback for blobs with no record table, so
the synthetic unit fixtures still exercise it.
Artifacts: examples/movie_map_csv.rs regenerates the CSV, now slot-keyed
(104 rows; the movie-keyed version silently dropped one slot of each
duplicate). Disc tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The record table gives a DIRECT binding hokyu_DS_s13A -> VOICE_D_452, where
the corpus records the movie as unbound and movie_manifest_disc.rs asserts
None, citing an in-game verdict that this exact value was "the wrong
recording". That is the only place on the disc where a runtime observation
disagrees with the record table, so it was worth settling.
First, shape: these banks are SHARED. Five slots bind VOICE_D_452, five bind
451, four 450, four 453, three 454 -- 21 hokyu slots over five banks, and the
movies repeat too. Generic resupply cutscenes, not per-stage recordings.
The recorded explanation for 453 decoding to 0.14 s and 454 to 0.43 s was
that the banks are "likely multi-subwave / not cleanly sliced". Refuted: the
count of RIFF magics EQUALS the number of sub-waves recovered in all five
banks, and the last data chunk ends exactly at EOF in four of them. Nothing
between or after sub-waves is being missed.
The real defect: slb::to_xma_riffs finds audio by searching for the RIFF
magic, and a large region PRECEDES it. 87% of VOICE_D_453 and 85% of
VOICE_D_454 sit in front of the first RIFF -- 21-27% zero over 256 distinct
byte values, i.e. content, not padding. VOICE_D_451 is the control: its
leading region is 100% zero, 1 distinct value, real padding.
So the in-game verdict listened to a decode that had discarded most of the
bank, for exactly this bank class. It is evidence about the decoder, not
about the mapping. Note also that what was rejected was a value INFERRED
from a shared demo id; the record table supplies the same value as a stored
field, and only the inference was ever tested.
This does NOT establish the binding is right -- it removes the only recorded
evidence against it. What the leading region actually holds is undecoded, and
confirming the binding needs a human listening.
Artifact: examples/voice_bank_shape.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Verified rather than adopted: a subagent proposed that every even operand
slot is a type tag. Measured, the strong form is false and a precise form is
true.
TRUE: a SYMBOL operand is two words, a tag holding the constant 1 followed
by the index. Slot 0 is the integer 1 in 19899/19899 calls whose slot 4 is a
unit; slot 8 is tag-shaped in 100% of calls for every built-in taking a
second unit; slot 16 is 1 in 152/152 for built-in 128, the only one taking a
third. The 24 built-ins whose slot 0 is NOT the constant are exactly those
taking no symbol there. This explains the unit slots 4/12/20 rather than
replacing them.
FALSE as stated: slot 8 is a bare double for built-ins 4, 20, 24, 26, 28,
29, 90, 106 and 127, and built-in 75 carries five bare indices at 0/4/8/12/16
with no tags at all. Each built-in has a fixed signature and is 100%
self-consistent; none of the 34 with >=20 sites mixes the two.
Symbol table 1 has three types -- 1 routes (1362), 6 messages (2247), 7
effects (81) -- and its operand slots are type-pure, measured the same way.
Resolving them makes listings say what the script means:
`request_script_message(MSG_VOICE_D_257, ...)`, a fourth independent
confirmation of that name. Slots 24@4, 46@12 and 114@4 resolve 100% but MIX
types 6 and 1, so they are left unresolved rather than guessed.
Two more names withdrawn, neither replaced:
* 88 `camera_at` -- ZERO call sites in all 28 stages; never testable.
* 90 `camera_at_route` -- 8 sites, all Stage 02 phase 3, first operand is
symtab-1 type 7 `eff_n0071`, an EFFECT name, in 8/8, with a per-missile
Route_ADT301..308_p3M at slot 20. Not aimed at a camera.
Left unnamed on purpose: replacing a guessed name with another guess is how
the three names corrected earlier today went wrong.
Also flagged: 115 `named_event`'s only symbol operand is an eff_* name in
84/84 sites, so that name is suspect too. Not renamed pending a handler read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Last iteration I said launch_mission died because skip_intro only tests for
the title on a static frame, gated at rmse <= 1500, and that run logged 1503
and 1549 just above the cut. I also said the fix was NOT to nudge 1500 but
to measure both signals through a boot first. Measured, and the diagnosis
does not survive.
boot_trace.sh logs the two signals skip_intro decides on -- frame-to-frame
RMSE and the is_title.py green-glyph count -- through a clean boot with no
presses at all. One run, 29 samples over 484 s:
8 samples had rmse <= 1500, so the gate OPENED eight times
0 samples had glyph > 0, so the title was never seen
At t=145 s the RMSE was 1205, comfortably inside the threshold, and the
glyph test was called and answered zero. A frame can be perfectly static
without being the title -- the intro movie has long quiet stretches, three
reading RMSE exactly 0. So 1503/1549 were almost certainly movie frames too,
and raising the constant would have admitted two more of them.
What is left is narrower and honest: the interactive title never appeared,
rather than appearing and being missed by a threshold.
The limitation is recorded rather than buried: the tracer intended 1 s
sampling and achieved 16.9 s, because each iteration forks two screenshots,
ImageMagick compare and a fresh Python. So this does NOT prove the title
never appeared -- only that it was absent from 29 samples. A window shorter
than ~17 s falls between them. The recorded next step is to make the tracer
sample at the rate it claims before concluding anything stronger.
Artifact: docs/re/captures/boot-signal-trace.tsv.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Third independent line for yesterday's built-in 100 rename, from the callee
this time. sub_8226E3B8 was labelled "push", which is what made built-in 100
look like push_trigger. It reads the element count, returns immediately when
the container is EMPTY, and otherwise walks the node list splicing nodes out
until it is empty. A push links one node; this unlinks all of them. It is
clear(). The append is sub_8226E160, reached from built-ins 19 and 25.
So the rename now rests on the handler, the usage (all 12 Stage 02 sites sit
in the phase terminator next to timer_stop / clear_flag(-1) /
MARK_LAST_PHASE), and the callee.
The dynamic half did NOT run, and the write-up says so. phase_watch.py now
samples [phase+272+20] (triggers queued) and [phase+216+8] (coroutines
alive) so a phase terminator's effect on the VM is visible in one line —
written here, never yet exercised against a live guest.
Boot-nav could not reach the title in 381 s. Diagnosed rather than retried:
skip_intro.sh only runs the title test on a static frame, gated at
rmse <= 1500, and this run measured 1503 at 104 s and 1549 at 139 s — just
above the cut — so is_title.py was never called and the one allowed press
was never spent. Recorded in BACKLOG with the explicit instruction NOT to
raise the constant: the first step is to log rmse and the glyph count
through a whole boot and look at the two distributions, because tuning a
threshold to make one run pass is fitting to a single sample.
Also reaped a stale lock: a gdb orphaned 2h14m earlier was holding
/tmp/xenia-canary.lock with an already-defunct emulator child.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
All re-read twice — the handler, and the thing it calls — because each had
been named from its shape rather than its effect.
* id 11 `yield` -> `end_coroutine`. 0x82272624 is li r11,1 ; li r3,3 ;
stw r11,164(r31), and the dispatcher's r3==3 arm erases the thread from
the active list and returns it to the free list. It destroys the thread.
2945 sites game-wide, 372 in Stage 02 — the most-used built-in there was.
* id 5 `await_label` -> `kill_coroutine(label)`. sub_82273B08 kills the
thread parked at the target pc, or itself if the target is its own pc.
It waits for nothing.
* id 100 `push_trigger` -> `reset_phase_threads`. It clears the trigger
container and then frees every thread whose pc differs from the caller's
— the opposite of pushing a trigger. Corroborated by usage: its 12 Stage
02 sites all sit in the phase terminator, next to timer_stop,
clear_flag(-1) and MARK_LAST_PHASE.
One name recovered from the game's own text: opcode 992 prints
"RequestScriptMessage %s" at 0x820A5700, so id 64 is request_script_message
(2683 sites).
Return codes documented properly: 1 = restart the coroutine from its entry
(previously not recorded at all), 3 = terminate. And the blocking set was
wrong in two places — it is 102, 120, 137, 142, 143. Id 97 does NOT block;
its handler ends `b 0x822724F8`, so it always returns 0.
Unit-operand resolution settled from DATA over all 28 stages rather than by
reading 147 handlers: a slot qualifies only if every value is a valid
symtab-2 index, it takes >=15 distinct values, AND its maximum reaches most
of the table — that last clause is what discriminates, since every small
integer is trivially "in range". 31 built-ins at slot 4, 8 at slot 12, one
at slot 20. It also refutes set_flag's slot 0, whose maximum overruns the
table, and the resolver now declines rather than inventing a name.
New and unexplained: symtab-2 holds two types, 2 and 8, and built-ins 95 and
128 take type 8 at slot 12 in 100% of their sites.
A downstream inference is withdrawn with it: the note reading the live
trigger counter attributed it to "the script arming watches as it goes" via
built-in 100. The measurement stands; the attribution does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
With the record table decoded there is finally a ground truth to check the
old string-pool reader against. It infers `key -> value` from pool adjacency,
which is a consequence of how records are written, not a rule of the format.
Verified by hand against the disc, with an independent parser:
* `FCSRange` = 500000.0 — the module docs' own canonical example of a field
"left at its default" that "omits the value string".
* `ShieldRatio` = 1.0, where `tests/pak_idxd_disc.rs` asserted None with the
comment "a defaulted/omitted field must be None". That test encoded the
false belief; it now keeps the None as a deliberate characterisation of the
legacy reader, with the true value asserted beside it.
* `get_raw("Model")` on GP_HANGAR_ARSENAL returns the first record's model for
every record — silent corruption, not an absent value. New test pins four
records that disagree with it.
The cause is the flat API having no way to name a record: only 548 of 6325
objects have one. `HP` on the DeltaSaber answers 1000.0, the hull, while 63
Turret_* records each carry their own 100.0 (measured — a first draft said 34,
taken from a report rather than from the disc).
Disc-wide rates are recorded as single-source and labelled as such: get_raw
52% wrong, typed getters 38% miss, but 100% correct on single-record objects.
Also records a negative result: the 504 unnamed field keys were NOT recovered.
A 572464-string dictionary and 73191 variants gave 0/42. The key deltas do
prove the preimage ends with the two decimal digits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The binary region in front of the string pool was the parser's oldest open
note ("Not yet decoded"). It is a uniform 16-byte record array sorted by
name hash, a field count, a 12-byte field array sorted by key, a pool size,
and the pool. The trailing `pool_size == file_len - pool_base` identity makes
the layout self-checking, which is what caught the first wrong version.
Verified over the WHOLE disc with zero failures: 7750/7750 IDXD objects,
190782/190782 records reproducing their stored tag_hash, 1271462/1271462
named fields reproducing their key. IXUD is the same container with
ixud_hash, UTF-16BE and every offset in chars — 1104/1104 objects,
628165/628165 fields, checked with an independent parser.
Field names are stored on disc, so no preimage search is needed: a field's
middle word points at its own name. Only 504 fields disc-wide are hash-keyed
with no name; the other 1485073 nameless fields are positional, keyed by a
literal integer (line slots, movie ids).
Two long-held beliefs are WITHDRAWN:
* The word at 0x08 is not a schema hash. It is record 0's name_hash — the
format has no type field at all, and an object's kind is known only from
the caller that loads it. It survived as "schema" because tables of one
kind share their lowest-hashed record name. Caught by a test asserting
every movie id names a real record: 1005 -> STAGE10_PHASE01 failed because
tag_hash("STAGE10_PHASE01") IS 0x067025B9, that table's supposed schema id.
* The field's middle word is not an always-0xFFFFFFFF flags word. It is
0xFFFFFFFF for 54% of fields, enough to look constant in a small sample;
the tell was that it is constant per key ACROSS records, which a per-record
flag cannot be but a per-name pointer must.
`schema_hash` keeps its name rather than churn 33 call sites, with corrected
docs. The first sweep globbed dat/** and missed hidden/DefTables.pak (1425
objects); the test now walks the whole disc root.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
534 IXUD entries across 36 paks, 94 distinct keys, 92 resolved by name. Content
identified: mission objective/lose/hint panels, 91 bonus-objective banners,
scripted radio, cutscene dialogue, the reactive combat-chatter pool (MSG_ADAN
23,236 fields, MSG_RHIN 21,196, ...), resupply barks, speaker labels and the
hangar weapon Name/Desc/Condition.
Verified independently: language\MissionDialog_local_string.tbl decodes to 600
fields with the trailer identity holding, giving Stage 02's real objectives --
phase 1 'Shoot down all invading enemy fighters / while watching out for attacks
/ on the ACROPOLIS', phase 2 'Protect the Admiral's ship the CALIBAN', phase 3
'Destroy the interstellar cruise missiles'. That matches the script exactly: the
phase-1 condition polls three enemy squadrons and the escort's loss is the fail
branch, and phase 2 is positional, which is why no kill count appears there.
The .ssb symbol names (S02_P1_OBJECTIVE etc.) are NOT IXUD keys -- they are
records in an IDXD map, message\MissionDialogMessage.tbl, whose positional
fields list the lowercase per-line IXUD names; *_GRAPH holds a texture instead.
Structure: one record per object (except the six 63-record hangar files),
624,440 named fields, 55% of them empty line slots, no control characters, the
only escape a literal two-char \n in block-text tables, and records/fields in
ascending key order so lookup is a binary search. '[OB]' turns out to be a HUD
marker label in this text -- the same one the REMAINING OB work chased.
Six languages confirmed D/E/F/I/J/S; J omits the trailing empty line slot in 78
of 86 objects. Two keys remain unresolved, byte-identical across all six paks
(untranslated Japanese voice transcripts).
Also corrects mission-objectives-text.md: SUBOBJ_009 does have text, and
SUBOBJ_005 is two fields.
Three come from the game's own strings, verified present: 'Set Squadron order
attack/trace/escort' at 0x820AF17C/1A4/1CC, loaded once each at entry to the
unit message pump sub_8232C4C0 so the pairing is unambiguous. The printf is
compiled out (bl 0x82674028 = li r3,0; blr) but the format strings and arguments
survive, which is what makes them usable as labels. The same poster functions
serve both the message path and the interpreter-command path, so the identity
transfers to opcodes 517 trace / 784 attack / 790 escort, i.e. ISL built-ins
46/47/48, now named in isl.py.
The fourth, opcode 512, is recorded at LOWER confidence on purpose: those strings
name only three. Its label rests on the route-generation strings on its own
vtable, a symbol-table-1 (route) argument rather than the unit table, a route
point-count lookup, and the pre-existing move_order label -- four independent
lines, but none of them the game naming it.
Also records a trap: sub_82320B48's ORDER_{WINGMAN,SQUAD,SQUADRON}_{FORMATION,
ATTACK,COVER,EXTENDED} enum looks like it should map onto the order classes and
there is no evidence it does. It is a voice/comms axis; not connected.
Settled from the disassembly, no run needed. Built-in 69's tail maps the
lifecycle lookup into [phase+164]: handle == 0 takes the early exit at
0x8226AF44 and returns 0, while the destroyed states return 2, 3 or 4 (and two
of those also normalise the record's +16). Those are different values.
So poking rec+4 = 0 made the predicate report ABSENT -- the same answer an
undeployed unit gives -- and never the answer the script branches on. The
condition was polling at 5 Hz throughout and correctly saw 'not here'.
Both null results are now fully explained, and neither was evidence about the
condition: the first poke wrote a field nobody reads, the second wrote the wrong
value into the right field. Simulating a kill needs the handle to stay valid
while the lifecycle lookup returns 3/4/5, i.e. the write belongs in whatever
sub_82301240 reads, not in the script's own record.
Also names built-ins 46/47/48 as squadron_trace / squadron_attack /
squadron_escort in isl.py.
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.
Disassembling all 25 timer triggers in threshold order turns the phase into
readable script: fade, BGM, then squadron deployments at 30/60/90/120/170/210/
240s each as deploy + move_order + objective_marker, with radio messages
interleaved and a late block at 1020-1170s.
Answers the open question from the previous entry: timer 0 is armed by
timer_set(1200, 180) followed by timer_resume, fired at 4.0s on the phase-intro
clock (timer 5), which is already running when the phase begins. So the mission
clock has a 1200-second limit.
And it independently confirms the arrival measurement: the timer0 @ 170.0s
trigger deploys symbol index 0x01 = ADN110, the first of the three squadrons the
phase-1 condition polls. The live run measured those three going active at
~155-165s of mission time, and the route table also says 170. Three independent
sources agree -- the route table, the trigger table, and the running game.
Open: whether a coroutine can re-arm its own trigger, and timer_set's second
argument (180), for which 'warning threshold' is a guess rather than a finding.
sub_822748D0, called from ScriptPhase::Update every frame, walks the table at
[phase+240]: stride 24, each record carrying a coroutine offset (rec+4), an f32
threshold (rec+12) and a timer index (rec+20). It compares the previous and
current copies of the 32-entry float register file ([phase+104] and [phase+88])
and starts the coroutine at codebase+off on a RISING EDGE, prev <= t < cur, so
each fires once. Verified instruction by instruction.
The table comes from the mission-level begin_phase opcode 0x83's fourth operand
(the fifth is the phase end-event); sub_82270DF8 stores them at [phase+240] and
[phase+236]. On disc it is tagged constants -- 0x819 int, 0x81A float, both past
the ISL dispatcher's bound so they never execute. Verified on Stage 02: counts
25/13/18 with the tag triple correct in every record. Only timers 0 (1-1170s,
mission clock) and 5 (0-5s, phase intro) are used corpus-wide.
This explains the poke results. Two experiments set a squadron to 'destroyed'
and nothing happened; the leading explanation was that the condition coroutine
is not polling. It is not -- coroutines are started on a schedule by timer
crossings, so state written between firings is read by nobody.
It also reframes the arrival timetable: the routes' t=170 and these thresholds
are the same kind of thing. The mission is substantially a timeline, with unit
predicates deciding what happens at each scheduled point rather than when.
Open: which built-in arms or resets each timer.
I wrote that no mission counts kills. Too strong. What holds is narrower: no
mission reads the two GLOBAL counters (33/34, verified 0 sites). The game does
track kills and missions test them through attribution:
built-in 105 killed_by_player(unit) -- reads [squadron+600], which the
unit-death handler sets from the damage message's _BY_PLAYER flag -- 117 sites
across 14 stages (S02: 38, S01: 32), the 17th-most-used built-in in Stage 02.
built-in 72 -- numerator is a per-(killer, victim) squadron KILL COUNTER,
incremented in the unit-death handler; 13 sites, S01 only.
So 'did the player get that kill' is a common predicate; the game just never asks
'how many kills in total'. Evidence for 105 is the game's own strings: the death
handler branches into KILLED_BY_PLAYER vs KILLED, beside LOST_TURRET_BY_PLAYER,
SHIELD_DOWNED_BY_PLAYER and the rest.
Also withdraws the 'current / initial members' gloss on built-in 72: it is
100 x (units of B killed by A) / (declared members of B), attributed kills rather
than survivors, and reads 0 unless the script registered the (A,B) pair.
Built-in 55 resolved and shown to be effectively dead (0 sites corpus-wide);
built-in 75 confirmed as five HUD text lines via a [phase+376] dictionary.
sub_822FE040 is a fully unrolled registration: 1023 slots filled with a default,
then 57 explicit writes, of which 48 are real handlers and nine are a shared
accept-and-discard stub. Return convention is nonzero = consumed, 0 = retry,
which is how the interpreter waits for a named unit to exist.
Opcode 995 is the ONLY handler touching the phase mirror [*(0x828F35F8)+236] --
the sole read and sole write in the table -- independently confirming why polling
that mirror saw nothing during phase 1. And no handler spawns or despawns a unit:
256 is the strongest deploy candidate but is unconfirmed because the message ids
are write-only in this image.
WITHDRAWN, verified wrong: I had recorded the writes to '+20' in sub_8226E7D8 /
sub_8226E930 as block initialisations by container constructors. At
0x8226E86C-0x8226E8E0 they do li r3,28 / bl 0x8230C160 then
lis r10,0xAB03 / ori r7,r10,0xE4BA / stw r7,4(r3): they build an INTERPRETER
COMMAND RECORD for opcode 996 and push it, i.e. AddSelector and RemoveSelector,
with a 32-entry cap. The stw to 20(r3) is the command record's +20, a different
object. Wrong twice: not constructors, and not that container.
Also flags that sub_8230C398 -- gated on *(0x82899CE0) == 16 at both call sites
-- looks like Stage 16's script compiled in C++, which 'debug defaults' does not
survive given the .ssb loader explicitly refuses mission 16.
Chasing which of sub_8226EAB8's 16 callers grows the trigger count: sub_8226E458
decrements a count at 8(r30) then calls the insert helper -- a splice -- and has
exactly one caller, inside sub_8226D740, the per-frame engine->script drain. That
suggests the engine moves records into the phase queue each frame.
But the call site refutes the neat reading: at 0x8226D780 the argument is
lwz r4, 324(r29), the UNIT ARRAY, not the trigger container. So I have not shown
sub_8226E458 touches the trigger queue at all, and 'the engine feeds triggers
each frame' is my inference rather than the disassembly's.
Recorded unresolved. Taking a function's shape for its purpose is what produced
the 'push' mislabel on sub_8226E3B8 and the ADN110-for-null pretty-print, each of
which cost an iteration.
Unaffected and solid: sub_8226EAB8 increments a count at +8 of its container, the
trigger container embeds its list at +12, so the watched word at phase+272+20 is
that inner list's count, and the guest was inside sub_8226EAB8 at the write.
Next: instead of guessing among 16 callers, re-run the watchpoint and read the
guest LR from the context -- the technique that named the writer will name its
caller.
The watchpoint plus Canary's source settle it. At the write, the guest context
(rsi, per x64_emitter.cc:881) holds 0x8226EAE0, inside sub_8226EAB8. That
function is a generic list-node insert: it reads the count at 8(r30), guards
against 0x3FFFFFFF overflow, does addi r11,r11,1 / stw r11,8(r30), then links the
node. It has 16 callers, so it is a shared container helper.
That explains why two static searches missed it. The trigger container at
phase+272 EMBEDS an inner list object at +12 -- which is why the push does
'addi r31, r30, 12' -- and the inner object keeps its count at its own +8. So
272 + 12 + 8 = 292 = the watched word, and the instruction is stw r11, 8(r30)
with r30 = phase+284. Searching for 'stw rN, 20(rM)' could never have found it.
Also resolves the earlier open item on +12: it is the embedded list object, not
a list head pointer, which is why it read 0x000A0009 instead of an address.
Method note kept: the static hunt assumed the field's offset in the OUTER object
would appear in the writing instruction. A watchpoint is indifferent to the
addressing form, which is why it was the right tool after two failed offset
searches.
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.
All 28 StageNN.ssb decoded: 2,085,628 bytes, 25,705 call sites, 108 of 147
built-ins used. Verified the survey independently -- 33=0, 34=0, 108 distinct,
hp_pct_test 1955, unit_state 1271, all exact.
global_counter0/1 have ZERO call sites in all 28 stages. The handlers exist and
are wired; no mission calls them. So 'does a wave start after N kills?' is
answered for the whole game, not just Stage 02: no mission counts kills.
Two idioms change how the counts read. hp_pct_test(unit, 0.0) IS a destruction
test -- 1786 of 1955 calls (91%) pass 0.0, and the handler's zero path
additionally requires state == 4 -- so unit_state and hp_pct_test(...,0) are
interchangeable and stages just pick one. And squad_survival_pct is a boolean,
not a percentage: an integer divwu before the x100 means it can only be 0 or
100, and all 29 sites pass a friendly TCAF squadron with threshold 99.9, making
it 'has this escort lost anybody'. The game has no destroy-N%-of-a-squadron
objective.
Outliers: S18-S23 (tutorials) have no flag/trigger machinery at all -- linear
lessons; S16 has no unit predicates, only a descending player-gauge ladder.
Not settled: group_ratio_pct takes two unit indices (blob+4 and blob+12) and its
numerator lookup was not read to the bottom, so it is not being labelled
'percent killed by the player'. isl.py's UNIT_ARG omitted 71/72.
trigger_watch.sh + host_addr.py translate the guest VA to a host address
(0xBE14DEA4 -> host 0x1BE14DEA4) and watch it. It fires: Thread 50 'Main
XThread', old 0, new 16777216 -- which is big-endian 1 read little-endian, so
the count going 0 -> 1, independently confirming the field. The write happens on
the guest's own main thread, not an emulator worker.
But the writer cannot be named from the host stack: the faulting PC is
0xa0c65f23 with no symbol, executing 'mov 0x110(%rsi),%rbx', i.e. Xenia's
JIT-compiled guest code, and the frames above it are not host-unwindable. So the
watchpoint answers when and which thread, not which guest function.
Recorded as a ceiling of the method rather than retried blindly. The way past it
is that the JIT holds the guest context in a register (%rsi here), so the guest
PC is recoverable from the context block -- which needs Xenia's context layout
from the xenia-rs sources on this box, a separate tractable piece of work.
All 25 opcodes now have meanings. Ops 2/4/6/8 are integer compound assignment
(+= -= *= /=) and 3/5/7/9 the float versions; 10 and 11 are integer and float
compare writing three condition bits; 13-18 are je/jne/jl/jle/jg/jge; 21-24 are
push.i/push.f/pop.i/pop.f over deques at phase+44 and phase+64.
The shared-handler question is answered: the dispatcher leaves the opcode in r4
and the shared thunks never overwrite it, so those helpers take an extra opcode
argument and index a secondary table (0x82271448, 0x8227152C).
CORRECTION to my own tool and note: the branch/jump base is [phase+232], which
the phase initialiser sets to 0x24 + the phase's entry from the mission-level
stream -- 0xE4 / 0x14AA8 / 0x24B4C for Stage 02's three phases, not the file's
0x24. Measured on phase 1: base 0xE4 puts 525 of 525 branch targets on an
instruction boundary; base 0x24 manages 188. isl.py had been using 0x24 for
every phase, so its jump targets were wrong throughout. Fixed via
isl.phase_bases().
That also settles two things mission-script-ssb.md left open: offsets ARE
code-base-relative, and 0x1883's operand IS a code pointer -- the earlier worry
that some 'land on IEEE floats' was an artefact of adding the wrong base.
Three approaches, none successful. sub_8226E160, earlier flagged as 'enqueue a
pending trigger', has exactly one caller and is a specific operation rather than
the general append. Writes to the count at +20 inside the container code number
only four, and all four are part of a block initialisation (stw to 0/8/12/16/20/
24 in consecutive instructions) in sub_8226E7D8 and sub_8226E930 -- constructors,
called from 0x8226E560 and from ScriptMission's own constructor at 0x822608A0.
So the increment that takes the count 0 -> 1 -> 2, which is measured live, does
not appear as a plain stw to 20(rM) anywhere in the container's code. It is
inlined, uses another addressing form, or lives somewhere I have not looked.
Recorded as not found rather than guessed: inferring from the shape of nearby
functions is exactly what produced the 'push' mislabel last iteration.
Names the approach that would settle it: a gdb watchpoint on
ScriptPhase+272+20 during a live mission. The address is known at runtime, the
count demonstrably changes within ~2 minutes, and a watchpoint reports the
writing instruction directly instead of inferring it from static shape.
Walked the container's list from +16 for 200s. The structure holds: +20 tracks
the node count, nodes chain through their first word, and entries appear as the
mission runs (0 -> 1 -> 2, then stable).
The record layout does not. I expected node+8 to hold small symbol indices,
which the pop's out-parameters made natural. Every field is a guest heap pointer
(0xBC..), so trigger records reference objects rather than table indices, and
those objects are unidentified.
Also records a false resolution I introduced: a line printed 'f4=0(ADN101)'
because the raw value is 0 and my formatter mapped index 0 to symbol-table-2's
first entry. ADN101 is not in that record -- the pretty-printer invented a name
for a null. A resolver must refuse values that were never indices.
And corrects the previous section: sub_8226E3B8 is a CLEAR, not a push. Its tail
decrements a counter, calls an erase helper, and loops while [+20] != 0. So
built-in 100 clears the queue then rebuilds the thread list, matching the
built-in table's own wording; 'push' was my label, not the disassembly's. Two
callers: vt2 (script) and 0x8226D420 (an engine site). What appends a node is
still unidentified.
Read from a running Stage 02 mission: the container at ScriptPhase+272 has +20
moving 0 -> 1 -> 2 over the first two minutes while the phase ordinal stays 1.
So +20 is a real count of currently registered triggers -- the script arming
watches as it goes, and Stage 02 has 12 push_trigger sites -- and it is readable
from /dev/shm with no debugger. First direct view of what the script is waiting
for.
Corrects one field from the static reading: +12 is not (only) a list head. It
reads 0x000A0009, which is not a pointer, even though the push's
'addi r31, r30, 12' made list-head the obvious interpretation. Recorded as
unresolved rather than kept quietly.
Chasing what makes the phase-1 condition re-evaluate, since the polls do not run
continuously.
Two method corrections: searching the VM range for '272(rN)' mostly returns
VTABLE slot offsets -- 0x82273174 lwz r11,272(r11) is followed by mtctr/bctrl,
a virtual call through slot 68, not an access to the phase field. And
[phase+272] is not a pointer to a queue but an EMBEDDED container: vt2
(sub_82265DD0) is 'addi r3,r3,272 ; b 0x8226E3B8', passing phase+272 as this.
Layout from the push/pop pair (sub_8226E3B8 from built-in 100, sub_8226E220
called every frame from sub_8226D740): +12 list head, +16 current node, +20
element count (zero = empty, tested first by the pop), +24 scratch. The pop
returns the record through out-parameters read from node+8: three u32s, a
double at +16, another u32 at +24 -- matching the six pointers sub_8226D740
passes in.
The actionable part is [phase+272+20], a live pending-trigger count readable
from /dev/shm. Watching it alongside [ScriptMission+40] should show when the
engine hands the script an event, which is when condition coroutines start --
the thing every phase experiment so far has been blind to.
Layout is from disassembly only; not yet verified live.
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.
This iteration set out to run the positive control the poke results need -- poke
the player's hull, which the pilot logs every sample, and confirm the guest sees
it. It did not run: the bind took two attempts (first fwd_cos -0.94, second 1.0)
and by the time the player was being located the guest had frozen, with
entities2 reporting '0 moving triples' and frozen.py confirming
max_pixel_delta=0.
Session tally: froze at ~70/126/150/253/610-682s and this run; ran clean at
694s (ended by the game), 936s and 1064s. Roughly two in three freeze, each
costing a ~5min boot plus the window. The freeze has now truncated more
experiments than every other cause combined.
Restates that it is probably not ours: the mission-end freeze is recorded as
pre-existing in both our build and the official AppImage, and this session
produced a pilot-only freeze at 150s with no probe attached. The
probe-correlation lead is real but never became a clean split.
Consequence: experiments needing more than ~2 minutes of live mission should
checkpoint and resume, or detect the freeze and re-run themselves. Every tool
here witnesses the freeze; none survives it.
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.
Disassembling built-in 69's handler at 0x8226ADF0 instead of trusting the
summary: it loads rec = base[idx], tests rec+4 against zero (early exit =
absent), passes rec+4 to sub_82301240 for the lifecycle lookup, and compares the
byte at rec+104. rec+16 is never touched on that path.
That explains the null poke result exactly -- I wrote into a field the condition
does not consult. +16 remains a faithful OBSERVABLE (the arrival and death
transitions were real) but it is a readout, not the input. It also explains why
rec+4 holds small consecutive integers: it is a handle that sub_82301240
resolves, not a pointer.
The corrected simulation of 'this squadron is gone' is rec+4 = 0, taking the
documented early exit.
Flags a general caution: this built-in table came from a subagent's reading of
handler behaviour, and this is the second per-offset field description that did
not survive the disassembly. The identifications of which built-in does what
have held up; the offset meanings are leads to verify.
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.
A pilot-only run froze at t~150.7s (frozen.py: max_pixel_delta=0), found by
accident when the sweep-free experiment aborted at startup and the run flew with
no script probe attached. The tally is now 3-of-3 frozen with the probe versus
1-of-3 without, not 3-versus-0. Still a lean, but not the clean separation the
previous entry claimed, and marked down accordingly -- the fourth time a freeze
conclusion here has had to be softened by one more run.
The sweep-free test itself could not run: ScriptMission is re-allocated per run,
so the address from an earlier run (0xBC7A2A20) read back all zeros. The cheap
self-consistency check ([m+44] must equal the phase's [+244]) rejected it
instead of reporting garbage, which is the part that worked.
Names the cheaper replacement: a BOUNDED pointer scan. Every ScriptMission seen
so far sits in 0xBC79xxxx-0xBC7Axxxx, so ~32MB instead of ~371MB would cut the
sweep cost roughly tenfold. Not yet implemented.
phase_watch.py runs give a sharper tally than the earlier inducer test, because
the only difference between the groups is one tool: pilot alone went clean to
936s and 1064s (2 runs), while pilot + phase_watch froze at ~70s, ~253s and
~126s (3 runs). Same boot path, same pilot, same mission.
What phase_watch adds is find_mission(), which does TWO full sweeps of guest
memory at startup plus ~130 small reads every 5s. The sweeps are the same shape
as the heavy_read.py scans the earlier n=2 experiment implicated.
Still correlation, not cause -- 3 vs 2, and the earlier inducer test produced a
clean counter-example. But it is the best-supported version of 'the instrument
provokes the freeze' so far, and it is my instrument, which makes it actionable.
The cheap test is named: find_mission only needs to run once, so re-run the
watch with the sweeps replaced by an address from a prior run. Freezes stopping
implicates the sweeps; continuing implicates the per-sample reads.
Cost so far: three attempts at observing a phase advance, all truncated inside
four minutes.
ADN110/111/112 all carry first-keyframe time 170 on their _p1F routes, and in a
live run all three flipped from not-deployed to active at ~143s on the probe
clock. The probe zero sits roughly 10-20s into the mission, putting the arrival
at ~155-165s of mission time against a predicted 170.
That pins the unit as seconds: 170 frames at 30fps is 5.7s, so they would have
been active at the first sample and demonstrably were not. Three squadrons
sharing one timetable entry changing state in the same 5s window is not
coincidence. Flagged as a match rather than an exact measurement -- the probe
clock is not aligned to phase start.
Supersedes mission-arrival-watch.md's headline negative: 'no arrival has ever
been observed across six runs' was an instrument limit, not a fact about the
game. Its craft-counting analysis stays accurate -- craft counts conflate
deployment with attrition and cannot see an arrival at all.