undecoded.rs supplies the denominator the coverage numbers never had; the disc
holds 6294 XBG7 resources, 6069 decode, 225 are searched and missed, 0 lack a
descriptor. gate_histogram.rs attributes each miss to the furthest gate its best
candidate reached: 120 connectivity, 74 grouped-pool (different path), 15
degenerate/extent, 9 winding, 7 buffer-not-covered. Recorded as a work-list, not
a verdict -- a wrong candidate can pass more gates than the true block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
debug_grouped_report (why_rejected) shows n206_02's alternative pool is ACCEPTED
at pad 0 under production gates and is in the candidate list -- and the decoder
does take it: n206_02 now anchors at 0x342d984. The 'still collapsed' reading
came from the audit comparing decoded geometry, and that offset holds a direct
(unmirrored) copy, so a separated pair still looked identical. The audit now
requires a SHARED BUFFER to call it a collapse: disc-wide 18 exact mirrors, 16
related, 0 collapses, 0 unrelated -- and the regression test drops its exception.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anchor_grouped_meshes takes the same taken set; a colliding grouped model is
re-placed whole past everything claimed, or keeps what it had. Runtime oracle
unchanged (45/46 exact, 0 unclaimed), coverage unchanged (6069), cross-container
inconsistency 62 -> 56, suite green.
It does not clear the n206 twin collapse: no alternative pool validates for the
loser, so that pair is a validator case (like eng_02_l before the cap move), not
a selection one. Also fixes a stale '0.28 (default)' label in edge_cap_sweep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
twin_mirror_audit applies the capture-derived rule to all 166 containers: of 34
equal-count twin pairs, 18 are exact X-mirrors, 15 related another way, 1
identical, 0 unrelated. Two calibration fixes were needed first (authored halves
need a tolerance, and a mirrored pair may be stored in another vertex order).
The one collapse, n206_01/_02, is a grouped-pool pair -- the path distinct
assignment excludes -- so it names the next target. Added a disc-gated
regression test; refreshed the stale ignore message on the consistency test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
locate_draw counts copies of a captured buffer. The f105 parts _rou_f105_break
claims exist 3-6 times over (the 2336-vert one: 6 direct AND 6 mirrored), and
every live LOD checked is anchored on a direct, byte-identical copy -- so the
composite taking the drawn copy costs nothing. This also calibrates the oracle:
'exact' (anchored at the drawn offset) is stricter than correct, so 45/46 is a
lower bound. Open: a resource landing on a MIRRORED copy would be a real defect
invisible to every count-based metric.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swept against the 46 capture-named Stage_S02 buffers with distinct assignment in
place: 0.28 anchors 40 exactly and leaves 4 unclaimed; 0.42 anchors 45 and leaves
none; nothing above 0.42 improves further, so it is the least permissive value
that captures the whole gain. 589 more resources decode, nothing is lost. The
cross-container consistency proxy worsens (46 -> 62) -- it cannot see a
systematic mis-anchor, and where the two disagree the capture wins. Suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selection was per-resource and greedy, so two resources could take one vertex
buffer while a valid one sat unused. A runtime capture proved that wrong for the
mirrored e106 hull twins: the container holds both halves and the engine draws
each from its own address. Now the first claimant keeps a buffer and later
resources re-anchor past everything already claimed (coverage can never regress;
grouped-pool models untouched).
Against the 46 capture-named Stage_S02 buffers: exact anchors 29 -> 40, unclaimed
12 -> 4. Disc-wide: 5480 resources decoded (unchanged), cross-container
inconsistency 125 -> 46.
The twins' mirror therefore lives in the DATA, not in the placement matrix: the
embedded e106_bdy_02 row and the two assertions encoding the old convention are
updated, each with the reason recorded. Full suite green incl. disc/ISO gates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Against the 46 capture-named Stage_S02 buffers: 0.28 gives 29 exact anchors,
0.42 gives 31 with 0 lost and 2 fewer unclaimed. The 18 cross-container
consistency regressions are a different phenomenon: the decisive one is the
32-vertex buffer at 0x24ce5f4, unclaimed at 0.28 and claimed by BOTH f303_body_l
and e302_barrel_l at 0.42 -- a collision from greedy per-resource selection, not
a mis-placement. So the cap and the selection must change together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
capture_truth_scan places every drawn buffer across all 166 containers by modal
vbase-offset. Stage_S02 wins with 64 matches at one constant against Stage_S01's
16, and the logs carry f101/f105/f106/e105 -- a Stage-02 cast. Stage_S01 looked
consistent because the shared block is duplicated verbatim (twins 0x116F0 apart
in both), so the earlier structural findings hold; only the loaded-container
claim was wrong. The S02 table places 46 buffers, 12 claimed by nobody, and
resolves the six Stage_S01 mystery buffers as e105 parts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XBG7_EDGE_CAP / XBG7_SMALL_TRIS make the threshold sweepable (defaults unchanged,
full suite green). Above 0.417 the capture-proven eng_02_l anchors exactly right
and no e106 part regresses, and nothing that decoded at 0.28 is lost -- but ~250
existing anchors move silently and 18 shared resources lose cross-container
consistency. Not changed: the movers have no oracle yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
debug_find_index_buffer scans the container for an index buffer that validates
against a capture-proven vertex buffer. For eng_02_l nothing validates with the
connectivity test on; with it off the nearest hit is exact pad-0 adjacency
(vb-ib = 144 = 72*2). The block's mean_edge/diag is 0.417 against a 0.28 cap --
the documented false positive for coarse LODs, now caught with ground truth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read off the emulator patch rather than inferred: CaptureShipDrawForRE dedups by
(vbase, WVP hash), so a buffer drawn repeatedly at one transform appears once.
The 3.4% counts multi-instance placement, not buffers serving several parts, and
is not the population evidence about sharing it was written up as. Also pins
vcount = buffer capacity and indices = that draw's num_indices.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
debug_try_anchor asks validate_block directly at the capture-proven offsets. Both
119-vert twin buffers are accepted by both twins (so the correct block lost the
first-match race, and a distinct assignment fixes it); the drawn 51-vert bridge
buffer is accepted by both bridge resources; eng_02_l's proven offset is rejected
outright, even with the pad widened to 64 -- a validator gap, not a selection one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
debug_vertex_run_starts exposes the anchor scan's candidate list: Stage_S01 has
15710 stride-24 starts and all three capture-proven offsets (0x3c55d8 twin,
0x40e418 bridge, 0x44a32c eng_02_l) are among them. anchor_pool_mesh takes the
first that validates, so an earlier lookalike wins. Scoped to the current
decoder's e106 cases; does not overturn the residual-51 finding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first read of the capture-truth table located our resources by searching the
container for their leading vertices, which reads much worse than reality: the
same leading run occurs at several offsets in one container. GameMesh now carries
the offset the anchor scan actually chose, so the comparison is exact -- 4 of the
ship's drawn buffers are anchored correctly, 2 are the twin collapse, and 2 are
real mis-anchors of a size we do decode (brg 51 verts, eng_02_l 44).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Locating each drawn buffer's positions inside Stage_S01.xpr shows vbase - offset
is one constant (0x1A94FFF4, same in two runs), so a capture gives ground truth
at file-offset granularity. Read against our anchor scan it is a defect list:
full/_m resources starting at their own _l buffer, eight drawn buffers claimed by
nobody, and the bdy_01_l/bdy_02_l twins sharing one buffer while the container
carries both halves (0x3b3ee8 and its exact X-mirror at 0x3c55d8).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The raw Canary capture logs from the 2026-07 sessions are still on disc at
/sylph-home/re/shipcap/xenia_ship_capture_*.log, and their DRAW lines carry
vbase -- the GPU vertex base, i.e. ground truth for which buffer the engine draws
a part from. That is precisely the oracle the shared-decode question needs, and
it needs no new capture run.
Restricted to the ship-geometry stride 24, across the three logs: 6093 draws from
2291 distinct vbases, and only 77 vbases (3.4%) drawn more than once. So one
buffer serving several parts is the exception in the engine, while our decoder
has 19% of resources sharing geometry.
The measures differ (draws-per-buffer vs resources-per-geometry) so this is
evidence not proof, but it points the same way as e106_bdy_02 == e106_bdy_03_m:
much of that 19% is anchoring error rather than asset reuse.
Per-part use is already available: correlate_capture matches a draw to a resource
by vcount plus decoded positions, and the same match yields each part's vbase --
so two resources our decoder gives identical geometry can be checked directly,
different vbase meaning our shared decode is wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups, both negative, both worth recording so the next attempt does not
repeat them.
The descriptor cannot replace the candidate scan. If entry.data_offset located
the geometry, anchoring would be direct addressing. Across e106's resources in
Stage_S01, anchored_vb - data_offset ranges from 1199052 to 4173988 with no
constant or stride; data_offset locates the DESCRIPTOR and none of the first six
descriptor words tracks the vertex pool.
And yesterday's "1241 of 1242 pairs identical in every container, so sharing is
legitimate reuse" is weaker than it read. A SYSTEMATIC error is invisible to a
consistency test, because it is consistent. The same dump shows e106_bdy_02 and
e106_bdy_03_m anchoring to the identical offset (1505556), and e106_bdy_01_l with
e106_brg_01_m (4251208) -- a hull half and a different body's medium LOD, a hull
half and a bridge LOD. Different parts; one of each pair is wrong.
Honest position: sharing is common (19%), some is certainly legitimate (a
mirrored twin pair genuinely shares one geometry) and some certainly is not, and
cross-container consistency cannot separate them. What can: comparing a shared
pair against a runtime capture.
Instrumentation reverted; only docs land here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I wrote that "two distinct resources sharing one decode is itself the bug" when
withdrawing the neighbourhood anchor. Measured, that is wrong.
1043 of 5480 decoded resources (19%) share geometry with another resource, and of
1242 related pairs that share a decode somewhere, 1241 are identical in EVERY
container they co-occur in. That is what legitimate asset reuse looks like, not a
defect. A mirrored pair like bdy_01/bdy_02 is SUPPOSED to share one geometry with
the reflection applied at placement -- which is precisely what apply_twin_mirrors
does.
So the twin-mirror regression was never about sharing. It is about WHICH of two
mirrored buffers is canonical: the disc holds an X+ and an X- version, the engine
treats one as the base, and the neighbourhood anchor moved these resources to the
nearer buffer, which is the other one. The fix is to pin the canonical buffer
with the capture as oracle, not to split the twins apart.
Exactly one pair is provably mis-anchored by this test: e105_bdy_02_l /
e105_brg_m, identical in 7 of the 15 containers holding both and different in the
rest -- two names cannot be the same geometry only sometimes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The neighbourhood anchor (f18d591) and its refinement (27a0701) took
cross-container inconsistency from 125 to 51 with coverage unchanged, and made
e106 render as a destroyer rather than a slab. Both are reverted.
ship::tests::static_assembly_matches_runtime_capture is gated on SYLPHEED_ISO, so
it SKIPS in an ordinary cargo test -- which is why the regression was invisible
in every suite run so far. With the ISO it fails:
e106_bdy_01: static M row0 [-1.0, 0.0, 0.0] != captured [1.0, 0.0, 0.0]
e106_bdy_01 and _02 are a mirrored pair whose two buffers hold the same geometry
reflected in X, and BOTH resources currently decode to the SAME buffer (identical
counts, span and mean_x). apply_twin_mirrors picks which instance to reflect from
the sign of that mean_x, so which buffer wins flips the decision:
before both twins mean_x = -66.83 -> mirror bdy_02 (matches the capture)
after both twins mean_x = +66.83 -> mirror bdy_01 (contradicts it)
Neither is right -- two resources sharing one decode is itself the bug and the
mirror heuristic has been compensating. The capture is ground truth, so a change
that contradicts it does not ship. The real fix must give each twin its own
buffer first.
Kept from the attempt: this test now also asserts the SET of static placements
against the capture (allow-list {e303_wep_01} for vbase dedup), so extra
placements can finally fail it -- the direction it could never fail in before.
Docs, backlog, INDEX and the ignored test's message all corrected to say
diagnosed-not-fixed rather than fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implemented the proposed tiebreak as a last resort (take the attractor only if
nothing else validates), keyed first on "all coordinates multiples of 50" and
then on the sharper "all coordinates integral" -- the attractor reads
(42, -900, 2400), (-600, -500, -950) while real geometry carries fractions like
(24.55, 9.84, 4.46). Neither changed anything: still 51 inconsistent, 5480
decoded.
The null result is the finding. A mechanism that defers the attractor whenever
another candidate exists, and changes nothing, means no alternative candidate
validates for any of the 51: the correct block is not in the candidate list at
all. So the residual is not a selection problem and no reordering, scoring or
tiebreak will move it.
The frontier is vertex_run_starts -- the unit-normal run scan that builds the
candidate list -- which emits no start for these resources' real vertex buffers.
Both attempts reverted; only docs land here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tested the tempting explanation for the last 51 -- that _l/_dead variants share
their base's vertex and index counts, making them mutually confusable and
adjacent so locality cannot help. It is FALSE: 2714 variant/base pairs across
every container, and exactly zero share identical counts.
What is actually happening is better news. e010_bdy_01_l is 171 verts / 90 tris
and NO other resource in its container shares those counts, yet in Stage_S02 and
S26 it decodes to 1600x2100x4800 -- the same bounds e303_wep_01 (172/110)
produced before the fix. Differently-shaped resources land on the same place, so
the attractor is a region of round axis-aligned box data that validates for many
(vtx, idx) shapes at once, not "another mesh with my shape".
That also explains why the neighbourhood fix helped so broadly: it steers
resources away from one strong attractor rather than resolving many pairwise
confusions.
Candidate next step recorded with its risk: de-prefer candidates whose sampled
positions are all multiples of 50 with a large span. It must be a TIEBREAK
between validating candidates rather than a rejection -- an earlier sweep for
that signature also flagged legitimate e_rou_* composite proxies -- and it needs
the same gate (decoded must not fall from 5480).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pass 1's anchor map contains exactly the mistakes the neighbourhood is meant to
correct, so a resource sitting beside a mis-anchored neighbour inherits a bad
reference. Re-anchoring against the improving map and repeating converges
quickly: two rounds, and a third changes nothing (the loop exits early when a
round is a fixpoint).
before decoded 5480/6294 inconsistent 125
neighbourhood anchor decoded 5480/6294 inconsistent 63
+ refining the map decoded 5480/6294 inconsistent 51
Coverage still unchanged. The 51 that remain cluster in _l (LOD) and _dead
variants -- e001_l, e010_bdy_01_l, e106_eng_02_l, _rou_f302_base_dead,
e303_base_dead and friends. A plausible reading is that a variant shares its
base's vertex and index counts, making the two mutually confusable so that
locality cannot separate them; recorded as untested rather than asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anchor_pool_mesh took the FIRST candidate in file order from a container-global
vertex-run scan, so a resource could be handed another resource's block whenever
both shared (stride, vertex count, index count). Both blocks are real geometry and
both pass every quality gate, so only position separates them.
anchor_pool_mesh_near now tries candidates in order of distance from a reference,
and anchor_models_filtered runs two passes: pass 1 anchors first-match to learn
where resources land, pass 2 re-anchors each resource preferring the median anchor
of its +/-2 descriptor neighbours. Too few anchored neighbours -> keep pass 1, so
nothing regresses to guesswork.
before decoded 5480/6294 shared 681 inconsistent 125
after decoded 5480/6294 shared 681 inconsistent 63
Coverage unchanged, inconsistency halved. e303_wep_01 decodes to 49x23x42 in ALL
containers now, and e106 renders as a destroyer instead of a slab -- its two
shared turrets symmetric at X[-203,-154] and X[154,203]. That resolves the
user-reported "capital ships assemble wrong" for this cause.
The filtered path needed care: models_named (what the viewer uses) dropped
non-wanted resources, which would have left filtered decodes with no
neighbourhood and silently kept the old behaviour. Resources are now collected
regardless of the filter, but only the asked-for ones and their +/-2 neighbours
are decoded in pass 1, so a filtered decode stays proportional to what was asked.
63 cases remain; mesh_consistency_disc stays ignored and now records 63, not 125.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enumerating every validating candidate for e303_wep_01 in Stage_S02 gives exactly
two:
vb = 18403456 span 1600 x 2100 x 4800 <- taken, only because it is first
vb = 52257440 span 49 x 23 x 42 <- correct, the size all 11 good
containers give
So the scan is not missing anything; the wrong block merely appears earlier in
file order.
Monotonic anchoring is REFUTED as the constraint: recording each resource's
accepted anchor in descriptor order gives 25/47 increasing steps in Stage_S01 and
130/248 in Stage_S02, no better than chance. Locality holds instead -- in
Stage_S02 the descriptor neighbours anchor at 51974668 and 52218424, the correct
candidate is 52257440, and the wrongly-taken block sits at 18403456, two thirds
of the file from its own family.
Proposed rule, needing no new format knowledge: among validating candidates
prefer the one nearest the neighbouring resources' anchors, falling back to
first-match when there is no neighbour yet. It selects 52257440 here.
Not implemented: it moves the anchor for all 6294 resources, so it needs the
before/after measurement (decoded must not fall from 5480, inconsistent should
fall from 125) and then un-ignoring mesh_consistency_disc.
Instrumentation reverted; only docs land here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anchor_pool_mesh walks candidates from vertex_run_starts(bytes, data_base,
stride): ONE scan of the whole container per stride, shared by every resource of
that stride, and it accepts the first candidate that validates. Nothing ties the
chosen block to the resource it belongs to, so two resources sharing (stride,
vertex count, index count) are interchangeable to this search.
The wrong block is not distinguishable by quality. Tracing the accept for
e303_wep_01:
Stage_S01 vb=4600480 span 49 x 23 x 42 passes 0.85 = true
Stage_S02 vb=18403456 span 1600 x 2100 x 4800 passes 0.85 = true
Both clear the strict winding gate, because the wrongly-taken block IS real
coherent geometry -- another resource's. That rules out a family of fixes: no
threshold, scoring, or best-of-N selection can help, which is exactly why the
previous iteration's attempt changed nothing.
The search space must be constrained instead. Two candidates recorded, ordered by
how much new format knowledge they need: per-resource data extents from the XPR2
resource table, or monotonic anchoring (resource k's vb after resource k-1's) if
data blocks follow descriptor order -- testable against the 556 resources that
already decode consistently.
Instrumentation reverted; only docs land here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tried the obvious fix: make anchor_grouped_meshes pick the best-scoring vb0
instead of the first candidate clearing the 0.85 gate. It changed nothing --
still 5480 of 6294 decoded, still 125 inconsistent -- and instrumenting the pivot
loop shows why: for e303_wep_01 that function never runs. The resource has a
single sub-mesh, so it goes through the per-block adjacency path
(anchor_pool_mesh) instead.
Recorded because the grouped-pool anchor is the prominent, well-documented one
and the natural first suspect; the fault is in the other path.
The change is reverted -- untargeted, unproven, and it added a scoring path for
no measured benefit. The idea behind it (several vb0 candidates can clear the
gate, and first-in-scan-order is an arbitrary tiebreak) still applies to whichever
anchor is actually at fault.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Following the e106 slab to its cause. e303_wep_01 decodes from 14 containers: in
11 it is a 49x23x42 turret with organic vertices, in Stage_S02/S08/S26 the same
resource -- identical 172 verts and 330 indices -- decodes to 1600x2100x4800 of
axis-aligned box corners with (0,0,1) normals. The anchor scan found a different
buffer that happens to share both counts, so every size-based check passes. That
is the "declined for location, not format" risk succeeding wrongly instead of
declining.
The detector needs no ground truth: a resource shared by several containers must
decode to the same bounds. 681 resources appear in >=2 containers and 125 decode
to different bounds while reporting identical vertex/triangle counts -- a lower
bound, since a resource wrong everywhere is invisible to it. _rou_f401 is
62x25x10 in 16 containers and 4738x3147x4738 in 2; _rou_e011_wep05 gives four
different spans across 8.
Majority vote would resolve 104 of the 125 (14 are exact 50/50 splits) and agrees
with ground truth in the one case that has a render and a runtime capture behind
it. Recorded as a heuristic, not applied: for _rou_e302_base_break the majority
is the LARGER span and nothing yet says which is right. Flag, do not silently
rewrite geometry on a vote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"A few multi-stream / quantized bodies remain" declined understates it. Across
all 166 resource3d containers: 6294 XBG7 resources, 5480 decoded (87.1%), 814
declined, in 31 of 166 containers. By name the declined set is 492 e* (enemy
craft), 142 f*, 73 n*, 23 eff*, plus *_dead destroyed variants -- not hero
bodies.
Also recorded as a dead end, so nobody repeats it: the descriptor's third word
looks like (streams << 16) | format -- decoded g001..g003 carry 0x00010001 while
declined t170/t180 carry 0x00020004 -- but histogramming it disc-wide puts
decoded AND declined at every value (0x00010001 alone: 4479 decoded, 328
declined). Its low half tracks sub-mesh count. Decodability is not declared in
the descriptor; it is whether the unit-normal anchor scan can locate vb0, which
the code already tests. 229 declined resources carry the most common word[2] with
under 1 KB of data, i.e. small meshes with too little signal to anchor rather
than exotic formats.
No decode gained this iteration; the gap is now quantified and one attractive
wrong turn is closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
INDEX read as if the parser stopped at one level. Measurement says there is
nothing deeper: 2859 top-level bundles hold 18002 children at depth 1 and ZERO at
depth 2, with no blob failing to parse.
The children that are themselves RATC -- the .rat layout records -- are leaves.
They carry no child list and reference their siblings BY NAME (the sprite they
place, and via "opt " their focused variant): 3311 such leaves, every one
embedding sibling names, and 10144 of 10148 references resolve within the same
bundle. That is the same by-name convention used one level up (a screen config
naming .prt components) and one level up again (the movie table naming
<lang>.pak+<member>).
The 4 unresolved references are a disc defect, not a parse gap: pmbase.rat in
GP_STAGE_CLEAR.pak's four language builds places pmbase.t32, and that sprite
exists nowhere -- not a pak member, not a child of those bundles. Second such
dangling asset after SUBTITLE_S12B.tbl, so a reimplementation should skip a
missing sprite rather than call it a decode failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Booted to the ARSENAL and detected the eight teal category chips by colour, then
compared their row centres against the eight prbtn1..8.rat placements parsed from
eng\prmain_scr.prt. All eight fit
screen_Y = placement_Y + pivotY(14) + chrome(45)
with residuals -1,-1,-1,-2,0,-2,-1,-2 px. The spacings are the real signature:
predicted 54,56,55,54,56,55,55 against observed 54,56,54,56,54,56,54 -- an
irregular alternating pattern, not a round number that could match by luck. The
one free parameter is the 45 px emulator window chrome, which is not part of the
game, and the residual is centroid measurement noise.
Confirms three things together: the declaration table is the element set, the
placement region gives real screen coordinates, and the pivot composes additively
(pivotY 14 = half the 28 px chip, so placement is top-left as documented).
Explicitly NOT confirmed: the max-dwell rule for animated elements. These buttons
are static -- every keyframe identical -- which is exactly what makes them a clean
ruler. That rule needs an element captured mid-slide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dumping the remaining Arsenal components turned up placements reading as
4294966780 and 4294966856 -- negative as i32 (-516, -440). X and Y are signed;
off-screen animation starts are negative, and read as unsigned a reimplementation
would draw them four billion pixels off.
The raw region then showed the rest of it. The trailing word is a TIME, and a
group is an in -> hold -> out animation: prselect_win1 runs
t=4:-516 -> 6:-71 -> 7:81 -> 8:127 -> 23:134 -> 24:134 -> 25:127 -> 27:81 ->
31:-71 -> 1:-516. So NEITHER the first nor the last keyframe is where the element
sits -- both are off-screen. The resting position is the max-dwell keyframe,
(127,155)..(134,155) here, which is where the weapon-list panel actually appears.
This corrects my own note from earlier today, which reported "final=" positions.
Static elements were unaffected (the pause menu's buttons have identical
keyframes) but animated ones were misread. screen_layout.rs now reports the
max-dwell pose plus the full timeline, and both captures are regenerated.
Also seen while dumping: kind 0x2, and a .sbo child (scrollbar) parented to the
list window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
examples/screen_layout.rs dumps a bundle's declaration table and placement region
together. It reproduces the tutorial pause menu exactly and reads the ARSENAL the
same way -- 23 elements that match the running game: eight buttons prbtn1..8.rat
at X=242 evenly spaced (the config declares WEAPON_CATEGORIES = 8, and eight
categories are what the Arsenal shows), prexp3.t32 declared SEVEN times at X=726
34px apart (the DATA SHEET rows), prexp1 sliding (726,143)->(1286,143) with
prexp1a parented to it, and prmsg at (151,645).
Two additions to the format:
kind = 0x4 marks a REPEATED INSTANCE of a sprite -- prexp3.t32 appears once
with 0x0 then six times with 0x4, each with its own placement. So the element
name is not a key; the declaration index is.
A screen composes from named .prt components. GP_HANGAR_ARSENAL.pak has 510
RATC entries because its config names components (Menu = prmain_scr.prt, etc.)
and they resolve under the config's own PATH prefix: prmain_scr.prt is absent,
eng\prmain_scr.prt is present -- the same <lang>+<member> convention the movie
table uses. A sub-component reads identically: psselect_win1 declares 4
elements, three parented to element 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Immediately after the declaration table the RATC bundle holds a placement region,
one group per element: a header of (u32 element index, u32 keyframe count)
followed by that many 40-byte keyframes carrying scale/tint/X/Y. The element
index is stated in the header, so the correspondence is read, not inferred from
order.
Tutorial pause bundle: 11 groups for 11 elements, every header index and count
matching the blocks present. The values are self-evidently right -- three menu
buttons at X=546 exactly 70 px apart (288/358/428), title (540,119), message
(451,545), and the eff* sprites carrying multi-position fly-ins.
Cross-checked: pgp_ttrl_btn10.rat places its own sprite at (546,288), identical
to its inline group, so the inline region is the same placement data and it
covers the eff*/deli*/msg elements that have no record of their own.
So a screen is fully reconstructible from its bundle alone: element list and
order from the declaration table, placement and animation from this region,
plus the sprites, with "opt " supplying focused states. This retires the caveat
added earlier today that only the order was known.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc's open gap was "where does the screen's draw list live" -- the eff/deli/msg
sprites have no .rat and loop1.rat turned out to be an animation. It is the
bundle's own declaration table, which is NOT the child listing: children are
grouped by type (all .t32, then all .rat) while the declaration table names
ELEMENTS in a plausible back-to-front order and contains exactly the missing set.
For the in-mission pause menu it lists 23 elements ending with loop1.rat, and it
omits the focused button variants -- those are reached via each base record's
"opt " link -- so it is the screen's element set, not an inventory.
Also corrects the entry layout: it is FIVE words after the name, not "u32 x4
flags", and word +32 is a PARENT ELEMENT INDEX (0xffffffff = none). pgpeff02a
carries 3 and element 3 is pgpeff02; pgpeff03a carries 5 and element 5 is
pgpeff03. Verified on two independent language builds, no out-of-range value in
any bundle, and the tutorial bundle (no "a" variants) is 0xffffffff throughout.
Bounded honestly: what is drawn and in what order is now answered; WHERE the
eff/deli/msg sprites land is not -- the declaration entry has a pivot but no X/Y.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build_ship_model resolves each placement with find(|m| m.name == p.resource) --
first match wins -- so a repeated resource name inside a container would draw
the wrong geometry. It cannot happen: decoding every XBG7 resource in all 22
stage containers gives 4603 resources and ZERO repeated names. Per ship it is
tighter still: e106 wants 9 distinct names and decodes exactly 9 models for 11
placements, e105 9 for 9, f105 5 for 6.
Two of the three remaining candidates for the user-reported viewer bug are now
gone (this and include_external), leaving mirror handling and the exhaust cones,
plus the untried visual comparison.
The container sweep also reconciles a number I flagged earlier: the disc has 22
stage CONTAINERS -- Stage_S01..S16 plus Stage_S24..S29 -- i.e. 16 story stages
plus 6 extra. The save screen's 16 STAGE sprites and the notes' "22 stages" are
both right and count different things; neither gets edited to match the other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two results, both from a safe probe.
Slot 03 was given SHAB[0] = (a=1, b=12345, c=60000) -- a one-minute best time --
and MISSION SELECT still showed 4612 P EASY / 05:24.77 EASY, i.e. slot 01's
numbers. So the title-menu screens read the AUTO-SAVE, and any probe written to
a throwaway slot is invisible there. The previous SHAB identification is
unaffected: it matched slot 01's own bytes, which is what the screen renders.
The consequence is a decision, not a task: mapping the difficulty enum (a) or
testing b needs slot 01 itself edited -- the only save with real progress. Left
undone deliberately and flagged in the doc; a full content backup exists and
slot 01's bytes are archived, so it is recoverable, but it is the user's call.
Also: High Score 4612 is stored NOWHERE. It is absent from both .gpd profile
files, all three saves (compressed and inflated) and xconfig.settings, while the
best time 324773 is present in slot 01 alone. The score is computed at display
time, which is why SHAB[0].b = 4101 does not equal it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EXTRAS -> MISSION SELECT renders per-stage records and they match the table.
Stage 01 shows "High Score 4612 P EASY / Best Time 05:24.77 EASY" while SHAB[0]
is (2, 4101, 324773, FILETIME): 324773 ms IS 5m24.773s, the displayed best time.
Sixteen records, sixteen story stages -> the table is indexed by stage, and
record 0 is Stage 01, the only stage this save has cleared.
a = 2 difficulty of the record (both entries tagged EASY)
b = 4101 NOT the displayed high score 4612; equals GHAD +24/+28
c = 324773 best clear time in ms -- confirmed against the screen
d,e FILETIME
This also explains the probe that filled SHAB[1] and changed nothing: the table
holds stage RECORDS, not progress, and Stage 02 has never been cleared so its
record is legitimately empty. Promotes the earlier "per-stage result record"
guess to confirmed for the layout.
Route: title -> EXTRAS -> MISSION SELECT. Only cleared stages are selectable (16
d-pad presses never left Stage 01) and the screen has its own difficulty
selector (Y).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Disassembling around the serializer names its mirror and the whole read side:
deserializer 0x822C0380 (reads GDAA + phase string), GHAD reader 0x822BF858
(called with save+8, pairs with the writer 0x822BF678), SHAB reader 0x822BFD60
(save+136, count 16).
The GHAD reader is a plain unrolled byte copy with no clamping or validation
anywhere, which is why hand-written field values survive into the object exactly
as written.
The deserializer's only caller builds its argument as
lwz r11,4(r30) / lwz r11,0(r11) / addi r4,r11,304, so the save state lives at
+304 of a game object and every field now has a fixed absolute offset there --
Points at +336, clear ratio at +320, the develop blob at +380, the SHAB table at
+440. That is what a live-RAM read of the save state needs.
Recorded as a negative too: searching those absolute offsets does NOT find the
panel's populate code. lwz rX,336(rY) has 193 matches, and the windows holding
several right offsets at once are a vtable thunk table (slots 308-340) and a
float-parameter block. The stage/difficulty fields need the screen-side path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Static, no emulator. The LOAD/SAVE screen's config keys sit in .rdata as a
pointer array at 0x820a0074, identified as config keys (not loose strings) by
the Arsenal's own key list sitting a few hundred bytes earlier and matching its
pak record exactly.
- GAME_VERY_HARD exists alongside EASY/NORMAL/HARD, while the stage tables
only carry BonusPoint_EASY/NORMAL/HARD -- a fourth, unpriced tier.
- Game Status is a 3-valued enum: STATE_STAND_BY (our save's "At Standby"),
STATE_STAGE_CLEAR, STATE_GAME_CLEAR.
- Exactly 16 STAGE sprite keys, matching weapon.tbl's stage01..16 (plus 6
tutorials and 6 challenges). So 16 is the story-stage count and the "22
stages" figure used elsewhere counts a different set -- flagged, not
silently changed.
Also recovered: the panel's field names and positions (Points, FlightTime
%03d:%02d:%02d, ClearTimes, CompletionRate, RatioOfClear, Index, Label, Date,
BrokenData).
It does NOT name the stage/difficulty save fields, and it explains why the probe
sweep could not: the screen picks a sprite key BY INDEX through a config lookup,
so there is no lis/ori immediate to xref back to the selecting code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extends the previous refutation to a sweep. Probed with no effect on STAGE 02 or
Difficulty EASY: every scalar in the GHAD block (+0, +12, +16, +20, +28, +32,
+36 at 1/3/9, +40 u64, +48, +52, +56, +60, +64 raw), SHAB[0].a, and the SHAB
FILL COUNT in both directions -- record 1 filled with a copy of record 0, and
record 0 cleared. The "stage = filled-record count + 1" idea dies with it, and
so does the reading that made SHAB a per-stage result table by that route.
The panel does re-read each slot: slot 02 holds Points 4101 / Clear Ratio 5 %
and displays exactly that while its neighbours show 101 / 6 %.
Left: the phase string, the trailer, or the blob. Recorded caveat -- every save
on disc is genuinely Stage 02 EASY, so "field not found" and "panel does not vary
those two labels per slot" are not yet separated, and another probe round cannot
separate them. The next move is static: find the code that formats STAGE %02d
and read which offset it loads.
savegame_edit.py --set now packs an int into raw_* byte fields.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Those three fields all hold 2, and "difficulty or stage, undecidable from one
save" has been the reading since the format was parsed. Writing saves makes it
decidable and the answer is neither.
boot_menu.sh boots to the title menu WITHOUT loading anything, and LOAD GAME's
slot list renders each slot's Details panel from that slot's payload. Extra
slots can be fabricated (copy the directory plus a gameNN.header with its
UTF-16BE display string and ASCII name patched), so four probes fit in one boot,
read-only.
Probed: +36 at 1/3/9, +52 and +56 at 1/9, and +0, +16, +32, +48, +28,
SHAB[0].a. Every one left the panel at STAGE 02 / EASY / At Standby /
Times Cleared 0.
The negative is meaningful because the panel does read each payload -- slot 02
shows 5% clear ratio against the others' 6%, and Points tracked +24 exactly.
Two controls: patching a slot header to "STAGE09 HARD" changed nothing (the
display is payload-driven, not header text), and the row date follows the
container FILETIME.
Remaining candidates: +12, +20, +40 (u64), +60, +64, or the phase string.
Also here: savegame_edit.py --slot for SHAB records, and boot_menu.sh itself --
nav_probe.sh's boot loads a save, which with probe slots on disc loads a probe,
and a dropped d-pad step there put A on TAKE OFF and spent a boot loading a
mission.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three hand-written probe saves settled what a mission payout could not.
all 54 owned -> every tab fills and the lists WRAP, giving exact counts
(GUN 8, BEAM 8, LASER 4, MPM 5, ASM 5, B/R 7, CANNON 6,
SPECIAL 6 = 43 displayed) and confirming the display order is
weapon.tbl's order item-for-item. It also killed the idea
that gate-unmet owned flags are suppressed: Maelstrom Bomb
(Stage 6) and Grav Cannon XGS (Stage 12) both rendered at a
Stage-2 save.
only 9 + 39 -> STILETTO BG1 Developed (control) AND TOMAHAWK ALPHA RAIL GUN
Developed. The CANNON block starts at 39, not 38.
only 39 + 48 -> JAMMING SYSTEM Developed. SPECIAL starts at 48, and with six
SPECIAL items the array closes exactly at 53, nothing spare.
So the blob is indexed by strings.tbl's item order -- the display order PLUS the
items only the localisation file carries (Adhesive Mine B2A, and three of
Ballista GSH / Designator LH / Smoke Marker Launcher / Bumble UV). They never
appear in the Arsenal but they hold slots, which is the entire reason the tail
looked broken. It also explains the retail save's five "unexplained" owned
entries at 33 and 45/46/47: cut items, shipped flagged owned, never rendered.
That weapon.tbl's No_Equipment..Wep_83 run is ALSO 54 long is a coincidence, and
it cost real time -- the two orders agree only to index 32. Recorded as such.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The container's derived fields turned out to be reproducible -- length+10 at
+0x30, payload length at +0x8c, adler32(payload) at +0x8e, everything else
copied -- and savegame_edit.py re-wraps a real save BYTE-IDENTICALLY, which is
the check that those three are the only ones. A hand-written save then loaded.
That replaced a blocked experiment (the tail question needed a mission payout,
and none of the currently developable items even sit in the disputed range) with
a direct one: write the blob, read the Arsenal.
- controls: 4 at index 9 -> STILETTO BG1 Developed, 21 -> FALCON 9AM
Developed. A hand-written 4 reaches the screen.
- tail: 4 at 33 and 45 left their rows dashed (both on screen, not below the
fold), and 38 left TOMAHAWK ALPHA RAIL GUN at "0 P" -- not owned. So the
tail is not the weapon.tbl order continued.
- clearing the real save's {22,26,39,46,47} cost the Tomahawk its Developed
status, which puts its flag in that set (39 positionally) -- but a uniform
+1 fails for SPECIAL, so no shift is asserted. Indices >=32 stay marked.
Two behaviours fell out. The title RE-DERIVES developable state on load and
announces it ("You can now develop Broad Sword ..."), so only the 4s are stored
state and a written 2 is pointless. And a no-cost item is bought for 0 P rather
than granted -- TOMAHAWK at "0 P" is what unowned looks like -- which is the
actual reason items read Developed in a save where nothing was spent.
Slot 03 was restored from its archived original (md5 verified); slots 01/02 were
never touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GP_HANGAR_ARSENAL.pak's screen config points at weapon.tbl (item ids, in the
8-category display order) and strings.tbl (names, descriptions, and a
"Conditions to obtain" block per item). The id run No_Equipment .. Wep_83 is
exactly 54 long -- the save blob's length -- and all 60 conditions blocks are
extracted to a CSV: gates are stage completion, a predecessor item, or an ace
kill; costs run 3000-350000 P, and 20 items cost nothing once gated (which is
why items the player never bought read as Developed).
Predicting the save state from those conditions -- before looking at the blob --
says exactly six items are developable here, and the blob's six 2s sit on those
six, in weapon.tbl order, at indices 1/5/10/12/27/31. With the four obtained
items and the differential's own two transitions that is twelve concordances
over indices 0-31, nothing fitted. Broad Sword SG1 at index 5 needed scrolling
the GUN list to see, which is the only one the first screenshots missed.
The tail is NOT settled and is marked so: the Tomahawk is weapon.tbl index 38
and the screen shows it Developed, but blob[38] = 0, and the other tail 4s
(33/39/45/46/47) land on items the Arsenal shows as locked -- the SPECIAL tab is
entirely empty. A +1 shift does not repair it either. Settling it needs a second
development in a late category, which needs a mission payout.
Also recorded: IDXD string pools dedupe repeated values, so only the first
record of a table can be read from the token stream -- record 2 shows just its
unique values, record 6 no cost at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third save, taken in the running game after developing exactly one item
(Light Machine Gun MG I, 4000 P) and declining the mount prompt, moves exactly
three things against the same-state save:
+24 Points 4101 -> 101 and +28 does NOT move, which separates the
pair one save could not tell apart; the
Details panel then reads Points 101 P, so
+24 is the spendable balance
+8 clear ratio 5 -> 6 so the ratio counts collection, not stages
+68 blob[1],[2] 2->4, 0->2 the item bought, and the successor the game
announced as newly developable
One action, two blob transitions, two on-screen events in the same order — which
is what makes 0 locked / 2 developable / 4 developed a reading rather than a
guess, and rules out a plain owned-bitmask (it could not hold the middle state).
Which item each of the 54 indices is stays open: the arsenal id lists in
GP_HANGAR_ARSENAL.pak union to 35, and MG I sits at index 1, not 0.
Also recorded: inside a modal yes/no dialog a 60 ms d-pad tap is ignored (the
list menus accept it), and the develop confirm starts on NO while the mount
prompt right after it starts on YES.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Made a second save in-game (READY ROOM -> SYSTEM -> SAVE GAME -> empty slot) so
the format had a differential to read against. nav_probe.sh drives it: boot to
the READY ROOM, walk a scripted step list, screenshot after every step and stamp
every save file's md5, so the trail says which keypress wrote a save. That stamp
is what caught the first attempt failing -- the save confirm starts on YES,
unlike the load confirm which starts on NO, so the load flow's extra up-press
selected NO and wrote nothing.
Result 1: saving the same loaded state into a new slot produces a BYTE-IDENTICAL
545-byte payload. Only the GDHA header moves, and every byte that moves is either
the container FILETIME or one of the guest-pointer words -- which empirically
confirms those words are uninitialised padding rather than data. So the payload
holds no timestamp, no slot number and no name; a save's identity is entirely in
its content header.
Result 2: the LOAD/SAVE screen's Details panel prints Points 4101 P, Flight Time
000:05:24 and Clear Ratio 5 % for exactly this state, which names GHAD +24
(Points), +4 (flight time in ms -- 324773 ms = 5m24.773s) and +8 (clear ratio %).
Difficulty EASY and STAGE 02 both being 2, and three fields holding 2, is left
undecided on purpose: one save cannot separate them.
Result 3: the 16 SHAB records are not the UI's save slots. The UI has 20, slots
are separate gameNN files, and record 0's FILETIME stayed at 2026-07-23 in a save
written on 2026-08-11 -- so the table is part of the game state, most likely a
per-stage result record (one stage finished, one record filled), which the next
cleared stage will falsify or confirm.
The original save was backed up first and is untouched; the new save went to an
empty slot.
The whole retail save is 545 bytes: a GDHA container wrapping a zlib payload,
which is a chunk stream — 'GDAA', a length-prefixed game-phase name (GP_BUNK,
one of the title's GP_* screen ids), a 'GHAD' 122-byte progress block, a
count-prefixed table of 16 20-byte save slots, and a trailer.
None of it is guessed. The layout comes from the serializer at 0x822C00E8 and
its callee 0x822BF678: the writer primitive is 0x821885A8(stream, buf, len), the
slot count 16 and the 20-byte stride are literals, and the GHAD block is ten u32,
a u64, four u32, a raw 4-byte field and a raw 54-byte blob = 122 bytes, which is
exactly what the file carries. savegame.py re-serializes the parse and asserts
byte-identity.
Because the struct is written field-by-field with no packing changes, a payload
offset is also the offset in the live save object -- save+8 for GHAD, save+136
for the slot table, and 136+16*20 = 456 is the serializer's very next access.
Confirmed independently: slot 0's trailing two u32 decode as a FILETIME to
2026-07-23 20:07:23 UTC, and the content header's display string (written by the
game) says 07/23/2026 21:08 at UTC+1; the 15 empty slots hold 2006-01-01 rather
than zero.
Field meanings are left ❔ on purpose -- the naming oracle is a diff of two
saves, and all four copies on disk are byte-identical, so a second save has to
be made first. Nothing has been written back to any save.
Four rules from 21 units invites the coincidence objection, so run the identical
sweep against the Weapon/Shell capture, which has COMPLETE coverage (126
records). It finds no sibling rule at all: the one 100%-agreement candidate has a
single distinct value and is really a constant default. Weapon defaults vary per
record exactly as unit defaults do, so "defaults are computed" is general while
"defaults come from a sibling field" is not.
Size_Y <- Size_X survives, and is now checked at the raw-token level rather than
through the sub-record merge: e105, f105 and f101 each declare Size_X/Size_Z/
Size_Radius and no Size_Y, and each reads back its own Size_X at runtime. The
two two-unit hypotheses are demoted to coincidence-not-excluded.
Also records a negative for planning: Stage 01, the only other reachable stage,
adds four uncaptured units that are variants of already-captured ones, so it
would re-measure rather than test the rules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>