The 13 tables that voice every player-facing event are all
PresetMessage_Katana_*, the PLAYER's own sets -- unit-group-table.md already
names the link (DeltaSaber_T_Player carries msg=MessageSet_Katana). Exact
partition: 13 voice all 14 events, 131 voice none, 0 voice some, and no
non-Katana table voices any of them. CharacterKATANA is the only speaker
exclusive to the set.
Corrects preset-message-rules.md, which called those the wingman tables. The
wingman roster is owned by isl-condition-builtins.md (UNITS: Bird1-Sandra ...
Rhino2-Katana, Rhino3-Ellen) and needed no experiment.
Residual: Katana_09_S10-1 and Katana_14_S16-2 voice none of the 14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
sub_82215A58 reads CrewCount + PresetMessage_Phase1/2/3 and reaches the loader
from ONE call site, so the three phase tables fold into one map keyed by the
event record name. Every merge collides; sub_82213840 reconciles on the
message list plus +16/+17/+18/+20/+24/+28 (NOT the +32 Yes mask), and the
incumbent always wins. Measured: 26432 collisions, 26208 identical, 224
different (189 differ only in the message list), 0 mask-only differences.
Refuted handle: intersecting functions by the object's offsets finds dozens of
unrelated layouts -- offset shape is not an identifier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The one function referencing all seven field-name strings is the rule table's
loader. Interval / IntervalFluctuation / EffectiveTime are SECONDS, emitted
as *60 frame counts; Probability is a percentage and zero skips the record;
Pattern is a 4-arm enum of which only Sound and Window ship; the Yes/No pair
element is a u32 bitmask, which is why MessageCount is clamped to 32 (max on
disc is 26). 40-byte object layout recorded. 13 dead records characterised.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Three more naming routes: strip _msg from a message table (137/144, superset
of route 1, zero non-rule hits), predict the name from the Sperkers roster
(6/6, control 0/4), and sweep the naming grammar (1/144). Union 144/144,
and every rule table has its _msg companion.
Refutes the reading left by 3f20787: the undeclared tables are two story-stage
tables and six TCAF fleet/ship tables, no tutorial content at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
864 records = 144 rule tables per language pack x 6. One schema for all
9216 event records; MessageCount*2 == positional count with zero mismatches.
Named 136/144 by two independent routes that agree as sets. 2388/2405
message ids join the settled sound-cue table.
Corrects squadron-orders.md: the executable misspells all four SQUADRON
entries of the 0x820AEEB0 enum as ORDOR_, and the disc data matches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Correction first. stage-numbering-and-player-craft.md called S24-S29
'story' and reported 22 story stages. challenge-mission-gate.md OWNS
that split and already had it: S01-S16 story, S18-S23 tutorials,
S24-S29 challenge. A third confirmation fell out of this iteration:
AUTO_SETTINGS names stage01..16_settings.tbl, tutorial01..06 and
challenge01..06, and its field tags are 1-16, 18-23, 24-29 -- the
shipped stage numbers exactly. The campaign is 16 story missions, not
22. AUTO_SETTINGS and IGNORE are likewise already owned by
isl-condition-builtins.md, so nothing there was new either.
What is new: the 11 marker records hud-config.md listed as unread. 12
named fields each, portable HUD data. Colour is ARGB, three per marker
-- objectives red 0xFFFF0000, guard/waypoint green 0xFF20FF20,
Acropolis/tanker cyan 0xFF00FFFF, sub-objective yellow 0xFFFFFF00, and
every TutorialMarker_* amber 0xFFFFA010. Each carries a Normal sprite
and an Emphasis part with optional Sub companions; HPGauge 0/1/2;
RadarCursorType Circle/Rectangle/blank; BlinkCycle 0x800 on all eleven.
TutorialTarget is Yes on exactly the five TutorialMarker_* and No on the
six ObjectiveMarker_*.
Parameters binds the roles: TargetMarker -> ObjectiveMarker_Target,
HelpMarker -> ObjectiveMarker_SubObjective, TutoTargetMarker ->
TutorialMarker_Target, plus ReloadDispTime 0.1.
Enumerate_ObjectiveMarkers is the 11-name roster.
Artefact +13 lines / 0 deletions; the other nine regenerate
byte-identical.
Six bits, never more. Over all 835 turrets: 41 distinct values, max
0x3f, bits 6/7 set on none; per-bit 709/117/274/274/260/237. 0x00 (34)
= the empty Weapon_NULL hardpoints with YawLimit 0; 0x01 alone (439) =
craft hardpoints with YawLimit 2.5/1.0/0; 2-6 bits (362) = warship
mounts with YawLimit 45-180.
It tracks the MOUNT, not the weapon. UN_e107_ADAN_AAFrigate has eight
identical AAFrigate_AAGun turrets: GN_GunXS_01..04 are 0x1d and 05..08
are 0x2d -- same gun, same YawLimit 120, different mask. The Battleship
spreads five masks over GN_TGunL_01..05 / GN_TGunM_02..03, all firing
the same CAF_Ship_ASGun.
Which sector each bit denotes is NOT determined; six bits and the name
invite +-X/+-Y/+-Z or six hull faces, but nothing static fixes the
convention. Not adopted. Refuted on the way: bits 2 and 3 are not a
mutually-exclusive pair -- 188 turrets set both.
The 26 weapons no turret mounts are a coherent set: 11 _P player
variants, the nose/twin mounts, two _Child sub-munitions, the three
S16Boss_*, three Weapon_Test_*, and ADAN_Attacker_S_GunTurret.
Versatile_NNN ships nowhere -- 0 populated records across all 41
archives, only the ??? template row.
The two units one turret short: both extras are missile mounts with no
Frame. Elan_EX4 = NoseGun + Missile, both mask 0x01, both frameless;
AAFrigate_EX4 = eight framed guns plus one Ship_AAMissile at 0x0c with
no Frame. n=2, stated as the observed pattern, not a rule.
New artefact and regenerator; the other nine regenerate byte-identical.
1. A different hash family. The corpus knows three (idxd-tag-hash.md):
name_hash, tag_hash, ixud_hash. Scoring all 5977 harvested names x 6
prefixes: GP_TITLE 8/16 and GP_PAUSE_MENU 6/11 under name_hash (the
positive controls), and tag_hash and ixud_hash explain NOTHING anywhere
-- including the paks name_hash does explain. So they are not the TOC
function, and the unnameable pair is not keyed by a different one.
GP_MAIN_GAME_E2D stays at 0/711 under all three.
2. The executable. sylpheed.db's strings table holds 7140 rows, of
which exactly two look like asset paths -- Data\gmicon002_2.t32 and
Data\gmicon006_2.t32, in a Data\ directory nothing else on the disc
uses -- and neither resolves in any archive. The binary is not the name
source; it holds two strays and no table.
3. Name transformations -- 13 of them on the 419 config paths, all 0.
The container runs out here. Those TOC keys hash names that exist on
neither the disc nor the executable in readable form. The only lever
left is a dictionary attack using name_hash's shape (top byte = the
character-sum checksum), and that needs a plausible name corpus this
disc does not contain. Noted as blocked rather than improvised around.
The port does not need these names: sprites and bundles are readable by
content (T8aD, RATC), and the config records already say which asset
each HUD element uses. Only the archive-key to name mapping is missing.
Artefact +22 lines / 0 deletions; the other eight regenerate
byte-identical.
Chasing more prefixes for the 419 HUD config paths would have been the
same mistake twice, so this censuses the whole disc: harvest every
plausible asset-name string from every archive (6027), hash each under
the 16 known path prefixes, and ask per archive what fraction of its TOC
that explains. idxd-container.md and idxd-tag-hash.md own the hash;
neither says which archives are reachable by it.
The result is bimodal. GP_TITLE 16/16, GP_PAUSE_MENU 11/11,
GP_STAGE_CLEAR 44/44, GP_CHALLENGE 151/151, GP_MOVIE_THEATER 56/56,
MiscBin 40/40, GP_GAMEOVER, GP_BUNK, GP_SYSTEM, GP_TUTORIAL and fonts
are at 100%; tables.pak 78/79, GP_DIALOG 139/140, the six language paks
115/117; GP_MAIN_GAME_* 751/1119. Then the cliff: the six
GP_MAIN_GAME_*2D.pak at 0 of 711 each, and GP_READY_ROOM at 6 of 1106 --
the largest UI pak on the disc, not previously noted anywhere.
Eleven paks at 100% in the same run is the control that makes 0.0% a
finding rather than a failed guess.
So the 419 HUD paths are not missing assets: nothing in the 2D paks is
reachable by name from the disc's own strings at all. Those TOC keys
hash names that are not written anywhere readable.
Also refuted first: the 419 values under 13 name transformations, every
one scoring 0 against the E2D 711 and against all 16630 entries.
Not settled: what those names are. The lever left is the hash's shape
-- the top byte is the character-sum checksum -- but that needs a name
corpus the disc does not contain.
New structure doc, artefact and regenerator; the other eight regenerate
byte-identical.
Checked first: no docs/re file mentions ArmsStatus, RangeFinder, Radar,
Sight, Wing, NamePlate or ResourceTable. Only HudResource had been
opened; the other fifteen records had not.
The six IDXD entries of GP_MAIN_GAME_E2D.pak: two carry the 16-record
HUD config, two the 13-record ObjectiveMarker_*/TutorialMarker_* set,
one Face (52 portrait sprites), one ResourceTable. Between them they
name 419 distinct asset paths -- the whole flight HUD -- with new
subdirectory prefixes throughout (ArmsSt, ActvArm, RangeF, Marker,
Manuva, Map, Speed, Radar, Sight, Wing, Hitmark, Lockon, Info).
The decisive control uses the config's own exact path strings, so no
guessing is left in the loop: 419 distinct .prt/.t32/.tbl values, ZERO
resolve as a pak entry under 10 prefixes, and the four config filenames
resolve to nothing either. The .t32 sprites certainly exist -- 574 T8aD
in that pak. So the 2D pak is not addressed by name_hash of the name
its config uses.
This supersedes the earlier framing: the 28 'dangling' .prt names were
never a missing-asset story; they are 28 of a set where none of the 419
resolves.
ResourceTable is 58 positional fields = 29 pairs, alternating
HudResource.tbl / HudMarkerResource.tbl, identical in all six language
paks, with exactly one override at pair index 25 -- HudResource_S26.tbl.
Reading: indexed by stage number minus 1, so index 25 is S26, the one
stage with its own HUD config. Arithmetic exact, indexing unproven, not
adopted.
New structure doc, artefact and regenerator; the other seven regenerate
byte-identical.
Censused the 32 bundle-less in-game .prt names with the 68 as control:
4 of 32 appear as an element of some 2D bundle, against 58 of 68.
Being an element is normal for a part that exists; these are not
elements either. Last iteration's 'not adopted' reading is refuted.
28 names are neither a bundle nor an element.
They are not unreferenced: a HudResource record inside the 2D paks' own
IDXD entries names them -- the in-game screen config, parallel to
tables.pak for menus. GP_MAIN_GAME_E2D.pak has 6 IDXD entries; two
carry HudResource (24 named fields) beside ArmsStatus, Map, ArmsItem,
RangeFinder, Marker, Manuva, Number; two more carry the
ObjectiveMarker_*/TutorialMarker_* set.
Its values carry subdirectory prefixes -- Hitmark\, Lockon\ -- which my
hand-written prefix list never had. So I rebuilt the negative the right
way round: harvested every IDXD field value containing a backslash, kept
the 60 commonest directories, and re-swept. 241/376 with the hand list;
241 with hand + harvested -- zero new resolutions. After the language\
miss, this is the closure that counts: the prefix list came from the
disc, not from me.
Still open: whether the 28 are cut features or assembled at runtime from
sprites. Nothing static separates those two.
Artefact +45 lines / 0 deletions; the other six regenerate
byte-identical.
Read first: ui-rat-layout.md OWNS the RATC stack and already documents
the 60-byte element declaration table and 'one bundle = one
(context x language) build of that screen'. What it does not say is how
a part is addressed when it is NOT a pak entry.
The elements of one bundle share a common name prefix, and that prefix
is the part name. GP_MAIN_GAME_E2D.pak has 130 bundles with 114
distinct element prefixes -- pgmenu_btn00, pghud_wing, pgface,
pghud_range, pgmanuva_eff0 -- exactly the in-game part names.
The 100 in-game-only .prt names match a 2D bundle prefix 68 times; the
control, the 241 shipped parts, matches ZERO. The two families are
disjoint on the test. 13 shipped parts match a bundle prefix in their
own screen pak, which is the expected shape.
Four of the five mission banners land here: pgmsg_start.prt is E2D
bundle 0x89fac252, a one-element bundle declaring pgmsg_start_sub.rat;
likewise _end_, _failed_, _restart_. pgmsg_update has no bundle at all,
consistent with having no _sub.rat.
32 in-game names still have no 2D bundle. They cluster into families
whose base name is a bundle, reading as a variant declared inside a
parent bundle -- a reading, not adopted.
Artefact +38 lines / 0 deletions; the other six regenerate
byte-identical.
Censused the whole 135 unresolved set instead of chasing the five.
Cross-tab over all 376 .prt names: referenced by tables.pak AND
resolves 241; neither 100; referenced but absent 35; resolves WITHOUT
being referenced by tables.pak -- ZERO.
So resolving implies a tables.pak reference, 0 counterexamples of 376.
tables.pak holds the menu screen configs and every part it names ships
as <lang3>\<name>.prt. The 100 names that appear only inside the
GP_MAIN_GAME_*2D.pak bundles never ship as entries -- the in-flight HUD
is authored into the bundles, not loaded by name. The five pgmsg_*.prt
were never a special case; the split is menu = named + shipped,
in-game = embedded.
It is not a prefix rule: 6 of the 135 pg* names do resolve (pgloading,
pgloading2, pgmsg_scr, pgpause, pgpause_ttrl, pgpbase), because
tables.pak names them.
The 35-name residual is one coherent family -- pgmenu_btn*/item*/pad,
pgfacewin*, pgtextwin, phinfo1-3, 02d, 02d_scr, psview_release: the
in-game pause menu and squadron-order overlay, named by a menu config
but drawn from the in-game bundles. Consistent with the split, not
separately proved.
Artefact +44 lines / 0 deletions; the other six regenerate
byte-identical.
Read first, as the rule now requires: ui-prm-primitives.md,
ui-rat-layout.md, ui-screen-runtime.md -- they own the .prt/RATC/T8aD
stack but describe element names inside bundles, not where parts live.
A .prt IS a pak entry, under a LANGUAGE directory. Of 376 distinct
.prt names referenced on the disc, 241 resolve as archive entries --
220 under eng\ and jpn\, 38 under each of the other four. prbase.prt
resolves under all six; palogo1.prt under two.
All five pgmsg_*.prt resolve under nothing, across 15 prefixes that
include every language dir, so this is not the missing-prefix mistake
again.
A lead, refuted by its own control: four of the five have a same-stem
_sub.rat sub-bundle inside every per-language 2D pak, and pgmsg_update
has none -- but only 4 of 376 .prt stems have a _sub.rat, and
prbase.prt, which does ship as an entry, has none. Not the container
convention, not adopted.
An intermediate pass nearly reported 'the five appear only in the
manifest'; that was wrong because the token census was truncated by a
slice and the RATC hits were pgmsg_start_sub.rat, a longer token.
New artefact and regenerator; the other six regenerate byte-identical.
The previous commit announced message\MissionDialogMessage.tbl as a
find. structures/ixud-localised-text.md OWNS it and already says
S02_P1_OBJECTIVE and friends are record names in an IDXD map,
message\MissionDialogMessage.tbl, whose positional fields list the
lowercase per-line IXUD names -- and that *_GRAPH is the odd one, a
single named field holding a texture. I grepped the manifest doc and
not the text doc. Third overclaim in four days; the rule is now to grep
the doc that owns the DATA, not only the doc that owns the FILE.
Worse, the headline negative was wrong. MissionDialog_local_string.tbl
DOES resolve -- as language\MissionDialog_local_string.tbl, IXUD, in all
six GP_MAIN_GAME_* paks. My 33 prefixes omitted language\, which is
precisely the convention ixud-localised-text.md records for language
paks. A prefix sweep is only as good as its prefix list, and the list
should come from the corpus.
'LOSE has exactly one entry' was wrong too. Per kind: HINT_PAUSE 4,
HINT 3, LOSE 4, OBJECTIVE 4 positional fields, and GRAPH 1 NAMED field
holding the .t32. The single-field bucket was GRAPH, not LOSE.
What survives as new: the 11 non-MISSION field values read as a block;
the GP_TEST and TEXTS sibling records; the per-stage phase census; and
the five pgmsg_*.prt resolving nowhere under 34 prefixes x 41 archives,
now with two working controls in the same sweep.
Artefact 8 insertions / 3 deletions, every deleted line replaced by its
corrected form; the other five regenerate byte-identical.
structures/mission-script-ssb.md owns this manifest and names its 40
fields but never read the 11 that are not MISSION<n> = StageNN.ssb.
They are DIALOG_MESSAGE, DIALOG_LOCAL_STRING, FONT (+size), TEXT_POS,
TEXT_LINES and the five pgmsg_*.prt. Two sibling records were also
unread: GP_TEST (PATH = dat\GP_TEST\, a debug archive not on the disc)
and TEXTS (a second text style).
Probed 7 values x 33 prefixes x 41 archives. One resolves:
message\MissionDialogMessage.tbl, in all six GP_MAIN_GAME_* paks --
200 records, 25280 bytes, every name S<NN>_P<n>_<KIND> with five kinds
40 each (HINT_PAUSE, HINT, OBJECTIVE, GRAPH, LOSE), fields positional
and tagged 0..3, each value a message key. An index from (stage,
phase, kind) to the localised strings, on the same S<NN>_P<n> keying
the ISL corpus already uses.
Control: the 40 stage-phases span stages 1-16 and 24-29 -- a subset of
the 28 shipped, and the six with no hints are exactly 18-23, the
tutorials. A fifth independent route to the story/tutorial split, and
it gives the phase count per stage.
The other six do not resolve, with the control in the same sweep:
MissionDialog_local_string.tbl and all five pgmsg_*.prt are not a pak
entry under any of the 33 prefixes, while
message\MissionDialogMessage.tbl and Stage\script.tbl both resolve in
6 archives.
name_hash is CASE-INSENSITIVE (message\ == Message\); tag_hash is not.
New structure doc, artefact and regenerator; the other five artefacts
regenerate byte-identical.
Correction first: the previous commit claimed the corpus 'did not know
the stage NUMBERS'. It did -- structures/mission-script-ssb.md has
recorded S01-S16, S18-S29, S17 absent by a three-way convergence (hash
lookup, the table sweep, the loader's != 16 guard) since before that
pass. What was actually new there is the player craft per stage and
ResourceID's ordinal tags; the numbering is a fourth independent route,
not a discovery.
The question this item asked: do the ISL StageNN.ssb files and the
UnitGroup_SNN.tbl tables use the same numbering, or is something
silently mis-labelled? They are identical. Stage\StageNN.ssb,
stage\UnitGroup_SNN.tbl and stage\Route_SNN.tbl all resolve for the
same 28 N with an EMPTY symmetric difference; AIParams is the same minus
18-23. Nothing in the corpus is mis-keyed. Formation_SNN and
Message_SNN resolve 0/40 -- those are field names, not files, and the
four families above are the controls that make that negative real.
New: Stage\script.tbl's SCRIPTS record lists 29 MISSION*.ssb fields --
MISSION1..MISSION29, including MISSION17 = Stage17.ssb -- and only 28 of
29 name a file that ships. Control: MISSIONn -> Stage<n>.ssb with the
same n, 0/29 mismatched. So S17 is a cut mission the shipping manifest
still names, which is why the loader carries an explicit != 16 guard
rather than just running 0..27.
Artefact +14 lines / 0 deletions; the other four regenerate
byte-identical.
The corpus knew '28 stages' and 'six tutorials with no AIParams'. It
did not know the NUMBERS, and they are not 1..28. Hashing
stage\UnitGroup_S%02d.tbl for N=0..39 against GP_MAIN_GAME_E.pak:
1-16 and 18-29 ship, 17 does not -- 28 files -- and the six with no
AIParams_SNN.tbl are exactly 18-23. So S01-S16 story, S17 absent,
S18-S23 tutorials, S24-S29 story: 22 + 6.
eng\GP_HANGAR_ARSENAL_3D.tbl's ResourceID record keys a player-craft
mesh by stage: Unit_St1_6 -> rou_f001 (DeltaSaber T), Unit_St7_16 ->
rou_f002 (W), plus six further fields tagged with the raw numbers
24,25,26,27,28,29 -> rou_f002 x4, rou_f004 (DeltaSaber A) at 28,
rou_f002. The bare tags are the last six story-stage numbers. Control
in the same record: tag_hash(name) == tag for 11/11 named fields, so
those six genuinely carry no name.
Cross-check from a different file: grouping the Arsenal pak's 168
stage-scoped entries by which _Player craft their loadout mounts gives
f001 = 6, f002 = 15, f004 = 1, tutorials 5+1 = 6, and every number
closes against ResourceID -- 6 = Unit_St1_6; 15 = Unit_St7_16 (10) plus
tags 24,25,26,27,29 (5); 1 = tag 28; 6+15+1+6 = 28.
So the player flies the DeltaSaber A in exactly one mission, S28, and
the DeltaSaber T only for the first six.
Not settled: the Arsenal entry filenames, so which of the 168 is S24 vs
S25 is constrained but not pinned.
New structure doc, artefact and regenerator; the other five artefacts
regenerate byte-identical.
Settled by elimination with two controls. The 15 loadout names appear
as a field VALUE nowhere on the disc -- 0 occurrences across every pak
-- while the control Arbalest_155KG, which is referenced, appears 150
times as a value in the same pak. They occur only as the field names of
the UNITS record. They are not in the executable either; control:
WEAPONS (a section key) is, Arbalest_155KG is not.
So nothing references a loadout by name. The selection is which pak
entry the Hangar loads. GP_HANGAR_ARSENAL.pak has 180 IDXD entries in
10 shapes; 168 carry a UNITS roster = 28 stages x 6 languages, and the
other 12 = 2 x 6. Each entry is one stage's whole Hangar config, and
its UNITS roster is the flight for that stage -- 5 distinct rosters over
15 / 5 / 5 / 2 / 1 stages, including one where Rhino2-Katana flies
alone. Every count is a multiple of 6 and they sum to 28, the disc's
stage count by a fourth independent route.
Corrects the previous commit: it read the pak with setdefault, so
'15 loadouts / 24 allow-lists / STANDARD_ARM1 has 11 entries' is the
union of first-seen records, not one table. Contents vary per entry --
PlayerSET_ARM1 has 5 distinct contents, STANDARD_ARM1 3, ExSET_NOSE 4.
The chain and both controls (60/60, 70/88) are per-record and
unaffected; only the per-list sizes were over-generalised.
Not settled: the entry filenames. name_hash probing over 8 templates x
40 indices x 6 languages resolved 0, while the same probe's controls
<lang>\weapon.tbl and <lang>\strings.tbl resolved 12/12.
Artefact +40 lines / 1 changed heading; the other four regenerate
byte-identical.
15 loadout records, one per flight position x pilot (Bird1-Sandra ..
Rhino4-Yoji), each with Arm1/Arm2/Arm3/Nose + UnitID.
The same trap as PlayerWeapon, one level up: Arm1/Arm2/Arm3/Nose do NOT
name items. They name a per-slot ALLOW-LIST record -- one of 24 whose
only named field is Type (the slot kind) -- and the candidate items are
that record's positional, unnamed fields, in order. Four hops:
Rhino4-Yoji.Arm1 -> STANDARD_ARM1 -> [Falcon_9AM, Condor_105AM, ...]
-> item.PlayerWeapon = Turret_NNN -> slot.WeaponID -> Weapon.ID
Controls: Arm1/2/3/Nose -> allow-list record 60/60; allow-list
positional entries -> arsenal item 70/88, and every one of the 18
misses is the single sentinel No_Equipment -- one of the four
WEAPONS-roster values with no item record, i.e. the empty-slot marker.
UnitID is two ID spaces at once: 5 rows name a unit Generic.ID (the
three -Katana rows are the player -- a _Player craft plus an extra,
empty PlayerUnit field), 8 name a character, resolving as Character +
the value into the 64-record character table.
Two values resolve to nothing, both single rows against 13 that do:
Rhino2-Ellen.UnitID = UN_f001_TCAF_DeltaSaber_W exists nowhere (checked
as a Generic.ID across every pak and as a record name, 0 hits) while
UN_f002_TCAF_DeltaSaber_W does -- consistent with a shipped typo,
reported not diagnosed -- and Rhino4-Brandon.UnitID = BRANDON has no
CharacterBRANDON among the 64.
New structure doc, artefact and regenerator; the other four artefacts
regenerate byte-identical.
An Arsenal item does not reference a Weapon record. It references a
Turret_NNN HARDPOINT SLOT on the player craft's own unit table, and the
slot is what carries the WeaponID. Three hops:
Arbalest_155KG.PlayerWeapon -> Turret_050 (a slot on
UN_f001_TCAF_DeltaSaber_T_Player) -> .WeaponID ->
Weapon_DSaber_P_wep_50_Cannon
Controls, both in the same loop: 0/59 distinct PlayerWeapon values are a
Weapon.ID; 59/59 are a Turret_NNN slot id; the full chain lands on a
Weapon.ID 59/59. WingmanWeapon resolves identically. The WEAPONS
roster's 59 = 55 item names + 4 empty-slot sentinels.
Wingmen fly a cheaper gun: following the same 59 slots across craft
variants, the _Player tables give each item its own weapon record (59
distinct) while the AI tables collapse all 59 onto 10 generic classes.
That is most of the 131.
Upgrades yesterday's 'hardpoint catalogue' reading from 21 to adopted,
proved from an independent file, and corrects its '10 distinct WeaponID'
figure -- that was the AI variant, not the player's.
Also adds an __main__ guard to unit_substructures.py so importing
pak_entries from it does not run its report; its artefact is unchanged
and still byte-identical.
The corpus has named these since unit-struct-runtime.md but never
opened them. Per unit table: Turret_NNN 835 records (max 63 on one
unit), ShieldGenerator_NNN 46, Thruster_NNN 38, Hatch_NNN 26,
Bridge_NNN 25, plus one each of Shield/Mass/SE/Explosion/
StructureCount and NS_Body on 68 of 114.
Turret/ShieldGenerator/Thruster/Hatch/Bridge are ONE record shape: a
shared 19-field destructible-part base (ID, Name, ParentStructureID,
Frame = a mesh NODE name, NomalModel, CollisionModel, Radius, HP, the
four Is* flags, SpreadDamage, damaged/destroy motion + time, and the
three Effect_*), with per-kind extras. Turrets add WeaponID,
AngularVelocity, YawLimit, PitchLimit_Elevation/_Depression, CoverArea,
IsAuto, HasBarrel and up to 80 CannonModel_NNN/CannonFrame_NNN. Shield
generators, thrusters and bridges add PowerRatio. Hatches add
SquadronID, LoadedCount, MaxAvailableCount, TakeoffInterval -- a
carrier's launch bay.
Control 1: StructureCount.<Kind>Count == #<Kind>_NNN records, over 684
comparisons -- 612 equal, 55 "0 declared, one blank placeholder"
(55/55 blank in Name AND NomalModel AND Frame), 11 differ, 6 kind
absent. All 11 exceptions are Turret and all are declared < records.
Control 2: 835/835 Turret_NNN.WeaponID resolve to an ID in the
131-record Weapon datasheet, zero unresolved; 26 weapons are never
mounted on a turret.
Refuted in the same pass: "the DeltaSaber's 59 non-NULL hardpoints are
the 59-name WEAPONS arsenal roster". The counts match exactly and the
sets overlap in 0 values -- two namespaces, one coincidence.
New structure doc, artefact and regenerator; other artefacts unchanged.
Yesterday's page said "Generic (394 per pak) is the unit datasheet".
Only 114 of the 394 are. Every IDXD file carries exactly one Generic
record and its schema is set by what kind of file it is. Partitioned
by field set, identically in all six GP_MAIN_GAME_*.pak:
114 has HP -- a unit datasheet
204 {Count} only -- a dialogue file
64 {ID, Name, SideID, Unique} -- a character (36 TCAF + 28 ADAN)
10 {EnumAsteroidGroup} -- an asteroid group
2 degenerate
204+114+64+10+2 = 394, and 178 distinct Generic.ID = 114 unit + 64
character, the only two shapes carrying an ID. That settles the
"178 IDs vs 394 records" question the previous entry left open.
Positive control in the same loop: for the 204 dialogue headers Count
equals the number of Message_NNN siblings, 204/204, zero mismatches.
Cross-check from the other side: Maneuver = 114, Effect = 114, the
carrying-entry sets are identical, every unit Generic has a Maneuver
sibling, and Generic.Type splits 43 Craft + 71 Vessel -- the same
43/71/114 unit-struct-runtime.md reached from live guest memory.
New tool generic_partition.py + artefact; ISL artefacts byte-identical.
Followed the writer, not the reader. [phase+10160]'s only writer in
the image is one site in the mission frame loop sub_821AA1B0: it does
obj->get() on an object fetched from a registry by id 0x20FFFF02,
stores it to the phase, then clears the object -- read, publish, clear,
every frame.
The id namespace has exactly three members (0x20FFFF00/01/02), each
built at exactly 4 sites, and two of those are in sub_821D5178, which
gets 0x20FFFF01 and 0x20FFFF02 and logs both:
GamePart_ReadyRoom::Impl::OnCommand - Wait() command is requested.
Check flow control valiables. WAIT_MODE : %d, REQUEST_NEXT : %d
Argument order gives 0x20FFFF01 = WAIT_MODE, 0x20FFFF02 = REQUEST_NEXT.
PrepareScript corroborates: it sets WAIT_MODE=1, REQUEST_NEXT=0 before
an ISL script runs. So the six tutorial stages' lone dominating
condition request_next() != 1 is the script waiting on the game part's
proceed flag.
6 artefact lines changed, all 6 pair exactly.
They are start / read / stop of one of 32 per-phase stopwatches, not
flag operations. 123-127 keep timer_* -- that is the mission timer,
five scalars at [phase+304..320], a different clock.
Artefact check: 84 lines changed across 5 files and all 84 pair
exactly with their old-name partners once column padding is
normalised (0 removed lines without an old name, 0 added lines
without a new one). data/isl-timers.txt reproduces the same
675/675, 11.2 % control, 82/1 and identical histograms, which is
what shows the rename is cosmetic.
Also withdraws a label from the previous commit: sub_8230C398 is NOT
the message pump. It runs every frame but drains nothing -- a state
machine on [0x828E1F8C] that only allocates, builds strings, looks up
and PUSHES. And bus+8216 is weak evidence: sub_82254A08 is a generic
map find with ~120 sites, and the key looked up is a pointer, not a
tag. The open handle is now the ring buffer at bus+4, not bus+8216.
Decodes the table found at the end of every phase region. Layout:
int N
N x [ int offset ; float t ; int kind ] -- 8-byte typed records,
tag 0x19 int, 0x1A float
1 + 3N matches the record count in every phase measured (Stage 02: 76/40/55
records for N = 25/13/18).
Checks, all independent of each other:
schedule entries disc-wide 675
0x1A float records disc-wide 675 (counted by a different route)
offsets landing on the instruction stream 675/675 = 100.0%
control, random 4-aligned offsets 33.3%
The floats are seconds -- 0, 0.5, 1, 4, 5, 30, 50, 60, 90, 120, 150, 170, 180, 210,
240, 270, 300, 330, 360, 420, 570, 1020, 1080, 1140, 1170 -- and the targets are small
one-shot coroutines that set arguments, call one built-in and end_coroutine. kind is
0 (556) or 5 (119) and is not identified.
Runtime cross-check, recorded as consistency rather than confirmation: the closed
REMAINING OB work measured Stage 02's squadron arrivals at t = 0, 120 and 210 s over
n=5 emulator runs, and all three appear in phase 1's static schedule, with 120 and 210
each appearing TWICE. These are round numbers and phase 1 has ~22 distinct times over
0-1170, so presence alone is not unlikely; the doubling is the sharper detail and was
not predicted in advance.
New artefacts data/isl-stage02-schedule.txt and data/isl-schedule-all.txt with a
committed generator (isl_report.py schedule). calls, phase-ends, conditions and
phase-guards all regenerate byte-identical.
Not settled and said so: kind is unread; the consumer is unread, so the decode rests
on the structural checks above; whether the clock is per-phase or per-mission is an
inference from the layout; and this is NOT what starts the unreachable code -- 0 of
the 675 targets are unreached run-starts, so that ~15% gap stands.
Disassembling the three Stage-02 entry_a targets shows opcodes 0x19 and 0x1A, and the
ISL dispatcher's table has 25 entries (cmplwi 0x18). They are not instructions. Each
phase region ENDS with a trailing data table of 8-byte typed records -- tag 0x19 = int,
tag 0x1A = IEEE float (0.0, 0.5, 1.0, 4.0) -- and entry_a is where it starts.
Confirmed across the disc: in 44 of 44 phases the first offset whose opcode exceeds
0x18 is exactly that phase's entry_a, with zero exceptions, and only two tags ever
appear (1394 x 0x19, 675 x 0x1A). So the record is
0x1883, base, size, 0, code_end, force_end_handler
one boundary and one entry, not two entries as the previous commit said.
That also retires this thread's own "82 of 88 land on a valid instruction = 93.2% vs a
38.6% control" as TOO WEAK a test: a data record has length 8 and passes "nonzero,
even". The entry_b result stands on different evidence -- those targets were matched
against isl.call_sites(), an independent enumeration.
isl.linear_offsets was decoding all 2069 data records as instructions, 1.23% of the
stream. Now each phase's walk stops at its boundary:
decoded instructions 168251 -> 166182 (= 168251 - 2069, as predicted)
opcode > 0x18 2069 -> 0
call sites covered 25705/25705 -> 25705/25705
exits unreachable 0 -> 0
conditions unknown 400 -> 400
Recorded because the first attempt at the fix was worse than the bug: it destroyed 36%
of the stream (168251 -> 107596, exits 0 -> 74) because linear_offsets is ONE global
walk from the first phase base, so stopping at phase 1's table lost every later phase.
It has to skip the region and resume at the next base. A count moving hard in the
wrong direction is the same signal as one that will not move.
Still open: the table's contents are undecoded -- its int values land on the
instruction stream 46/51 against a 29.5% chance rate, but 0 of them are unreached
run-starts, so this is not what starts the unreachable code either.
data/isl-phase-guards-all.txt goes from 5 of 177 unreachable exits to 0.
The cheap first step failed, usefully. An unreached routine's entry offset does NOT
appear as a word anywhere in the file, in any encoding: phase-relative 6.6% against an
11.5% control on reached offsets, absolute 1.6% vs 3.3%, and the /4 forms 0-1.6% vs
6.6-8.2%. Every variant is at or below its control, which rules out the whole family
of "some instruction operand points at them". It also rules out dead code: Stage 02's
3069 unreached instructions contain 485 calls, including start_coroutine x75,
squadron_attack x59, set_group_speed x42 and objective_marker x13.
The answer is the mission-level stream that isl-bytecode.md already partly read. Each
0x1883 record is
0x1883, base_delta, size, 0, entry_a, entry_b ; entries PHASE-RELATIVE
Measured over all 28 stages, 82 of those 88 values land on a valid instruction --
93.2%, against a 38.6% chance rate for a random 4-aligned offset. In Stage 02
entry_b is the phase's force-end handler: 0x1482C, 0x249F0 and 0x34A10, two of which
were exactly the unreachable exits, and the third being already reachable is the
consistency check.
Seeding them: exits unreachable 5 -> 0. Those exits now report 0 necessary
conditions, which is what an engine-entered abort handler should look like.
Recorded because it is the same mistake twice: the first seeding attempt moved NOTHING
(reach 85.0% -> 85.0%, exits 5 -> 5). dominating_conditions() builds its own entry set
and did not use the one I had patched -- fix-the-instance-not-the-class again, caught
only because an unchanged count is by now a standing signal.
Not settled and stated: reach went only 85.0% -> 85.2%, so what starts the other ~15%
of code is still unknown, and the negative above says it is not an operand in the file;
entry_a is unidentified; 6 of the 88 values do not land on an instruction.
Dominance said a phase cannot end unless X. A port also needs "once X holds, it
must end", and that is a must-reach set: nodes from which END_PHASE is unavoidable,
as a least fixpoint where n qualifies when it has successors and ALL of them qualify.
The conservatism is deliberate and is the honest answer: a loop never enters the set,
because a poll loop reaches its exit only if the polled predicate eventually becomes
true, which is a liveness property rather than a graph one.
A dominating condition is a TRIGGER when the successor it takes on being satisfied
lies in that set. Over all 28 stages: 732 dominating conditions, 234 triggers
(31.97%). isl_report.py phase-guards now tags every line precond / TRIGGER.
The split lands where it should. Stage 02's phase-1 objective exit is six
preconditions -- player alive, TCN004 destroyed, t <= 210, ADT102/ADT107/ADT113
destroyed -- and exactly ONE trigger: hp_pct_test(ADN101, 0) != 1. Destroying ADN101
is what fires the phase. That is a sentence a port can implement.
Per-exit distribution over 172 reachable exits: 89 have exactly one trigger, 42 have
none, 41 have several. The 42 with none are not a failure -- they are the exits no
branch fires; Stage 02's 0x006260 ends on read_freg(0) < 1200, a timeout, and time
passing is not a property of the graph, so declining to call it a trigger is correct.
Recorded as a heuristic rather than a rule: "the first trigger is the point of no
return" holds for 33 of the 41 multi-trigger exits, with 8 counterexamples where a
precondition appears after a trigger. The likely cause is that the listing is
ordered by file offset, which is not execution order -- coroutines and jumps let a
lower offset run later. Not asserted.
calls, phase-ends and conditions all regenerate byte-identical; the two phase-guards
artefacts change only by gaining the tags.
isl_report.py now accepts a directory, so the dominance analysis runs over the whole
disc: data/isl-phase-guards-all.txt, 177 phase exits, of which only 5 (2.8%) are
reachable from no static entry. CFG reach ranges 69.5% (S26) to 95.8% (S25), median
about 4 dominating conditions per exit.
The lopsided number in the per-stage table was the six TUTORIAL stages, S18-S23, each
with exactly ONE exit and exactly ONE dominating condition. That could have been a
degenerate result, so I looked: it is the same condition in all six,
END_PHASE <- builtin104() != 1
and isl-builtins.md reached built-in 104 from call-site USAGE alone -- "S18-S23 only,
followed by wait_s 39/39, preceded by end_coroutine 37/39, a textbook poll loop".
Usage said 104 is the tutorial's polled test; dominance says it is the tutorial's
clear condition. Two unrelated methods, six for six.
Stage 16 -- the corpus outlier whose script may be compiled C++ -- resolves as well:
read_freg(0) < 600, player_gauge0_test, player_gauge1_test, and two builtin141 calls
differing in a single argument (0 vs -4000), which is the shape of a position or zone
test. builtin141 is unread, so it is not named.
Stage 02's separate artefact regenerates byte-identical.
Also added: an RLIMIT_AS cap in isl_report's entry point. The dominator pass
OOM-killed a run earlier on this 15 GB box; a bad input should now fail the process
rather than the machine.
Still not settled and stated in the doc: dominance gives necessary, not sufficient,
conditions; the 5 unreachable exits need the trigger queue at phase+272; builtin104,
builtin141 and builtin7 all appear in clear conditions and are unread.
Closes the backlog's "which condition guards each END_PHASE". With the CFG from the
previous commit this is a graph query, not new machinery.
The obvious query is WRONG for this language, and I implemented it first: "one
successor reaches END_PHASE and the other does not" finds 1/62/1 guards across Stage
02's three phases, and the 1s are both the same read_freg(0) < 1200 timeout -- every
objective test missed. The cause is the dominant idiom: a POLL LOOP's loop-back
branch also reaches the exit, one iteration later, so neither successor discriminates.
The asymmetric 1/62/1 is what exposed it; a uniform number would have read as
plausible.
Dominance has no such blind spot: a condition dominates an exit when every path from
an entry passes through it, so it is NECESSARY for the phase to end that way, and a
poll loop's test dominates its own exit by construction. Iterative dominators
converge in 3 passes over 15670/18739 instructions (83.6%).
Result for Stage 02 -- every exit in all three phases is dominated by
unit_hp_pct(TCN001, Character_Player_Test) != 0, the player's ship being alive, which
falls out rather than being assumed. Beyond that, phase 1's objective exit requires
hp_pct_test on ADT102, ADT107 and ADT113; phase 3's requires ADT301 and ADT302;
read_freg(0) gates at 210 / 300 and times out at 1200; random(3) and random(5)
dominate only the exits that pick one of several closing lines.
Two of the 15 exits are reachable from NO static entry, both FORCE_END_PHASE. That
agrees with the independently measured 389 unreachable routines: they are started from
the trigger queue at phase+272, by data rather than code.
Practical note recorded: the first dominator run was OOM-killed -- 6743 nodes each
holding a Python set of up to 6743 elements. Integer bitmasks run in seconds.
Not settled, and said so: dominance gives necessary, not sufficient, conditions; only
Stage 02's artefact is committed; one listed condition is still an unresolved
<unknown>; read_freg's units are inferred from the gate values, not read.
calls, phase-ends and conditions all regenerate byte-identical.
The linear walk's 10% unknown was a floor imposed by the method: a block entered only
by a branch has a well-defined state, just not one a straight-line pass can see.
tools/re-capture/isl_cfg.py replaces it with a worklist fixpoint that joins each
block's state over its ACTUAL predecessors -- a value survives only if every
predecessor agrees.
Over all 28 stages:
instructions reached by the CFG 85.0%
condition sites, unknown LHS 756 (10.00%) -> 402 (5.32%)
of those, never reached at all 389
joined away (predecessors disagree) 13
both resolve but DISAGREE 161 <- linear walk was wrong here
Those 161 are on top of the 889 the previous jmp fix caught.
Two zero-results on the way, both my own bug, both caught because the number looked
wrong rather than because a test failed:
* The first CFG run reached only 36% of instructions and made things WORSE (35%
unknown). Cause: the phase bases reach almost nothing. Most routines are
COROUTINES the engine starts from its trigger queue, with no static predecessor,
so every start_coroutine target has to be seeded as an entry.
* That seeding then found ZERO entries in a file with 216 start_coroutine calls,
because the target is staged in TWO steps -- special[0] = imm, then
local[0] = special[0] -- and I matched only the direct-immediate form.
Reachability went 36% -> 64% -> 85% as each was fixed.
The 389 still unreached are an honest limit rather than a gap: nothing in the bytecode
starts them; they are entered from the trigger queue at phase+272, by data rather than
code, so no purely static analysis reaches them.
isl_report.py conditions now uses isl_cfg; calls and phase-ends regenerate
byte-identical. Stage 02 unknowns drop from 71 to 25.
Reading builtin80's body (0x82268460) to name it: it is NOT a predicate. It
allocates a 20-byte object, stamps vtable 0x820A8CB0, magic 0xAB0311BA and the
unit's live object into it, pushes it onto a queue via the same helper push.i uses,
and returns 1 -- or 0 when the unit is absent. A command.
That made the conditions listing impossible: it showed a six-way switch
`if builtin80(TCT206) == 0 … == 5` on a function returning 1 or 0. Disassembling the
site shows two unconditional `jmp`s between the call and the compare, so 0x1B6C0 is
reached ONLY by a branch and its special[0] has nothing to do with builtin80.
op12 is unconditional -- the next instruction is never reached by fall-through -- and
the tracker walked through it exactly as it had walked through end_coroutine. Last
iteration I fixed the instance and not the class, leaving 22x more bad sites in place
than the fix removed.
A/B over all 28 stages, 7563 sites, resetting at jmp as well:
sites whose operands change 889 (11.75%)
LHS unresolved, before -> after 34 (0.45%) -> 756 (10.00%)
So the previous commit's headline "0.0% unresolved" was a MISSING CHECK, not a strong
result: the linear walk always had some value to report, and reporting it was the bug.
10% is the honest figure and the other 90% is trustworthy for a reason.
Also corrected: isl-unit-args.md illustrated its diff with 0x1B6C0, which is one of
the bogus sites. The UNIT_ARG result itself stands -- it came from reading
implementations, not from this listing -- but the example was picked from bad output.
Not done, and said so: recovering the 756 needs a dataflow join over each block's
actual predecessors, a CFG fixpoint rather than a linear pass. The branch targets are
all known so the CFG is available; the analysis is not written.
calls and phase-ends regenerate byte-identical; conditions changes on 187 lines.
isl.py's UNIT_ARG decides whether a built-in's slot-4 operand prints as a unit name
or a raw number. It was inferred statistically from operand ranges and, by its own
comment, listed a slot "only when the ratio stayed below 1.0" -- conservative.
The vtable base makes it a lookup instead: every unit-taking built-in's implementation
opens with lwz 324(phase) / lwz 4(argbase) / rlwinm 2,0,29 / lwzx / lwz 4(rec). Read
directly for all 147:
implementation indexes [phase+324] by an argument 55
of the statistical set's 31, confirmed 31 (zero false positives)
UNIT_ARG claims a unit, implementation does not 0
implementation says unit, UNIT_ARG missed it 24
The 24 include builtin80, group_ratio_pct, is_engaged, set_unit_flags,
squadron_trace, wait_units_ready and deploy_and_wait. Hand-verified by reading
builtin7, 16, 80, 105, 117 and 136.
Recorded because it nearly passed: my FIRST control -- whether the additions' operands
resolve to a symbol-table-2 index -- is worthless. The additions score 100.0%, but so
do the 31 baseline (100.0%) AND the 92 built-ins in neither set (99.3%). Symtab 2 is
dense enough that almost any small integer lands in it. A control the negative class
also passes is not evidence.
The control that discriminates is the tag word: a symbol operand is a two-word pair
whose first word is the constant 1, so slot0 == 1 exactly when slot 4 is a unit --
100.0% (13677 calls) / 100.0% (140) / 2.5% (2903). A 40x separation.
Artefacts: isl-stage02.txt and -phase-ends.txt regenerate byte-identical; -conditions
changes on 28 sites, every diff line pairing, each a raw number becoming a unit name.
Left unnamed on purpose: all 24. builtin80 returns a small enum (tested 0..4 in a
switch) but its body past the liveness check is unread; builtin103 is a predicate over
[phase+10152]/[phase+10156]; builtin105 tests a unit record's +16 against 4.
The listing showed end_coroutine as the left-hand side of 34 comparisons disc-wide.
That is impossible -- it returns no value a script can test -- so it was the bug
reporting itself.
The recorded fix ("set special[0] only for built-ins that write [phase+164]") is
REFUTED. end_coroutine's handler 0x82272624 is `addi r11,r0,1 ; addi r3,r0,3 ;
stw r11,164(r31)` -- it DOES write [phase+164], so that filter would have kept it.
Reading the handler before writing the filter is what caught this.
The real cause: end_coroutine returns 3, which DESTROYS the thread. Execution does
not continue past it, so the instructions following it in the flat stream belong to
a different routine and every tracked value is stale. The linear walk that makes
the decode possible is exactly what walks across that boundary.
A/B over all 28 stages, 7563 sites, resetting the tracker at end_coroutine:
sites whose operands change 34 (0.45%)
LHS = end_coroutine, before -> after 34 -> 0
left as an explicit unknown 34 (0.45%)
The two counts being equal is the result: the leak was confined to exactly the sites
that displayed the impossible value, so the other 7529 conditions were never
affected. Those 34 now print "<unknown: reached after a coroutine boundary>".
Not done, and said so: their RHS is still exact and the LHS is recoverable by seeding
the tracker at coroutine entries, whose targets are staged slot 0 of start_coroutine.
data/isl-stage02-conditions.txt regenerated; calls and phase-ends both byte-identical.
The deque ops are an EXPRESSION STACK: push the left operand, evaluate the right
(a built-in call, whose result lands in special[0]), pop the comparand back into
special[1], compare. Tracking that through the linear decode is enough to recover
what each site tests.
Evidence the model is right, not just plausible:
push vs pop across all 28 stages 1877 vs 1877
files that underflow or end unbalanced 0 of 28
Stage 02 pop.i sites followed by cmp.i 319 / 319
ops immediately before a pop.i call x313, cmp.a x6
isl.conditions() recovers 7563 condition sites disc-wide with 0.0% left as an
unresolved special[N]; 83.2% have a built-in call as the LHS and 99.7% compare
against a plain number. Most-tested: hp_pct_test 1955, unit_state 1257,
unit_relation 796, dist_lt 450, unit_alive 413.
They read as conditions now:
if unit_alive(TCN105) != 1
if hp_pct_test(ADT308, 0) != 1
if dist_lt(ADT308, TCN000, 15000) != 1 (world unit = 1 m, so 15 km)
if unit_state(ADT308) == 1
data/isl-stage02-conditions.txt was a stale artefact with NO generator -- the thing
isl_report.py's docstring complained about. It has one now (isl_report.py
conditions). The calls and phase-ends artefacts both regenerate byte-identical, so
the change is additive.
Recorded rather than glossed: 15 of Stage 02's 965 sites (1.6%) attribute the LHS to
end_coroutine, which returns no value -- the tracker sets special[0] on EVERY call,
so those show a stale value and are wrong, not imprecise. The fix is to set it only
for built-ins that write [phase+164], which the vtable work makes checkable.
Answers what the previous commit left open: naming the branches did not give a
clear condition, because that needs the operand chain feeding each compare.
First, a correction to my own work. isl-bytecode.md -- which OWNS the opcode table
-- already named ops 21-24 push.i/push.f/pop.i/pop.f. isl-branches.md, which I
wrote last iteration, said op21 and op23 were unread. The stale file was mine.
Verified from the thunks rather than accepted: 21 pushes [phase+168] onto the deque
at phase+44, 22 pushes [phase+184] onto phase+64, and the 23/24 handlers touch only
r3+168 and r3+184. So pop.i lands in special[1].
New: the 147-entry built-in table is a thin DISPATCH LAYER, not implementations.
Each stub resolves the local[] argument base and tail-calls a fixed ScriptPhase
vtable slot. 112 of 147 dispatch that way; 17 write [phase+164] inline; 0 write
+184. Every named predicate is in the vtable group -- unit_state 184, unit_alive
188, hp_pct_test 64, dist_lt 56, is_engaged 252, timer_elapsed 372 -- which is the
control that the split separates engine queries from script bookkeeping.
The vtable is 0x820A84BC, derived from a known implementation rather than a stride:
MARK_LAST_PHASE is documented as [phase+300]=2; the function 0x8226B498 is exactly
that stub; it appears as a data word at exactly one address, 0x820A8570; built-in
39 uses slot 180. The check NOT used in the derivation: built-in 40 mark_not_last
uses slot 176, and slot 176 holds the [phase+300]=1 stub. Predicted and confirmed.
The db's own vptr_writes independently lists 0x820A84BC, written at 0x82261B80.
unit_state = slot 184 = 0x8226ADF0, which indexes [phase+324] by local[4] and writes
its answer to [phase+164] = special[0] at both exits. The phase-3 poll loop now
reads end to end: unit_state(ADT308) -> special[0]; pop.i -> special[1]; cmp.i; beq.
isl.py names ops 21-24; the calls artefact regenerates with NO diff.
Left open and said so: the other 111 vtable slots, which comparand each site pushes,
the 35 non-vtable built-ins, and the vtable's length.
Closes the backlog item that was the last thing between the flat decode and a
per-phase clear condition, and closes isl-builtins.md's standing "op10 + op13 look
like a switch -- NOT confirmed".
op10 resolves two operands, issues a SIGNED cmp, and writes three condition bits to
a bitset at phase+24: bit 0 = EQ, bit 1 = GT, bit 2 = LT. op11 is the same machine
for floats via fcmpu. op13-op18 branch on those bits to [phase+232] + word@+4 --
the same phase-relative target form as the unconditional op12:
13 bit0 set beq 16 bits 2 then 0 ble
14 bit0 clear bne 18 bits 1 then 0 bge
15 bit2 set blt 17 bit1 set bgt
13/14/15/17 are byte-identical apart from the bit index and the polarity. All six
relations are present and each appears exactly once; that completeness is the check
that the reading is right, rather than the usage pattern -- which the item
explicitly warned against.
Operand order recorded because it is easy to reverse: LHS = (kind byte[1], word@+4),
RHS = (kind byte[0], word@+8).
Method note in the doc: the jump table at 0x822635FC holds THUNKS, and the handler
is the bl target inside each. My first pass guessed handler addresses at a fixed
stride, landed mid-function, and produced a 20-line "difference" that was pure
misalignment.
isl.py names the ops; data/isl-stage02.txt is regenerated and every diff line pairs
exactly, only the op-name column changing (op10->cmp.i x5, op13->beq x4,
op14->bne x1). data/isl-stage02-phase-ends.txt now shows the phase-3 poll loop
reading as one: unit_state(ADT308) -> op23 -> cmp.i -> beq back to 0xFEB4.
Left unnamed on purpose: op23 (0x82271C30) and op21 (0x82175C20).
Two files (isl_report.py's docstring and structures/isl-builtins.md) recorded the
same blocker on a faithful per-phase condition listing: that it needs the coroutine
entry points from start_coroutine's operand. Measured against isl.call_sites(),
which enumerates by scanning the encoding rather than by decoding and so is an
independent denominator:
linear + jumps, stopping at ret (what the tool did) 133 / 2846 = 4.7%
linear + jumps, continuing past ret 2275 / 2846 = 79.9%
... + following start_coroutine (the recorded fix) 2355 / 2846 = 82.7%
plain linear decode, no control flow at all 2846 / 2846 = 100.0%
Following the coroutine entries buys 2.8 points. Disc-wide, a plain linear decode
from the first phase base reaches 25705/25705 call sites over all 28 stages, and
28/28 decode clean to code_end with no desync.
The real bug was isl.dis ending on `if op == 20: break`. Op 20 is `ret`, but this
is a coroutine VM -- the thread suspends and resumes at the FOLLOWING instruction,
so code continues past it. dis() now takes stop_at_ret (default True, preserving
the old output: data/isl-stage02.txt regenerates byte-identical) and
isl.linear_offsets() is the correct walk.
By-product, kept with its control: start_coroutine's target is staged slot 0 --
73/83 phase-1 sites land on a valid instruction, against a 38.7% chance rate for an
arbitrary 4-aligned offset.
New artefact data/isl-stage02-phase-ends.txt with a committed generator
(isl_report.py phase-ends). It shows END_PHASE's call site is the WRONG place to
read a clear condition: all 12 Stage-02 sites sit in one stereotyped outro. Not
settled, and stated as such: op10/op13/op14/op21/op23 are unread handlers, so the
condition in the poll loop upstream cannot be named yet.
pilot.py gains SYLPH_WEAKEST=1, which scales a target's score by its remaining
hull (pos+0x154) so the pilot finishes what is already hurt instead of
re-engaging whatever is nearest. Motivated by 00dc016: over ~8 minutes the pilot
damaged 14 of 16 e010 attackers (hulls 360..500) and killed none, because 500 HP
spread across a squadron kills nobody.
STATUS: the flag is implemented and its targeting works -- 3105 of 3105 target
samples selected e010 -- but it is UNVERIFIED in combat, because the run it was
written for was lost.
That loss is the second half of this commit. The run printed "READY ROOM / >>>
HUD / Stage 02 OK" and I began the experiment; there was no FLIGHT: line, because
the flight check failed three times and fell through silently while the next line
read like success. The game was frozen on a near-black screen (screen_id `other`,
mean 10.8/2.8/2.1, frozen.py max_pixel_delta=0) and the pilot's every sample from
t=0.0 to t=406.1 is byte-identical with speed 0.
assert_stage.py could not have caught it: it reads the DEFINITION table, which is
populated when the STAGE loads, independently of whether the mission is running.
Recorded in nav-guards.md with the rule -- enforce the flight gate with a non-zero
exit, and run the three-crop TIME liveness check before any experiment.
Third Stage 02 run, pilot with SYLPH_PREFER=e010 (312 fire=1 samples), per-class
live counts logged every 11s beside the HUD (new tools/re-capture/class_count.py).
CONTROL CONFIRMED: the live turret population fell 108 -> 101 -- seven e007 deaths
-- and REMAINING OB never decremented, only rose. Previously this was inferred
from a run whose kill log happened to be turrets; it is now measured with the
classes counted directly.
ARRIVAL TIMING n=3: 004 -> 008 in (108.7s, 125.5s] and 008 -> 012 in (204.2s,
221.7s], both brackets containing the predicted 120 and 210.
The live e010 count sat at exactly 16 in 20 of 26 samples -- precisely phase 1's
e010 roster (ADT102/ADT107/ADT113/ADS151, each n=4) -- an independent runtime
corroboration of the static roster.
DECREMENT STILL UNPROVEN: the e010 floor never fell, so no marked attacker died
and the counter had no chance to move. Three runs have failed to kill one. The
blocker is combat effectiveness, not instrumentation.
Artifact recorded: six of 26 class samples read 17-28. Spikes are always upward
and transient -- the tool dedups on a position triple read just after the pattern
scan, so an entity written between the two reads is counted twice.
Every earlier refutation in this file carried the caveat that entities2.typed
types entities by their position CHANGING, so a stationary objective is invisible
to it. This session's definition-pointer enumeration does not have that limit, so
the sweep was re-run against it (ob_flag_all.py, guarded route, stage asserted,
HUD cropped beside each sample):
A: HUD 004, 147 entities -> 152 candidates
B: HUD 012, 133 entities -> 15 candidates
intersection: 1
The lone survivor pos+0x0250 = 239d6732 is the same offset AND identical value
this file already characterised as a per-group word. Membership test: all 12
holders are UN_e010_ADAN_Attacker_S, 12 of 16 live attackers. It is a squad
parameter, and it survived only because that population equalled the counter at
both samples.
Also reconfirms "not a class head-count" on 147 entities including capital ships.
Trap recorded: the first sweep reported 298 entities and a class with head-count
exactly 4 -- a perfect-looking hit that was pure artifact. Deduping by ADDRESS
leaves the measured exact 2x duplication (pairs 0x1000 apart, byte-identical
positions) intact and doubles every population. Dedup on the position VALUE.
The app owns TWO windows of class xenia_canary -- measured in the tree right now
as 10x10+10+10 and 1280x745+1+20. Largest-by-area picks the game window while
both are present, but during a load or mode switch the game window is briefly
absent from the tree, the 10x10 helper wins by default, and the crop produces a
10x710 SLIVER. That is exactly the grab screen_id classified as `menu` on
2026-08-26, which let a nav guard pass on garbage and loaded the wrong stage.
Ignore candidates narrower than 640 so nothing is selected in that case and the
existing fall-through hands back the raw root grab -- itself a valid full frame.
Verified on the selection logic directly: with both windows listed the pick is
unchanged (1280x745+1+45); with only the helper listed the old logic returned
10x10+10+10 and the new one selects nothing. Pairs with 6aa31e5, which rejects
such a frame at the consumer.
assert_stage.py checks the DEFINITION table against an expected stage marker and
earned its keep immediately: its first live run reported MISMATCH -- the capture
had a live flight HUD and would have been filed as Stage 02, but was the S01
tutorial. That is exactly the failure that silently invalidated an earlier
cross-run comparison.
require_menu (launch_mission.sh) refuses to press until screen_id reads `menu`.
It is NOT sufficient, and this refutes my previous explanation: the run DID
confirm the menu and still loaded the tutorial. The real cause was that the
guard's own capture was a 10x710 sliver which classified as `menu` -- fixed
separately in 6aa31e5.
Left open: whether the menu guard suffices now that slivers are rejected (not
re-run), and why the capture was a sliver at all when the other shots in the same
run were 1279x675.
Every statistic in screen_id is an AREA FRACTION, so a capture that is not a game
frame still produces clean numbers. Measured 2026-08-26: a guard shot came back
10x710 -- a sliver -- and classified as `menu` with green=0.0000, white=0.0157.
The guard passed, the fixed key sequence went out anyway, and the run loaded a
TUTORIAL instead of the save's Stage 02.
This is the second time this failure has been paid for. bin/screenshot's own
header records the first (2026-08-18): a second window of class "xenia_canary"
meant grabs came back as slivers and "a whole session's screen ids were noise".
That fix hardened the CAPTURE side only, so the same failure still reached the
oracles by any other path. Reject it at the point the answer is consumed too:
features() now returns None below 640x360 and classify() reports `none`.
Verified: the 10x710 sliver -> `none`; readyroom, flight and the briefing capture
all still classify as before.
The briefing map is cyan and satisfies every clause of the menu rule (b-r > 30,
r < 45, little white), with no earlier rule claiming it -- so it was labelled
`menu`. That made wait_screen.sh report NEVER REACHED READY ROOM on a run that
had successfully done LOAD GAME -> slot 01 -> YES and was three screens further
on: a working route scored as a failed one, pointing the next debugging step at
an input path that was fine.
Cyan has b and g nearly equal (b-g ~ 5) where the menu's blue leads its green
(b-g ~ 32), so `r < 20 and g > 30 and b - g < 20` separates them; the r floor
keeps the title screen out. The file's own docstring already carried the
briefing's mean as an aside -- it just never had a class.
Verified against all eight signatures the file documents (2 menu variants, title,
ready room, flight, 3 briefing measurements): no regressions, and the captured
briefing image now reads `briefing`.
Menu navigation here has always been screenshot-driven, which is unusable under
--gpu=null -- the only configuration where the game does not hit the
software-rasterizer freeze. Without a memory signal, the one backend that runs
is the one that cannot be steered.
Snapshotting 0x82800000+3 MB at each menu of a rendered run and keeping the
4-byte words that differ between screens and hold small integers leaves exactly
four of 786 432. One has the property that matters -- it changes on a screen
transition and holds steady when only the highlight moves:
0x828A690C screen id 1 title, 3 main menu, 4 extras
0x828F38AC menu cursor (second copy at 0x828F38BC)
0x828F37B4 per-menu value
Verified on a fresh --gpu=null run with no display at all, driving the same keys
blind: title 1/2/12, main menu 3/4/45, after 4x down 3/12/45, extras 4/14/49 --
4/4 exact against the rendered run, across two runs and two GPU backends. That
is the check that matters, since this corpus has already had to mark one runtime
address run-dependent.
tools/re-capture/menu_state.py reads them; `menu_state.py watch` prints on
change.
Open: the rest of the sequence into a mission. Blind driving reached extras
(screen 4) and a further A did not move it, so MISSION SELECT needs a cursor
move first. Screen ids beyond 4 are unmapped, and the rendered run freezes on
entering that screen -- so map ids up to the freeze, then step blind past it.
Last iteration concluded the freeze is "entering MISSION SELECT" and made
avoiding that screen the next experiment. Ran it; the conclusion was too
narrow.
First, a liveness metric that actually separates the states: two frames five
seconds apart, percentage of pixels changed. The menu animates, so healthy is
99.80-99.97% and frozen is 0.00%. No navigation script needed, and no
classifier. Committed as tools/re-capture/route_liveness_probe.sh; this is what
should have been used from the first run.
Then the menu's FIRST item, NEW GAME, which never touches MISSION SELECT:
main menu 99.80% alive
after A on NEW GAME 7.33%
after the next A 0.00% -- frozen, and screen_id calls it "flight"
with the same 134217728-byte AllocRange failure in the log.
So the correct statement is broader: the game freezes on the first content load
after the main menu, whichever item is taken. MISSION SELECT was just the route
every earlier run used. "Avoid MISSION SELECT" is withdrawn -- there is nothing
to avoid, and that also puts the memory account back at the centre, since ~379
MB live plus a 128 MB content load fails on any route.
Worth repeating because it caught me twice: screen_id.py called a frozen frame
"flight" on a run that never left the menus. Liveness first, classification
second.
challenge-mission-gate.md §5.6 attributes the 128 MB heap failure to a careless
cleared-stage mask poke, concludes that poking only real story ids does not blow
the heap, and ends by asking for the control: repeat without the poke.
Ran it. nav_to_flight.sh gains SYLPH_NO_POKE=1, which skips the write; only
Stage 1 is selectable without it, so the control changes stage too, which makes
the agreement stronger rather than weaker.
poked 0x0001FFFE Stage 02 frozen, 128 MB request refused
control untouched Stage 01 frozen, 128 MB request refused
Both logs carry not merely the same error but the same numbers:
requested 134217728 bytes, parent free 28969/131072 pages
28969 in both, across two stages and two boots. So the poke does not cause it
and neither does the stage; the guest reproducibly arrives at a 128 MB request
with ~113 MB free. An identical free-page count across independent runs also
says the allocation pattern is deterministic -- not a race, not host pressure.
The control was verified three ways, because the first attempt was confounded:
two emulators were alive at once (the previous one survived a pkill). The mask
was read back as 0x0 from the live mapping, the log was confirmed to be this
run's, and the liveness test was repeated after killing the stale process so
exactly one emulator was running -- three frames at rmse 0.00, and an 8 MB slab
of guest RAM with 0 bytes changed over 3 s.
The census filtered pak entries whose own first four bytes are T8aD. A sprite
is usually a child of a RATC bundle, and a bundle entry's magic is RATC, so a
top-level magic filter cannot see one:
top-level T8aD entries (counted) 4 525 sprites, 45 keys
T8aD inside RATC bundles (missed) 16 659 sprites, 204 keys
both 21 184 sprites, 216 keys
171 of the 216 keys exist only inside bundles. The sharpest statement of the
error: that census never saw GP_TITLE.pak at all -- the pak holding both of the
screens this page's entire evidence comes from.
Retracted: "45 values", "the keys are pak-local", "each auxiliary pak occupies
its own narrow high-byte band". On the full population 68/216 keys (31%, not
9%) cross a pak family and the per-pak ranges overlap heavily -- GP_BUNK
0x8000-0xa110, GP_TITLE 0x8000-0xc150, GP_LEADERBOARD 0x8000-0xf100. The tidy
banding was an artifact of seeing one or two keys per pak. So the key looks
like a shared vocabulary, which is the opposite of what I published.
Survives, now on the full population: the field is a u16 at +0x0A (upper half
zero 21 184/21 184), and it is an enumeration (216 values for 21 184 sprites).
Three wrong numbers on this page now, all the same shape -- a statistic computed
over a population I had not checked was the population in question. Stated once
at the end of the section rather than three times: check the sampling frame
before the statistic.