Static only. Bounding each GamePart's code block by its factory creator thunk
(id -> creator recovered for 22 of 24 registrations at 0x8280C000-0x8280F800)
puts GamePart_ChallengeMission at 0x82187E60-0x8218CF10. Resolving every string
that block references gives the screen's config schema, and the record itself is
on disc -- tables.pak schema 54a10697, one copy per language, English entry #64.
Six missions: TimeAttack (record Time), ScoreAttack (record Points) and
Extra01..Extra04, each with MISSION_ID / REQUIREMENT / REQUIREMENT_DESC /
THUMBNAIL / STAGE_DESC / NEW_STAGE and a NORMAL_BUTTON / GRAY_BUTTON pair -- so
the screen always lists all six and greys out what is not earned.
THE GATE (0x82189970-0x821899D8), read off the code:
REQUIREMENT absent -> available
REQUIREMENT == "Always" -> available
else n = atoi(REQUIREMENT)
n == 0 -> locked
n < 24 -> test bit n of the word at singleton+80
n >= 24 -> test bit (n-24) of the word at singleton+1956
The singleton is 0x821707C0 (lazy, global 0x828F48BC). So availability is one
bit in a progress bitfield and REQUIREMENT is a bit INDEX -- not a stage number,
score or difficulty.
Values per mission are 🟡: the pool's numeric tokens are 16/25/26/27/29 and
24/28 already appear earlier as font metrics, so they would be deduped -- which
fits 24..29 but IDXD dedup makes positional pairing unsound here, so it is
recorded as a hypothesis, not a table.
Negative: the requirement TEXT is not in GP_CHALLENGE.pak (TextIndex over it =
0 entries; its only prose is embedded font copyright). Its PATH is a per-language
branch the loader does not currently reproduce.
Next: three stores to +1956 sit in 0x822C7DD0 / 0x822C8748, the same region as
the save serializer 0x822C00E8 -- if the bits are save-backed, a hand-written
save unlocks all six challenge missions and the last 42 units become one run.
Static-only (no emulator, no pad input). Three findings, each with its own
evidence:
- The disc holds exactly 29 StageResource records in three families --
S01-S16 story, S18-S23 tutorial (all bg=Original), S24-S29 challenge, plus
Test. That is 16 + 6 + 6 + 1, matching weapon.tbl's stage01..16 /
tutorial01..06 / challenge01..06 key set exactly. S17 does not exist.
GP_CHALLENGE.pak has 0 IDXD objects -- it is the menu screen; challenge
missions reuse GP_MAIN_GAME_E.pak's records.
- The GamePart id table is at 0x820A1630 (29 ids). Indices are confirmed by
the image's own RegisterToFactory<N, class silph::GamePart_*> text, not by
position: GP_CHALLENGE = 26, GP_TUTORIAL = 25, GP_BUNK = 10.
- The stage loader selects its config section from a mission-KIND field at
object+144: 3 -> EXTRA, 5|6 -> CHALLENGE, else FILE (two independent sites,
0x82184df0 and 0x82185ed0; two more classify {3,5,6} as one group). The
constructor sets it to 0 and every write inside the class only clears it,
and no immediate 3/5/6 store to it exists image-wide -- so the kind is
supplied by the launching GamePart, never derived from the stage number.
That last point is a mechanism (unproven) for why patching the save's stage
field to 27 kills the load: the record is a challenge stage but the kind stays
FILE. Names an untried, zero-cost discriminator -- try stage 18-23.
Also flagged, not resolved: roster_target says S10 (a STORY stage) still
fields an unharvested unit, which contradicts the "story campaign complete"
claim by one unit.
Re-running roll with 5 s settles and in-run clock brackets:
this run's clock: TIME 00:34.93 -> 00:45.97 = 11.04 s game in 7.98 s wall = 1.383
min speed: 90.6 deg/wall-s /1.383 -> 65.5 deg/game-s (AV_Roll_Min 200)
max speed: 59.1 /1.383 -> 42.7 (AV_Roll_Max 125)
The corrected numbers are within a few per cent of the PITCH run's 67.8 and 40.9 —
two different stick axes cannot produce the same rates, so the probe is not
separating them. Cause: watching a non-forward matrix row sees any rotation that
moves that row, and pitch moves it as much as roll. The correct measure is rotation
ABOUT the forward axis (project the row onto the plane perpendicular to forward and
track that angle).
So "roll shows no speed dependence, unlike pitch" is withdrawn: it rested on 2 s
settles and a row that mixes axes, and the two runs disagree with each other
(144/150 then, 90.6/59.1 now). AV_Roll_{Min,Max} are not confirmed and the axis
question is open.
The clock ratio is now measured three times in three flights: 1.260, 1.311, 1.383 —
a property of the moment, not the machine, so every rate probe must bracket its own
phases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
pitch_gametime.py brackets each turn phase with HUD screenshots, so the mission
clock's own advance converts wall seconds to game seconds within the same run:
this run's clock: TIME 00:33.68 -> 00:44.12 = 10.44 s game in 7.96 s wall = 1.311
pitch @ min speed 88.9 deg/wall-s /1.311 -> 67.8 deg/game-s vs AV_PitchMinus_Min 75
pitch @ max speed 53.6 /1.311 -> 40.9 vs AV_PitchMinus_Max 40
Both land on the definition (the slow phase 10% low, consistent with including the
AA_* ramp in an 8 s window), so the clock explanation covers angular motion as well:
every stated rate is per GAME second.
The ratio is not a machine constant — 1.260 in the earlier flight, 1.311 here — so it
must be measured in the same run as whatever it corrects. Bonus: the same shots show
the HUD reading 102 at full LT against MinimumVelocity 100.
Also documents the trap that cost three runs: a killed Canary leaves both its shm
image and its last frame on screen, so a dead emulator looks alive and the scans
report "0 moving triples" like a tooling bug. pgrep -x matches zombies, so
speed_law.require_live_emulator() checks the process state letter and refuses to
measure a corpse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Cleanest linear measurement: neutral throttle (HUD = CruisingVelocity 350), sticks
centred, 20 s of perfectly straight flight (displacement/path = 1.000):
8 900 world units in 20.1 s -> 443.6 /s -> 1.267x the HUD's 350
And the game's own mission timer across a wall-clock interval:
TIME 00:08.79 -> 00:46.97 = 38.18 s of game time in 30.29 s wall = 1.260
Same number. So the linear discrepancy is not a unit difference: the mission clock
runs ~1.26x faster than wall time under this emulator, and dividing world
displacement by WALL seconds inflates speed by exactly that. World units and
displayed speed share one unit; the definition velocities are per GAME second.
This supersedes the previous "world-unit vs displayed-speed" reading.
Left open (❔): settled turn rates measured 74.9/41.1 deg/s in wall time against
AV_PitchMinus_Min/Max 75/40, but the clock argument predicts ~94 for the first.
Either that agreement was luck inside a noisy sample (per-window rates spanned
61-96) or angular integration is frame-based where linear is time-based. The check
is to re-measure pitch and convert wall->game seconds with the clock ratio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Screenshotting the HUD speed readout at each throttle step, beside the
position-derived measurement of the same moment:
RT 0.00 HUD 350 (= CruisingVelocity) position ~447 ratio 1.28
RT 0.25 HUD 507 position ~652 ratio 1.29
RT 0.75 HUD 963 position ~1141 ratio 1.19
So (a) the HUD speaks the definition's units — exactly CruisingVelocity at neutral,
963 at three-quarters against the 987 the interpolation predicts — confirming the
throttle law in the game's own numbers without any position sampling; and (b) world
displacement runs ~1.2x the displayed speed. Since settled angular rates need no such
factor, this is a unit difference between the position triple and the velocity
fields, not a clock effect: a reimplementation moving entities at MaximumVelocity in
world coordinates will be ~20% slow.
Also fixes speed_law.find_player: a mission holds more than one *_Player object and
at least one never moves, so the finder now samples each candidate twice and keeps
the one that displaces. Locking onto the static one is what produced a run of exact
zeros while the game was visibly flying.
🟡 The ratio is 1.19-1.29 rather than a clean constant and every sample was taken in
a firefight; pinning it wants a quiet map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Re-measured with 5 s of settle per phase and the speed recorded at the moment the
turn starts (flight_law3.py):
pitch @ 130/s 74.9 deg/s vs AV_PitchMinus_Min 75
pitch @ 1821/s 41.1 vs AV_PitchMinus_Max 40
roll @ 110/s 129.5 vs AV_Roll_Min 200
roll @ 1722/s 149.3 vs AV_Roll_Max 125
Pitch lands on the definition's own numbers with NO scale factor, so the ~1.2x I
attributed to the emulated time base two iterations ago was an artefact of
differentiating during the AA_* acceleration ramp with too little settle. That
explanation is withdrawn: AV_* can be used verbatim.
What remains is only on the linear side — settled speeds still read high and vary
between runs (RT full: 1342 in one flight, 1821 in another, vs MaximumVelocity 1200),
consistent with a craft being shoved around in a firefight. The HUD reads exactly
CruisingVelocity at neutral. A clean linear measurement needs a quiet map; no cause
is claimed until then.
Roll re-measured with proper settles confirms the axis difference: no speed
dependence, both regimes near AV_Roll_Max, where pitch moved 75 -> 40.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
One flight, two measurements (flight_law2.py, binding early so the moving-craft scan
can see the player).
LT curve: 436, 379, 289, 209, 126 units/s across LT 0.00 -> 1.00 — a straight ramp,
whose endpoints after the ~1.2 time-base factor are CruisingVelocity 350 and
MinimumVelocity 100. So the law is symmetric:
RT: target = Cruising + RT * (Maximum - Cruising)
LT: target = Cruising - LT * (Cruising - Minimum)
Roll (measured on a non-forward matrix row, since roll turns about the forward axis):
~144 deg/s at minimum speed and ~150 at maximum — no speed dependence, where pitch
dropped by a third to a half between the same regimes. After the time-base factor
that is ~121, i.e. AV_Roll_Max 125 in BOTH regimes.
So _Min/_Max does not mean the same thing for every axis: pitch interpolates with
speed, roll appears pinned at Max. A reimplementation applying one rule to all axes
would get low-speed roll wrong by ~60%.
Caveat recorded: the two roll phases were 2 s of settling apart, marginal for a
126 -> 1342 speed change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
RT is an analogue trigger, so "held" was one point on a curve. Walking it 0.00 ->
1.00 (throttle_curve.py) gives a straight ramp: 438, 626, 879, 1094, 1342 units/s.
Dividing by the ~1.2 time-base factor, the endpoints land on the definition's own
numbers (365 vs CruisingVelocity 350; 1118 vs MaximumVelocity 1200) and the midpoint
follows, so
target speed = CruisingVelocity + RT * (MaximumVelocity - CruisingVelocity)
which refines the earlier "selects one of three targets" reading: those three are the
curve's endpoints.
It also refutes the standing afterburner hypothesis that full RT is the burner: the
curve is smooth through full deflection with no step, and the shield does not move.
The LT half is not measured yet — the entity scan needs the craft moving when it
runs, so a mission left idling drops out of it. Bind early.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
The definition describes the burner (AB_ConsumeShield_Begin 50, AB_ConsumeShield 10,
AB_AV_* turn caps well below normal) but names no input, and carries no AB velocity
field.
Three probes, all negative for A, B, X, LB (plus LS/RS on the first):
- ab_probe.py: hold RT for a max-speed baseline, then each candidate — speed stayed
inside the baseline's own noise band every time.
- ab_state_probe.py: sample a window of the player object during each hold and
report any float that falls — nothing fell.
- HUD oracle needing no offsets: count green pixels of the SHIELD bar on a freshly
spawned craft. AB_ConsumeShield_Begin 50 should take a visible bite; the bar read
156/156/156/157/157 across baseline and all four buttons.
So the burner needs a chord, an input this pad cannot reach, or belongs to another
craft/the AI. Recorded so the obvious buttons are not re-probed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
turn_law.py pins the speed regime with a throttle, holds a stick axis and
differentiates the craft's own forward vector over 1-second windows.
slow + nose down ~87 deg/s (AV_PitchMinus_Min 75)
fast + nose down ~54 (AV_PitchMinus_Max 40)
slow + nose up ~175 (AV_PitchPlus_Min 150)
fast + nose up ~136 (AV_PitchPlus_Max 70)
So agility falls with speed (_Min/_Max are at minimum/maximum speed, not rate
bounds) and pitching up is ~2x pitching down, exactly as the field pairs say.
Control mapping measured: LX is roll (forward vector barely moves, 3-5 deg/s), LY is
pitch (+1 = nose down per vgamepad's LY: -1 = up), and the right stick does not steer
at all.
The ~1.2x overshoot seen in the speed law appears again here (1.16-1.35x), and a
unit scale cannot explain both m/s and deg/s — a TIME BASE can: if the guest's
simulated second is shorter than the wall-clock second the probe measures against,
every rate reads high by the same factor. So the definition numbers are
self-consistent and these measurements confirm the shape of the law, not a scale.
Recorded 🟡: no yaw input found (AV_Yaw_* exists but neither stick yaws), which with
roll on LX and MaximumBank_Normal points at a bank-to-turn model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
speed_law.py locks onto the player entity once and samples its position while
holding each throttle input, differentiating over 1-second windows.
no throttle -> ~420 (CruisingVelocity 350)
RT held -> ~1 530 (MaximumVelocity 1200)
LT held -> ~125 (MinimumVelocity 100)
release -> back to cruise, from either direction
So the throttle SELECTS a target speed rather than adding thrust — which is what a
reimplementation would most likely have assumed from Acceleration/Deceleration
alone. Those govern the convergence rate instead: ~440 units/s^2 measured on
release (Deceleration 500) and ~470-560 under RT (Acceleration 600).
Recorded as 🟡: measured world speeds run ~1.2-1.3x the definition numbers in all
three regimes while the HUD shows the definition value exactly (350 at cruise), so
world coordinates are a constant multiple (~1.25) of the definition's velocity unit;
the spread is wider than the constant is precise because the craft manoeuvres while
sampled.
Three traps documented: RT/LT are analogue triggers (the button verb is a silent
no-op and the first run measured an unflown craft), per-sample differentiation
aliases against the guest's update rate (0, 1519, 1985, 0, 2681 for smooth flight),
and the player entity only enters the typed scan ~15 s in while the craft dies within
minutes if nobody flies it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
S08 gave the last two story-stage units — UN_be005_ADAN_SpaceFortress and
UN_mn500_ADAN_FloatingMine — plus six S08 asteroid collision meshes: 68 units,
9 393 rows, 7 115 defaulted-on-disc values. Every unit any story mission fields is
now read (689 more disc cross-checks, 0 disagreements).
Probe recorded as a negative: setting Game Status (GHAD +48 and its header mirror
+0x18) to 2 = STATE_GAME_CLEAR leaves both menus unchanged — the title still shows
NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS and EXTRAS still shows only
MISSION SELECT / MOVIE THEATER / BACK. So the EX/challenge rosters are not gated on
that field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
S03, S06, S09, S13, S15, S16 (plus the earlier run): 21 -> 60 units,
963 -> 6 071 defaulted-on-disc values, 8 241 rows. Disc cross-checks agree with
0 disagreements throughout.
- UN_e901_ADAN_Boss and UN_e910_core_ADAN_GeneratorCore are harvested (HP 10 000,
Size_X 500, Size_Radius 250, MaximumVelocity 450) WITHOUT the boss ever being on
screen: definitions are instantiated at stage load, so a unit only needs to be in
the roster. That is far weaker than what the draw-capture work requires.
- The extra stages are not reachable via the save's stage field: stage 27 boots to
the title and then the emulator exits during the load, as do the other S24..S29
entries, while story stages 1-16 all load. The field addresses the story campaign
only; EX/challenge rosters need the Challenge-mode menu path.
Remaining 50 units are almost all EX variants in S24/S25/S27/S28/S29; the only
story-stage stragglers are UN_mn500_ADAN_FloatingMine and
UN_be005_ADAN_SpaceFortress in S08.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
examples/roster_target.rs ranks stages by how many roster units are still
unharvested. The EnumUnit_S<NN> tables are found by hashing candidate TOC paths
(hash::TOC_NAME_SCHEMES) — UnitRoster::stage can only infer a tag when the roster
carries a UN_S<NN>_ prop, which most do not.
It picked S09 (10 missing). Flying it: 26 -> 36 units, 3 345 -> 4 785 rows,
2 351 -> 3 439 defaulted-on-disc values. New: e102_Battleship, e104_Carrier,
e107_AAFrigate, e011_Attacker_B, e008_TurretPlus, be001_TerrafoamingUnit,
e001_Elan_GR{,_Violeta}, f102_LightCarrier_Inv, f106_Destroyer_Inv.
Also settled: the definition objects are mission-independent. Eleven units appear in
more than one snapshot and four are not byte-identical, but compared through the
layout ZERO mapped fields differ — the 12 differing slots are all unmapped (offsets
4/8/16/20 and 0x250/0x268/0x300-0x308/0x330-0x338: object header and sub-object
pointers). So a harvested value is the definition, not a per-mission tweak, and the
earlier UN_f201_TCAF_Tanker flag resolves the same way. Cross-checks over three
snapshots: 1 052 agree, 0 disagree.
Third angle field found the same way (Through_AngleMaximum = 60 degrees in radians),
so the degrees<->radians rule covers any name containing "Angle".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
unit_runtime.py can only place fields the disc values (it scores triples against
disc records): 58 of 153 from one snapshot. data/unit_definition_layout.txt came
from the title's loader instead, so it places all 159 — including the fields no
disc record sets, which is the Route-B target.
tools/re-capture/unit_dump_layout.py reads every field of every live definition
object with that layout and keeps the discipline: a field the disc DOES value is a
check, not a new value. Over two snapshots (19 objects): 700 cross-checks agree,
0 disagree.
fields placed per object 58 of 153 -> 159
rows over those 19 units 609 -> 2 736
defaulted-on-disc values (whole file) 1 059 -> 2 351
Two traps recorded: the layout table's offsets are DECIMAL while the solver CSV
prints hex (parsing as hex fails the cross-check on everything — which is how it
announced itself), and angle fields can carry a prefix (AB_AA_PitchPlus is still an
angle, so the AV_/AA_ test must match anywhere in the name).
Flagged: UN_f201_TCAF_Tanker's object is not byte-identical between the two
missions — per-mission override or a runtime-mutated field; needs a third snapshot
to separate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Unit definitions are instantiated per stage, so the parked "coverage grows by
visiting missions" limit was blocked on progress. With the save's stage field
solved, any story stage can be flown from a hand-edited save and snapshotted.
First harvest (a Night Ravens mission, 027 objectives, different roster):
units with runtime values 21 -> 26
csv rows 1 827 -> 2 143
defaulted-on-disc values 963 -> 1 059
New: UN_e004_ADAN_ElanPlus_N, UN_e006_ADAN_Vindicator_Margras,
UN_f002_TCAF_DeltaSaber_W, UN_f002_TCAF_DeltaSaber_W_Player (a second player craft)
and UN_mn040_Asteroid_Big (47 defaulted values, Size_Z = 2000).
Two traps recorded with the fix: launch_mission.sh presses A on the PRESELECTED
slot, which is the last-used one (03 here), so a probe in slot 01 is ignored — the
first attempt flew stage 02 again and the solver correctly found 0 new units; patch
every slot or confirm the highlighted slot by screenshot. And back up before
patching: the mid-run backup already held the probe, so slot 01 was restored from
the earlier pristine copy (md5 142b4f43…, verified byte-identical).
Token file for the solver: idxd_tokens <GP_MAIN_GAME_E.pak> Generic — 110 UN_*
records, the disc's full unit count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
examples/why_missed.rs reports the furthest gate per undecoded resource. Disc-wide
that is 47 rows / 43 distinct names, and they are mostly not ship geometry:
30 e_rou_/_rou_ pose & proxy composites (24-vertex marker boxes, extent 0.010)
6 .DAT particle composites in ptc_pack
8 damage/LOD variants (e101_bdy_02_d, e901_wing_05_*_m, ...)
3 props/other (g005 extent 0.196, _rou_f001_wep_05, e_rou_e005)
By gate: extent 32, coverage 8, winding 7. The extent bucket is almost entirely the
pose-proxy boxes, and lowering that floor was measured and refuted earlier. The one
genuinely interesting residual is e901_wing_05_L/R at winding 0.587/0.570 against the
0.70 floor — the signature of a thin double-sided sheet, unproven without a capture
with the boss on screen (flying stage 16 puts the container in memory but the unit
never appeared).
So: 6 247 / 6 294 resources decode (99.25 %), and every real mesh drawn in three
captured missions decodes at the GPU's own offsets with index runs matching byte for
byte (93/93 and 128/128).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
With per-sub-mesh declarations enabled, n201_01 decoded as a 2-part fragment 4 bytes
off. Both starts validate for the pivot — 0x32BA718 at pad 2 (earlier in file order,
so first-match took it) and the capture-proven 0x32BA71C at pad 0 — so the pivot
alone cannot separate them; at the early one two of four sub-meshes fall out as
out-of-range.
anchor_grouped_meshes now builds each accepted candidate and keeps the one that
explains the most of the declared pool: it returns immediately when a candidate
explains all n sub-meshes, else keeps the best partial, so it can never decode less
than first-match did. n201_01 lands on all four capture-proven offsets
(0x32BA71C / 0x32BEFF4 / 0x32C416C / 0x32C536C) and its two sibling copies take their
own pools, so the twin collapse is gone.
XBG7_SUBMESH_DECLS is therefore on by default (=0 reverts):
resources that never decode 85 -> 47
resources decoding in no container 63 -> 30
degenerate index runs 1 -> 1 (unchanged)
cross-container minority decodes 96 -> 96 (unchanged)
captured index runs, stage-02 93/93 (unchanged)
captured index runs, stage-05 124/128 -> 128/128
The last line is the point: the buffers the capture could not name are the n201
family, and they now decode and match the GPU's indices byte for byte. Suite green
including twin_pairs_do_not_share_a_buffer, apart from the pre-existing
known-failing cross-container consistency test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
desc_dump shows each index marker is followed by its OWN element triples: n201_01
declares strides 24, 24, 24 and 28 (the last sub-mesh has a fourth element), which
matches the runtime capture's stride=28 on that draw and the four distinct vertex
shaders. parse_vertex_decl read the first declaration for the whole pool.
all_vertex_decls reads one per marker; anchor_grouped_meshes uses each sub-mesh's
own stride for the pool walk, the pivot validation and the read. debug_grouped_report
follows the same setting so the diagnostic cannot blame the wrong gate — at n201_01's
capture-proven pool start it now reports "pad 0: ACCEPTED" instead of a NaN position.
With XBG7_SUBMESH_DECLS=1: resources that never decode 85 -> 47, resources decoding
in no container 63 -> 30, capture oracles unchanged (93/93 index runs, 42/42 index
counts), consistency unchanged at 96.
Off by default because selection has not caught up: the three n201_0x copies then
settle on one pool (twin_pairs_do_not_share_a_buffer fails), production still picks
a start 4 bytes before sub-mesh #1 rather than the proven one even though the proven
start validates and is unclaimed, and four newly decoded ptc_pack .dat composites
carry degenerate triangles. Format truth is settled; choosing among candidates is
the remaining work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
capture_ib_truth now proposes an identity for each drawn buffer our decoder cannot
place, by matching (vertex, index) counts against declared-but-not-decoded
resources. That identified n201_01/_02/_03 in Stage_S02.xpr — three of the 63
resources that decode in no container — and the capture pins all four sub-meshes:
#0 vb 0x32BA71C ib 0x32B39FC 4464 idx 777 v stride 24
#1 vb 0x32BEFF4 ib 0x32B5CDC 4464 idx 869 v stride 24
#2 vb 0x32C416C ib 0x32B7FBC 576 idx 192 v stride 24
#3 vb 0x32C536C ib 0x32B843C 4464 idx 869 v stride 28 <- different
The layout matches our assumptions exactly (tight index packing, last buffer flush
against vb0 so pad 0, span 27936 == align4-summed markers, contiguous vertex
buffers, max index == verts-1 everywhere). The defect is that sub-mesh #3 has a
different stride AND its own vertex shader: anchor_grouped_meshes parses one
declaration per resource and applies its stride to every sub-mesh, so it reads #3
out of phase (372 non-finite position components of 2607), and since the pivot is
the largest index count with ties going to the last marker, the pivot IS that
sub-mesh — so the whole resource is declined.
Also adds examples/miss_targets.rs (which container to aim a capture at): 63
resources decode nowhere, 58 of them in exactly one container, clustering as
Stage_S16 21 (e901_wing_05_*), ptc_pack 12, Base 6, then per-stage n2xx groups.
Flying stage 16 did not draw the e901 wings — the container is resident but the
unit must also be on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Ran ship_capture_close.sh in story stage 05 (reachable now via the save's stage
field) instead of stage 02: 3 logs, ~11 200 draws.
- Out-of-sample check of the pad-scoring fix: 124 of 124 index runs identical to
the GPU's, 2 769 index elements, 0 differing — on draws the fix was not derived
from (the stage-02 capture gave 93/93).
- A mission keeps SEVERAL stage containers resident: 65 drawn buffers place in
Stage_S02.xpr at 0x17FE3FF4 (the shared TCAF/ADAN fleet) and 5 in Stage_S05.xpr
at its own constant 0x1927B7F4 (the f101 ACROPOLIS, this mission's escort) —
the first capture-truth rows for a container other than Stage_S02. Both sets
have exact index counts and exact coverage.
- Why: f105_bdy_01 lives in 13 of the 22 stage containers and not in Stage_S05 at
all, so the resident set follows the mission's unit roster, not the stage number.
Consequence for the residual misses: a different story stage is not a different
container. Aiming a capture at a specific container is a static question (resource
names per container x the stage's EnumUnit roster).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Static analysis of the header builder (0x822870b4..0x82287128) shows the GDHA
container header carries a SUMMARY of the progress block, and the screens read
that: hdr+0x14 <- GHAD+52, hdr+0x18 <- GHAD+48, hdr+0x1c <- +24 (Points),
hdr+0x20 <- +4 (flight), hdr+0x24 <- the computed clear ratio, hdr+0x28 <- +12.
savegame_edit.py copies the donor header verbatim, so payload-only probes left a
stale summary — which is why +52 looked refuted.
Patching both (hdr[0x14] = 5, ghad[+52] = 5) makes the title read
"STAGE 05 - Star System Escape", load it, brief the Gallia Asteroid Group, and fly
an asteroid-field mission with a different objective and roster. So +52 is the
stage number, 1-based, and one u32 chooses the mission.
Also corrected in the GHAD table: +48 is the Game Status enum (0 = At Standby,
matching the screen's STATE_STAND_BY/STAGE_CLEAR/GAME_CLEAR list) and +12 is Times
Cleared, both via their header mirrors.
This unlocks runtime capture in containers other than Stage_S02 (the 85 XBG7
misses, box identity, ship-placement generalisation) and per-stage unit
definitions. Slot 01 was backed up and restored byte-identically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Four probes against slot 01 (the save the title-menu screens read), each backed up
and restored byte-identically:
- SHAB[1..7] filled -> MISSION SELECT still locks Stage02-08, cursor will not
leave Stage01. The per-stage record table is not the unlock gate; the earlier
SHAB[1] attempt used a throwaway slot and could not show this.
- GHAD +36/+52/+56 = 5 -> still locked, panel still STAGE 02 / EASY.
- GHAD +16 = 4 -> panel still STAGE 02, so it is not a 0-based stage index.
- live-RAM writes into the loaded GDHA header -> panel unchanged.
The useful part is why the panel cannot answer this: guest RAM at that screen holds
NO payload bytes (neither the 4101/4101/79 triple nor the 54-byte develop blob),
but it does hold the save's GDHA container header with a summary copy of Points,
flight time, clear ratio and a FILETIME. savegame_edit.py copies the donor header
verbatim, so a payload edit leaves that summary stale — "the panel did not change"
therefore cannot separate "not the stage field" from "the panel never reads the
payload". Future stage/difficulty probes must patch the header too, or be judged by
what the game does on load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Two follow-ups to the pad-scoring fix, both driven by the same invariant (a
correctly located index run has no degenerate triangles):
- anchor_grouped_meshes picked its pad by first-match too; scoring the pivot run
the same way cleared every remaining ptc_pack composite (f102/f104/e107).
- anchor_pool_mesh now prefers a degenerate-free candidate over an earlier dirty
one. examples/better_home.rs showed the last two resources each had exactly one
degenerate-free, pool-covering block, sitting later in file order than the
lookalike we took. First-match order is kept for every clean hit, and a dirty
block is still used if nothing clean exists, so coverage cannot regress.
degenerate index runs, disc-wide: 582 -> 11 -> 1
captured index runs identical: 93/93 (unchanged)
resources decoded / misses: 6 209 / 85 (unchanged)
index runs changed / anchors moved: 590 / 10 (_rou_f402_dead x8, e201_bdy_03_m x2)
Cross-container minority decodes 89 -> 96, and that is progress: all seven new
rows are _rou_f402_dead, which now has a majority (32x25x8) for the first time, so
its seven wrong copies are named instead of hidden behind "no majority".
The last dirty run (_rou_f402_dead in Stage_S09) is blocked by distinct assignment
— its clean block is claimed by e_rou_f003_Near, both 24-vertex bounding boxes. A
winding-floor escalation for that case was written, measured to fire for nothing,
and reverted; the reasoning is kept as a comment.
Regression threshold tightened to 1. Suite green with --include-ignored apart from
the pre-existing known-failing cross-container consistency test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
ship_render --static on f101 (ACROPOLIS) with and without XBG7_PAD_FIRST_MATCH=1.
Identical triangle count, placements and bounds; the old wiring renders as a mess
of shards, the fixed one as a coherent hull. Kept as the evidence image for the
pad-scoring fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Extending the capture comparison from index COUNTS to index VALUES
(`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging
gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17
differing — every difference a shift by exactly one element, on buffers whose
index data sits at pad 2.
`anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried
first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block
yields [true[1], true[2], …, garbage]: every index in range, the pool covered,
the positions right, the winding often just above 0.70 — so it validated, and
every triangle was mis-wired. Nothing count-based could see it.
The signature is decidable without the capture: a shifted run wires arbitrary
vertices, so triangles come out degenerate. 282 of 283 correctly anchored
Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry
1–2 156. So score every validating pad by (degenerate triangles, then winding)
and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour.
captured index runs identical: 76/93 -> 93/93 (2 025 elements)
decoded runs with a degenerate triangle: 579 -> 16 (disc-wide)
sub-meshes whose index run changed: 575 of 8 850
resources decoded / vertex anchors / consistency: unchanged (6 209 / same vb / 89)
Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.
Suite green with --include-ignored apart from the pre-existing known-failing
cross-container consistency test (the 24-vertex bounding-box class).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
The decoder's central unstated assumption — a block's index buffer sits
immediately before its vertex buffer (`vb - idx_count*2 - pad`, pad <= 3) — was
also the prime suspect for the residual anchor misses, since a capture-proven
`e106_eng_02_l` block was rejected outright. Measured it instead of assuming:
- extended the F10 ship capture to log each draw's index buffer (base, count,
min/max index) and to key its de-dup on the index range, so every draw batch
is recorded rather than only the first;
- `examples/capture_ib_truth.rs` places each drawn buffer in the container by its
dumped positions and scores the capture against our decode.
Stage_S02, 42 drawn buffers placed: our idx_count == the sum of the draw's index
batches for 42/42, the batch union covers the vertex pool exactly for 42/42, and
all 30 single-block cases sit at pad <= 3 (20 at pad 0, 10 at pad 2). The other
12 are grouped pools, where one index pool serves the whole group. So the layout
holds, the decoded index count is exact, and eng_02_l died on the connectivity
gate (since replaced by the winding gate) — not on index location. The shipped
exact-coverage rule is independently confirmed.
The recorded "capture indices=21 vs our 246" disagreement was an artefact of the
old de-dup key: 21 was the first of two batches, 21 + 225 = 246. Any conclusion
from a pre-2026-08-13 capture's `indices=` or `vbase - ibase` is about one batch,
not about the block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Two independent derivations of the unit definition layout -- the earlier
disc-value-to-RAM-word solver and this session's extraction from the loader's own
key strings -- agree on 25 of 25 shared offsets, 0 disagreements. The code-derived
table covers 159 fields against 27 confirmed, while the earlier one uniquely holds
ScorePoint (+0x08c) and MassScore (+0x094).
Also flags a tentative entry as an artefact: Slalom_CutoffRatio at +0x00c records
2.8026e-45 / 1.4013e-45, which are the denormal readings of the integers 2 and 1 --
the same trap that produced a false YawDragFactor hit while building the new map.
INDEX updated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The offset->field map read out of sub_82341A20 is now usable rather than just
documented: data/unit_definition_layout.txt carries the 159 fields with their
provenance and the two conventions (angles are degrees on disc and radians in the
object; a defaulted field keeps the accessor's 0.0 miss value), and
sylpheed_formats::unit_layout exposes fields()/field_at()/offset_of() so a memory
snapshot can be read by name.
tests/unit_layout_disc.rs replays the verification against the checked-in live
dump -- every mapped float of all 11 identified objects must equal its disc value,
angles compared in radians -- asserting 0 disagreements and >=400 agreements. It
needs no emulator. Full suite green: 11 binaries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applying the map to turrets, fighters, an attacker and a station (UNIT records
rather than VESSEL) gives 406 field values agreeing with the disc and 0
disagreeing across 11 objects, so one layout serves both schemas.
Two consequences the four-value signature could not give: the map IDENTIFIES an
object -- 0xbd3b6a00 is UN_bf001_TCAF_SchlosBase (14 agree, 0 disagree), not
UN_be005_ADAN_SpaceFortress (12 agree, 2 disagree on Color_R/Color_G) -- and
variants are provably identical, with ArrowHead/_EX4/_EX5 at 48/48, Turret/_EX4
at 51/51 and three DeltaSaber player variants at 49/49.
121 defaulted fields now have runtime values. FiringRange (0 in all 11) and
ShieldRatio (1 in all 6) look like true constants; HQRatio, ThrusterRatio and
AttackCraftPoint vary per unit and are recorded as such.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anchoring each key string to the first store AFTER the accessor call makes the
extraction exact: 159 fields, 125 of them floats, and verify_fieldmap now checks
every one against the live objects -- 154 agree with the disc, 0 disagree.
The last ten mismatches taught the rule: angle fields are degrees on disc and
RADIANS at runtime (AV_PitchPlus_Max 3.6 -> 0.0628319; MaximumBank_Normal 60 ->
1.0472), an exact conversion across every angle field and unit.
101 defaulted fields now have runtime values: angle limits default to 3.6 degrees,
afterburner/turn-attack fields to 0, FiringRange to 0 (matching the loader's miss
value), Size_Y to that unit's Size_X, and the ratio family is per-unit rather than
constant (HQRatio 0.2 or 1, DefencePoint 0.1 or 0.25, ...).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The filler builds each key as addi r4, r30, -N with r30 = 0x82088f94, so every
store's field NAME is a string in the image. Pairing keys with the following stfs
gives the layout outright: Size_X/Y/Z at +48/52/56, HQRatio +88, ShieldRatio +92,
ThrusterRatio +96, resistances +116..132, Radar/FCS/FiringRange +672/676/680,
Attack/Defence points +692/696/700. verify_fieldmap.rs checks it against the live
dump: 62 fields agree with the disc, 0 disagree.
That yields the runtime value of each field for units whose record omits it --
HQRatio 0.2/1, ThrusterRatio 0.2/1, ShieldRatio 1, ResistanceToPlayer 1,
ResistanceToShell 0.1, ResistanceToExplosion 0.5, AttackVesselPoint 0.1,
AttackCraftPoint 0.1/0.5, DefencePoint 0.1/0.25, FiringRange 0, Size_Y = Size_X.
Recorded with the caveat that several show two values across units, so these are
per-unit runtime values rather than one global default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pool_window.rs shows the Destroyer's raw tokens: "200.0" "Size_X" "Size_Y"
"2000.0" "Size_Z" -- Size_Y is a bare key, so the 13 runtime values recovered
earlier are real defaults, not a reader artefact.
The filler reads each size field through sub_822FC5A8, which loads f31 from
0x8209fd28 = 0.0 at entry and returns it on a pool miss, then stores to +52.
Nothing else in the 15876-byte filler writes +52, so the definition leaves a
defaulted Size_Y at 0.0 and the runtime 200 is written later.
Two corrections: the +48/+52/+56 comparison block builds a size-class bitmask
against 1000.0 (constant 0x8209fd20), not a has-value mask; and both earlier
Size_Y derivation candidates are refuted (one is a conditional pick, the other
multiplies into a different struct).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Call graph completes: sub_82341048 allocates 880 bytes, calls the zero-filling
constructor, then sub_82341A20 (3969 instructions, 157 stfs, 892 calls) fills the
fields. A repeated lfs/stfs pattern writes the constant at 0x820854cc -- read from
the .pe as 1.0 -- into ten offsets, which looks like a blanket default.
Checking those offsets in the live objects first shows it is not: they hold
per-unit radian angles (10/15/16/30/40/45/60 degrees), so the 1.0 belongs to the
degree-to-radian conversion, not to a default. The ordinary default is 0 from the
constructor; non-zero defaults like Size_Y <- Size_X are specific derivations.
Incidental and useful: angle fields are radians at runtime, degrees on disc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sub_8233FAF8 (0x8233faf8) writes vtable 0x820af844 -- the class dumped from a live
mission -- and every one of its 32 stores writes zero, out past +872. So defaults
are NOT constructor immediates, which was the hypothesis this tick set out to
test. Since a defaulted Size_Y nevertheless reads back non-zero at runtime (always
equal to Size_X), the value must be derived by a post-parse fixup -- the same
shape as the inheritance rule derived statically from sibling records.
Two candidate derivation sites (0x82217e18, 0x823081e8) are recorded as leads
only: the first is a conditional assignment rather than a copy, and offsets 48/52
are generic enough to belong to unrelated structures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sylpheed.db has the machinery named: IdxdLoad_Dispatch 0x824486c0, Idxd_Parse
0x82449640, Reflect_FindFieldIndex 0x8244a2f0, Reflect_SetField 0x8244a4b0.
FindFieldIndex looks a field up by NAME at runtime (strlen + compare), which
supports the reflective-read reading -- but Idxd_Parse is a text parser that
calls SetField on a target object, so a parsed record does exist and the earlier
'no flat record' hypothesis is weakened.
The useful consequence: Idxd_Parse only writes fields the text contains, so a
DEFAULTED field keeps whatever the constructor stored. The defaults are therefore
immediates in each definition class's constructor -- reachable by walking the call
graph above IdxdLoad_Dispatch -- not values to be scanned for in RAM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Searched RAM for HP 25000 (200+ hits, too common), for the rare float
AttackVesselPoint 0.08 (64 hits, no window holds the unit's other values, one hit
is a code constant), and for the ID string itself (two copies, each pointed at
from -0x10, but the surrounding 256 words hold none of the numbers because that
region is the IDXD string pool where values are ASCII).
Hypothesis recorded as 🟡: IDXD is reflective and the engine likely reads values
from the pool by key on demand, caching only the per-frame ones -- which is
exactly what the 0x820af844 object contains at any dump depth. If so, a defaulted
field has no value anywhere in data and the default is applied by code at read
time, so Route B for the Ratio/Count family needs the read path or the static
DuckDB route, not more scanning. The 13 Size_Y values worked because Size_Y is
one of the cached fields.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dumping 512 words instead of 96 finds exactly the same 4 of ~50 disc values, for
all five correlated units, so the rest of the record is not in this structure.
Also corrects the reason recorded last commit: get_f32 is NOT unreliable on
default-heavy records -- it resolves 46-57 numeric fields per unit, which is what
made the emptiness of the correlation measurable in the first place. The one
automated hit (YawDragFactor -> +0x0c) remains false: +0x0c holds the integer 2
as a denormal and collided with the float 2.0.
Next probe is a RAM-wide search for a unit-distinctive value, not a bigger window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anchoring on a unit whose disc record sets a field locates it in the live object:
Size_X/Y/Z (+0x30/34/38) and HP (+0x54) confirmed across five capital ships and
the player fighter; +0x74 (0.8) and +0x84 (0.97) are probable ThrusterRatio and
ResistanceParalyze but rest on a single anchoring unit; +0x40 varies per unit and
is unidentified.
live_offsets.rs automates the correlation and currently produces one hit which is
false -- YawDragFactor 2.0 collided with the integer 2 at +0x0c stored as a
denormal. Causes recorded: get_f32 is unreliable on default-heavy IDXD records
because the value-before-key pairing shifts, and 96 words only reaches +0x180
while RadarRange/FCSRange sit beyond it. Next run dumps 512 words.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live definition objects carry no name, so identity comes from the values:
unit_signatures.rs prints (HP, Size_X/Y/Z) for every disc unit and vessel, and a
live object is the record whose known fields it reproduces. 7 of 14 match a disc
record outright; the other 7 match nothing because their records default a field
-- 18 of 23 vessel records are missing at least one, nearly always Size_Y.
Matching each single-default record against the live object that reproduces its
remaining fields resolves 13 values (Destroyer 200, Cruiser 600/700, ASFrigate
80, ADAN Destroyer 300, Acropolis 400, ISCMissile 300, plus _Inv/_EX variants).
Every one equals that unit's Size_X, so the engine's own parsed definitions
confirm the statically-derived inheritance rule.
Limits stated: variants share one live object, and the ...Ratio/...Count family
is not resolved -- their offsets in the live object are still unknown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runtime track is unblocked. Two environment facts cost most of the effort:
audio must be --audio --apu=sdl WITH SDL_AUDIODRIVER=dummy (muting with --apu=nop
fills the log with CreateDriver failures and the guest never passes the movie),
and everything must happen inside ONE blocking foreground call because anything
outliving its call is reaped. Boot is 25s to the flight HUD once caches are warm,
which is what makes launch-and-dump fit in one call. numpy/PIL are absent so the
analysis scripts do not run; gmem.py is stdlib and does.
Result: 14 live definition objects dumped (vtable 0x820af844, 96 words each).
The +0x54 column reads as HP across three orders of magnitude and +0x30..0x38 as
Size_X/Y/Z, corroborating the earlier single-snapshot layout, and five of the
fourteen have Size_Y == Size_X exactly, supporting the inheritance default rule.
Open: no name/id field in the first 96 words, so objects are not yet matched to
their disc records.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
INDEX's XBG7 row still said 5480 decode (87.1%) and 125 of 681 shared resources
failing; it is now 6209 of 6294 (98.7%) with zero real meshes disagreeing across
containers and 46/46 capture-verified drawn buffers. The capital-ship row blamed
an XBG7 mis-decode for the user-reported assembly bug -- that mis-decode (a shared
turret ~100x too large) is fixed, so the BACKLOG entry is marked resolved at the
format layer, keeping the viewer-side pointers and the note that no metric caught
it: it took rendering the ship and looking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filtering after the assignment made every subset query a full decode (~15s on a
50MB container). full_decode_cached memoises it per container -- fingerprint is
length plus three sampled 4KB windows, keyed with min_consistency, last four kept.
Decoding five ships from Stage_S02 in turn: 10.5s for the first, then 48us-1.4ms.
A stage now costs one decode rather than one per ship. Ten suites green, viewer
builds, and a spot-checked resource still lands on the same offset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
models_named pruned to the wanted set BEFORE distinct assignment, so collision
resolution saw a different resource population and returned different offsets:
27 of 356 resources in Stage_S02 decoded differently when asked for alone,
including real geometry (f001_bdy_30, f106_sld_02_l/m/d, f101_wep_01_l). Both the
viewer and assemble_ship decode subsets, so both could disagree with the
container's own answer. This was a regression from distinct assignment itself.
Fixed by filtering the OUTPUT: the assignment always runs over the whole
container. One-name, three-name and full decodes now agree exactly. Cost: a
single-resource query on a 50MB container goes from near-instant to ~15s;
per-container caching is the follow-up. Ten suites green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XBG7_MONOTONE=1 enforces descriptor order per (stride, vtx, idx) signature: 89
minority decodes, unchanged. Monotonicity constrains order WITHIN a container
while the disagreement is BETWEEN containers, which hold different numbers of
these boxes in different arrangements. The remaining 89 are bounding-box identity
ambiguities needing descriptor-level information, not another anchoring
heuristic. Knob kept, default off, measurement recorded; suites green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking the vertex count of every minority decode: all 89 are 24-vertex
resources, including e101_wep_01_l, which last commit called the one real
geometry disagreement -- it is a 24-vert box too. So after this thread's fixes no
real mesh on the disc decodes differently in different containers; what remains
is structurally identical bounding boxes swapping identities, which needs an
ordering rule rather than a validity gate. Monotone anchoring was refuted before
but under a decoder with other defects, and is now cheap to judge: 89 -> 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
consensus_check names the minority container instead of just flagging
disagreement: 89 minority decodes across 477 resources with a majority, and 88
are scene composites. A composite's descriptor carries a 24-vertex bounding box
(e_rou_e106 -> 22x22x22, e_rou_f106 -> 745x718x718); those boxes look
interchangeable so the assignment shuffles per container. 1141 of 6209 decoded
resources have scene nodes. Counting only real geometry the disc has ONE
disagreement: e101_wep_01_l in Stage_S25.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whole-disc comparison at XBG7_COVER_SLACK=4 vs =1: 29 resources changed anchor,
3 stopped decoding, 6207 unchanged. Of the moved ones present in several
containers, 0 matched the sibling consensus before and 9 after -- it moved them
onto the block their own copies agree on and none away. 22 of 29 were carrying
ANOTHER resource's geometry under their own name, including a shift chain in
Stage_S28 where n054_bdy_l held n056_bdy_l's mesh and n056_bdy_l held
n055_bdy_l's. Confidently-wrong-under-the-right-name is invisible to every
count-based metric; the coverage invariant is what separated them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Last iteration reported e303_wep_01 as inflated 30-110x by assemble_ship. Wrong:
the composite nodes carry scale 1.0 and orthonormal matrices, and under the
current decoder the turret places as 49x23x42 at +/-179. The 1600x2100x4800 box
only appears with XBG7_COVER_SLACK=4 -- the exact-coverage fix had already
repaired the turret too. The error came from comparing a deliberately pre-fix
render against post-fix measurements.
The containment screen's blindness therefore has a mundane cause: one mis-decode
inflated the envelope and hid another. Kept as a forward-looking invariant with
that caveat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>