101 Commits

Author SHA1 Message Date
1b618f500f re: cross-validate the code-derived layout against the earlier RAM-binding solver
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>
2026-08-13 01:11:43 +00:00
7cd9fb8e0b feat(formats): land the verified unit-definition layout as data + API + test
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>
2026-08-13 00:44:26 +00:00
19b338d9d8 re(route-b): map generalises to units -- 406/0 across 11 objects, 121 defaulted fields
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>
2026-08-13 00:12:45 +00:00
d674cddad4 re(route-b): full field map verified 154/154, and 101 defaulted fields read out
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>
2026-08-12 23:44:57 +00:00
0434afa6a6 re(route-b): the field map comes out of the code, and with it the Ratio family
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>
2026-08-12 23:17:05 +00:00
2ed475868b re: Size_Y is genuinely defaulted, and the loader leaves it at 0.0
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>
2026-08-12 22:47:43 +00:00
be42e998bc re: the unit filler is sub_82341A20, and its 1.0 constant is not a default
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>
2026-08-12 22:18:56 +00:00
71adafe7f7 re: the definition constructor zero-fills -- defaults are derived after parsing
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>
2026-08-12 21:49:48 +00:00
a3868fab5b re: the static route locates the reflective parser and reframes the defaults
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>
2026-08-12 21:21:21 +00:00
bab2f9e6ca re: three probes for the parsed record all miss -- hypothesis: there is not one
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>
2026-08-12 21:00:48 +00:00
f4f3bcb20a re: a deeper window does not help -- the 0x820af844 object is not the definition record
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>
2026-08-12 20:49:19 +00:00
892f1e3979 re: partial field map inside the live definition object, and why the automation failed
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>
2026-08-12 20:36:57 +00:00
3322202732 re(route-b): 13 defaulted Size_Y values read out of the running game
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>
2026-08-12 20:28:55 +00:00
a836f8e9ea re: live unit definitions from a running Stage 02, and the recipe that gets there
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>
2026-08-12 20:18:32 +00:00
4d8c1b9457 docs: bring INDEX and BACKLOG up to the measured state
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>
2026-08-12 19:02:54 +00:00
33a7bf836b perf(mesh): memoise the whole-container decode
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>
2026-08-12 18:57:50 +00:00
c79c0f6ccf fix(mesh): filtered decode must not depend on the requested subset
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>
2026-08-12 18:47:41 +00:00
e8434541f2 re: monotone assignment refuted again -- box identity needs descriptor data
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>
2026-08-12 18:30:58 +00:00
1d8b783d26 re: all 89 remaining disagreements are 24-vertex boxes -- no real mesh disagrees
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>
2026-08-12 18:11:20 +00:00
cdaeac8e1b re: the consistency figure is mostly composite bounding boxes -- real disagreement is 1
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>
2026-08-12 18:02:06 +00:00
0625976ed7 re: the coverage fix moved 29 anchors, 22 of which carried another resource's geometry
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>
2026-08-12 17:40:22 +00:00
095816834a re: CORRECTION -- the static assembler is not at fault, the pre-fix decoder was
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>
2026-08-12 17:28:18 +00:00
27fd9f186f re: containment screen fails too -- the static assembler inflates a shared turret
envelope_screen measures per-axis protrusion past the sibling envelope, the
relationship the eye used. It still does not flag e106_bdy_03, and dumping the
static assembly shows why: e303_wep_01's world box is 1600x2100x4800 around a
~400x400x2000 hull, so nothing can protrude. The decode is innocent -- that
resource is 49x23x42 in every container -- so the static assembler is inflating
it 30-110x per axis, non-uniformly. The screen is only meaningful once placement
is trustworthy, and the assembler now has a worse defect than the decoder had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:20:52 +00:00
85ad540a7e re: the other four ships are clean; automating the slab check failed
Rendered f101/f105/f106/e105 statically -- all coherent, no stray masses, so the
e106 slab was specific. slab_screen.rs tries to automate the check by comparing
each part's min-axis extent to its ship median, but it produces identical flags
with and without the coverage fix at both 4x and 2.5x: it never sees
e106_bdy_03, the part it was built for. The eye used relationship (a mass apart
from the hull), not scale; a containment test is the right analogue and is not
built. Kept for what it does show: f002_bdy_22's 100000-unit tether and
t901_e01_D's mast are legitimate, and capture-verified f101_bdy_01 flags at 6x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 07:22:43 +00:00
f340815db6 fix(mesh): require exact pool coverage -- found by rendering, not by a metric
Rendering assembled e106 from Stage_S02 showed the old slab: e106_bdy_03 spanning
600x1600x998 where three other containers give 276x236x941. Its anchor had slack
3, and the coverage gate tolerated up to three unreferenced tail vertices --
tolerance that was hiding a mis-anchor, since real blocks reach their last vertex
exactly (8580 of 8629). Requiring exact coverage moves it to the block the other
containers agree on and the slab disappears.

Costs 3 resources (6212 -> 6209), inconsistency 39 -> 38, capture oracle
unchanged at 46/46, suite green. Evidence: captures/e106-cover-slack-before-after.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:56:49 +00:00
9d33345560 re: the grouped pivot floor is not the blocker either -- anchor work plateaus at 98.7%
Sweeping XBG7_GROUPED_CONSISTENCY 0.85 -> 0.80 -> 0.75 decodes no additional
resources and leaves inconsistency at 39, while changing which geometry some
grouped models get -- strictly worse, so 0.85 stays. With the degeneracy/extent
fix already refuted, the remaining 82 misses are not a threshold away; they need
a structural answer (marker lists that do not map onto stored blocks), which
means a capture, not more tuning. Records the state of the four shipped changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:34:41 +00:00
2f0ce19f68 re: 98.7% coverage, and the scale-free degeneracy fix is refuted
Under the winding gate, coverage is 6212/6294 (98.7%) with 82 misses left,
attributed 42 degenerate/extent, 31 winding, 9 coverage, 0 connectivity. The
biggest bucket turns out NOT to be the blocker: replacing the absolute area test
with a scale-free collinearity test decodes no more resources and raises
inconsistency 39 -> 44, and dropping the extent floor to 0.05 adds two. Both stay
as opt-in knobs (XBG7_REL_DEGEN, XBG7_MIN_EXTENT) rather than defaults. Also
fixed another stale default label in edge_cap_sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:19:38 +00:00
f835705b48 fix(mesh): winding consistency replaces the connectivity heuristic
Gate attribution put connectivity behind 153 of 225 misses -- the largest
blocker, and the gate already known to reject a capture-proven block. Testing it
against winding consistency (an objective topology signal: ~1.0 or ~0.0 for a
real mesh, ~0.5 for a mis-carve) shows a floor of 0.70 with connectivity inert
dominates the previous default on both axes: 6212 resources decoded (+143) with
39 shared inconsistencies (-17), capture oracle unchanged at 46/46, twin
invariant clean, coverage invariant intact. Suite green.

The 0.80 cliff (5770 resources, 0 inconsistent) is documented, not taken --
consistency is the weaker witness, and both points are one env var apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 05:47:01 +00:00
ee2b98341d fix(mesh): pre-pivot sub-meshes must cover their pool, not just index in range
The last two blocks that under-covered their vertex pool were f102_break.dat and
f104_break.dat in ptc_pack.xpr, each reading a neighbouring block's index buffer
against the wrong declaration (414 verts indexed to 404; 160 indexed to 79).
Their marker lists do not map onto the stored blocks -- only 2 of 9 and 4 of 10
sub-meshes decoded at all. Requiring coverage (max_idx + 4 >= vtx_count) for
pre-pivot sub-meshes drops exactly the mismatched pieces.

Every decoded sub-mesh disc-wide now covers its pool: 8580 at slack 0, 49 within
tolerance, none beyond, none negative. Coverage 6069/6294, inconsistency 56,
truth table 46/46 -- all unchanged. Suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 04:57:49 +00:00
6634d79c2e fix(mesh): grouped pools no longer emit sub-meshes with out-of-range indices
coverage_audit measures index coverage per decoded sub-mesh. 8586 of them
reference their last vertex exactly, so the 'buffer not covered' gate is well
founded -- but 18 had NEGATIVE slack: indices up to 364 vertices past the end of
their own buffer, emitted because anchor_grouped_meshes reads pre-pivot
sub-meshes unconditionally. Quality gates stay relaxed there (a tiny flat lead
part is legitimately poor) but index range is now required.

Coverage 6069/6294 unchanged, inconsistency 56 unchanged, truth table still 46/46
claimed, suite green; the vertex total drops by exactly the 1546 garbage verts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 04:41:17 +00:00
c9c98cf942 re: coverage is 96.4% (6069/6294) -- and the 225 misses get a gate breakdown
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>
2026-08-12 04:22:43 +00:00
db8104ab99 re: n206 was fixed after all -- the audit was classifying by geometry, not buffer
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>
2026-08-12 04:10:05 +00:00
bb0c94c0ce fix(mesh): distinct assignment now covers grouped pools too
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>
2026-08-12 03:53:10 +00:00
fd34d350f2 test: lock in the twin invariant -- 0 unrelated pairs disc-wide, 1 known collapse
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>
2026-08-12 03:44:29 +00:00
55e1fe3783 re: the break-composite alarm is false -- the container stores 3-6 copies of each buffer
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>
2026-08-12 03:32:58 +00:00
7f1ac41563 fix(mesh): raise the connectivity cap 0.28 -> 0.42 on runtime evidence
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>
2026-08-12 03:25:50 +00:00
d83bdd3b68 fix(mesh): distinct anchor assignment -- no two resources may claim one buffer
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>
2026-08-12 03:16:51 +00:00
1815490d01 re: scored against the runtime, relaxing the cap is monotone -- the regressions are collisions
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>
2026-08-12 03:06:15 +00:00
b58f42e46d re: the capture is Stage_S02 -- provenance corrected, oracle grows to 46 buffers
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>
2026-08-12 02:59:40 +00:00
df7a10749a re: calibrate the connectivity cap -- +612 resources against 18 consistency regressions
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>
2026-08-12 02:47:07 +00:00
845b63d7dd re: eng_02_l's real block is adjacent -- the connectivity heuristic rejects it
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>
2026-08-12 02:31:53 +00:00
dde52c02e7 re: correct the 3.4% reuse figure -- the capture dedups by (vbase, transform)
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>
2026-08-12 02:24:44 +00:00
51723af59a re: the twins' correct block validates -- distinctness is a real fix; eng_02_l is not
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>
2026-08-12 02:17:55 +00:00
76a433f75c re: the proven offsets ARE candidates -- these mis-anchors are selection failures
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>
2026-08-12 02:11:41 +00:00
26f1bdf6fd re: exact anchor offsets (GameMesh.vbuf_offset) -- correcting yesterday's defect table
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>
2026-08-12 02:04:55 +00:00
3245aca8cb re: the capture names file offsets -- the twin collapse is an anchoring error
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>
2026-08-12 01:55:13 +00:00
aa82d293a9 re: the capture logs are the sharing oracle -- engine reuses a buffer only 3.4% of the time
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>
2026-08-12 01:40:56 +00:00
6d96446735 re: the descriptor does not address geometry, and consistency does not prove reuse
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>
2026-08-12 01:32:34 +00:00
f655c90b0c re: shared decodes are mostly legitimate reuse -- correcting yesterday's claim
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>
2026-08-12 01:25:44 +00:00
aedad811bc test: fix the stale default disc paths -- three tests could never run unaided
texture_disc.rs and mesh_disc.rs fall back to "/home/fabi/RE Project Sylpheed/..."
when SYLPHEED_ISO / SYLPHEED_RES3D are unset. The directory is "RE - Project
Sylpheed"; the fallbacks were missing the " - ", so stage_models_decode,
stage_models_sweep and stage_models_quality_audit failed with NotFound for anyone
who did not happen to know the env var. They now pass unaided (10/10 in
mesh_disc).

Found while auditing what the suite actually exercises after withdrawing the
anchor change: an ISO-gated test skipping, and an ignored test failing on a typo,
are both "green" in a normal run.

Full state, with SYLPHEED_ISO/DISC/RES3D set and --include-ignored: 82 lib tests
and every disc/ISO suite pass; the single failure is
shared_resources_decode_identically_in_every_container, which is deliberately
written as the target state and #[ignore]d.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:18:10 +00:00
64d372c7e8 revert(mesh): withdraw the neighbourhood anchor -- it regressed the e106 twin mirror
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>
2026-08-12 01:08:41 +00:00
0f9c95c52e re: selection fixes are exhausted for the last 51 -- the correct block is not a candidate
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>
2026-08-12 00:57:09 +00:00
343a26ecc0 re: the remaining mis-decodes share one attractor, not pairwise confusion
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>
2026-08-12 00:46:32 +00:00
27a0701e0d fix(mesh): refine the anchor map before using it -- inconsistency 63 -> 51
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>
2026-08-12 00:39:35 +00:00
f18d5919f7 fix(mesh): anchor XBG7 resources by neighbourhood -- inconsistency 125 -> 63, ships render right
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>
2026-08-12 00:31:20 +00:00
d660705c47 re: the correct anchor is already a candidate -- locality selects it, monotonicity does not
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>
2026-08-12 00:16:22 +00:00
7c514e3a57 re: ROOT CAUSE of the silent XBG7 mis-decodes -- the candidate list is container-global
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>
2026-08-12 00:08:38 +00:00
b5a4764567 re: the XBG7 silent mis-decode is in the per-block anchor, not the grouped-pool one
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>
2026-08-12 00:00:37 +00:00
f34280476d test: lock today's format findings into disc-wide guards
Each finding today was originally missed by reasoning from a sample, so these
assert DISC-WIDE invariants rather than one hand-picked file.

  ui_surfaces_disc.rs (3 passing)
    every_t8ad_on_the_disc_decodes            -- all ~19216 surfaces; the old
                                                 256-grid model looked like 96%
    lsta_count_equals_sprites_plus_primitives -- header counts T8aD AND PRMD,
                                                 64/64, which is what made the
                                                 count look unreliable
    ratc_nesting_is_exactly_one_level         -- nested records are leaves; zero
                                                 grandchildren disc-wide

  mesh_consistency_disc.rs (1 ignored, deliberately)
    shared_resources_decode_identically_in_every_container

The mesh test is written as the TARGET state, not a snapshot of the bug: a
resource shared by several containers must decode to the same bounds, which today
fails for 125 of 681 shared resources. Fixing the anchor scan makes it pass;
un-ignoring it is then the last step rather than a rewrite. It only compares
decodes that agree on vertex/triangle counts, so "found different geometry" stays
a separate question from "placed the same geometry differently".

All suites green: 81 lib + the disc guards, with 2 ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:50:20 +00:00
140856b3f9 docs(re): session log for 2026-08-11, and correct the capital-ship INDEX row
The capital-ship row still said placement was the open question. Today's work
says otherwise: placement is sound (hull static-exact, cross-id mounting is 2
pairs across 335 ships) and the user-reported "ships assemble wrong" is an XBG7
mis-decode instead. The row now says that and points at the backlog and the mesh
doc, and keeps the one-way-test problem visible.

SESSION-2026-08-11.md summarises the run for review, leading with the single item
that needs the user's decision (editing save slot 01, deliberately not done) and
separating findings, the two disc defects found, and the process notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:41:58 +00:00
4097e8503f re: a detector for silent XBG7 mis-decodes -- 125 of 681 shared resources fail it
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>
2026-08-11 23:34:31 +00:00
c9916bcb41 re: CORRECTION -- the e106 slab is a container-dependent mis-decode, not a stray volume
One iteration ago I concluded assemble_ship was drawing a non-renderable
collision volume. That named the wrong cause, and decoding the same resource from
every container that holds it settles it:

  Stage_S01  172 verts 110 tris  49 x 23 x 42        <- a turret, correct
  Stage_S02  172 verts 110 tris  1600 x 2100 x 4800  <- wrong
  Stage_S08  same                1600 x 2100 x 4800  <- wrong
  Stage_S26  same                1600 x 2100 x 4800  <- wrong
  eleven others                  49 x 23 x 42        <- correct

Same resource, same vertex and triangle counts, correct in eleven containers and
wrong in three. So the placement is legitimate (e303_wep_01 is a small shared
turret cross-mounted on e101/e106), the original author's vbase-dedup explanation
of the capture's silence stands, and my "dedup would show one, not zero"
objection does not survive -- at its true size the turret is ordinary geometry.
The defect is in the mesh decoder.

The wider point: the decoder can produce wrong geometry WITHOUT declining. The
XBG7 audit counted 814 honest refusals; this is the other kind, silently 100x too
large. A screen for the signature (bounds exact multiples of 50, span > 1000)
flags 22-32 models each in S02/S03/S08/S26/S27, but it also catches legitimate
e_rou_* composite proxies, so that is a candidate list and not a bug count.

Next: diff the anchor scan's chosen vb0 for this resource between Stage_S01 and
Stage_S02 -- same resource, two outcomes -- and turn whatever distinguishes them
into a post-decode sanity check so a silent mis-decode becomes a decline.

Kept from the previous entry: the test can only fail one way, and cross-id
placement is genuinely narrow (2 pairs across 335 ships).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:25:55 +00:00
c5a5065389 re: the format layer is NOT exonerated -- assemble_ship draws a non-renderable volume
Did the visual the backlog kept naming as the next step, and it overturns the
conclusion. Rendering e106 from the static assembly and from the baked runtime
capture: the capture has 8 placements, the static assembly 11 -- the same 8 plus
e303_wep_01 x2 and a second e106_eng_01. In the render the destroyer sits inside
a white slab that dwarfs it.

That slab is e303_wep_01: 172 verts, 110 tris, bounds X[-1000,600] Y[-1050,1050]
Z[-2400,2400] = 1600 x 2100 x 4800. A real e106 turret (wep_02_01) is 269x179x417
with 772 tris; the bridge is 105x76x305. So it is not a turret -- it reads as a
collision/trigger volume, and whatever it is the game does not draw it: the e106
capture contains no e303_wep_01 at all.

Why it was missed: assemble_ship treats every rou_* composite node as drawable,
the cross-id mount is documented as intended, and the test asserts
count("e303_wep_01") == 2. The capture's silence was explained as vbase dedup,
but dedup would show one instance, not zero -- and the test walks capture parts
looking them up in the static output, so EXTRA static placements cannot fail it.
Same shape as the earlier include_external gap: a test that can only fail one way.

Scope stated carefully: sweeping 335 ships for "ship-scale span with <400 tris"
flags 20 ships and 58 placements over 28 resources, but ONLY the e106 case is
proven (render + capture absence + geometry). Others may be legitimately large
low-poly parts and need the same three checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:15:22 +00:00
5b8b5e0ab1 re: measure the XBG7 declined set, and close off the descriptor shortcut
"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>
2026-08-11 23:03:58 +00:00
d9c69c566c lsta: the count was never unreliable -- it counts sprites AND PRMD primitives
The module said "a few entries disagree with the actual frame count, so we walk
by the T8aD magic instead". They do not disagree. An LSTA is a display list of
inline elements that are either T8aD sprites or PRMD primitives (the flat
coloured quad the UI bundles use to dim a scene), and the count at 0x04 counts
both: across all 64 lists on the disc, count == T8aD + PRMD, with no exceptions.

The six lists that looked wrong (GP_DEBRIEFING_PILOTLOG, GP_MISSION_SELECT, two
language builds each) each hold exactly one primitive, which is the whole of the
off-by-one.

Also measured after the T8aD rectangle-list fix: all 1281 sprite frames decode,
100%.

parse() still returns sprites and skips primitives -- that is the useful
behaviour -- but the docs now say so instead of blaming the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:52:02 +00:00
d9ffc223bc re: RATC "one level deep" is not a limitation -- measured, plus a second dangling asset
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>
2026-08-11 22:44:19 +00:00
ac10d3bd19 t8ad: a surface is a list of sub-rectangles, not a 256 grid -- disc decode 96% -> 100%
The "~15% deferred variants" were not variants. Auditing every T8aD on the disc
gave 19216 surfaces, 18442 decoding (96.0%) and 774 failing in two clusters:
GP_DIALOG strips declaring 524x63 with a "tile count" of 1 or 2 instead of 3, and
small textures in the six *2D language paks whose pixels ran past the end of the
file.

Both fall out of the per-tile header, which is not opaque flags: it is four BE
u32 -- dst X, dst Y, width, height. A 15x18 icon stores a 13x18 rectangle at
(1,0); pdmes010 stores (59,6,256,54) and (315,6,149,54), the second beginning
exactly 16 + 256*54*4 bytes after the first. So 0x1c is a RECTANGLE COUNT and the
256-grid reading was an accident of most surfaces being stored as full-width
bands.

Parser rewritten to that model, still refusing to guess: a rectangle must fit the
declared surface and its pixels must fit the file, else None. Disc decode is now
19216/19216 = 100.00%.

Two test fixtures were built to the old model and are corrected rather than
worked around. lsta's t8ad_frame wrote NO offset-table entry, so the decoder read
"pixels" from inside the header -- the test passed only because it checked
dimensions alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:37:21 +00:00
9228255bfa re: validate the screen layout against the running game -- eight rows, +/-2 px
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>
2026-08-11 22:25:18 +00:00
35e2d1067f re: placement X/Y are SIGNED, the tail word is time, and the resting pose is max-dwell
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>
2026-08-11 22:14:36 +00:00
de357ffdc9 re: the screen parse generalises to the ARSENAL, plus kind 0x4 and the component model
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>
2026-08-11 22:06:00 +00:00
f65387143e re: the UI screen gap is closed -- the bundle also carries every element's placement
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>
2026-08-11 21:58:21 +00:00
dd8770826b docs: INDEX — retire the stale 'loop1.rat not decoded' note
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:50:39 +00:00
8814df843b re: the RATC declaration table IS the screen's draw list, and +32 is a parent index
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>
2026-08-11 21:50:13 +00:00
8b7c9da067 re: clear mirror handling and the exhaust cones -- the viewer bug's static avenue is exhausted
Mirror handling: the concern was that apply() bakes R*(S*v)+T while the viewer
decides winding from det(m) alone and rotates normals by m alone, so a mirror
encoded as negative SCALE would reflect geometry without flipping winding. It
never happens -- across 1485 assembled parts in all 22 containers there are 22
mirrored parts, every one with det(m) < 0, and ZERO negative or non-uniform
scales. apply_twin_mirrors writes the reflection into m, so the flip always
fires and ignoring s for normals is harmless.

Exhaust cones: the only geometry the viewer invents, drawn at GN_Jet/GN_SJet
frames because the real engine geometry is recessed. Across 335 assembled ships,
192 with exhaust frames, not one cone sits outside its hull bounding box (10% of
span tolerance). Caveat kept in the doc: inside-the-box does not prove a cone is
correctly oriented or sized -- it rules out the reported symptom, no more.

Every mechanism the diagnosis proposed is now eliminated (include_external,
duplicate names, mirrors, cone placement). The visual comparison is no longer
the next step but the only remaining one, and if it agrees the original report
needs re-grounding against a specific ship and expectation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:41:20 +00:00
a7905d0f6b re: kill the duplicate-resource-name hypothesis for the viewer bug, and reconcile 22 vs 16 stages
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>
2026-08-11 21:31:18 +00:00
671891f9bf re: XPR2 channel order confirmed against the running game -- the second "dynamic unknown" closed
INDEX carried XPR2 as "colours unverified (dynamic item)". The failure mode that
matters is a swapped red/blue channel: invisible on greys, invisible without
ground truth, and it poisons every asset silently.

Method: pick an asset that is strongly asymmetric in hue and has known in-game
appearance. DeltaSaber_T.xpr (1024x1024 Dxt1, 11 mips) decodes to white/grey
panels with orange-rust accents; the game renders the same hull in the ARSENAL
scene.

  decoded texture : 1073 warm px, 0 cool, median saturated hue 23.3 deg
  in-game render  :  916 warm px, 124 cool, median saturated hue  9.3 deg

Both orange/red; a red<->blue swap would put the texture at ~200 deg. The cool
pixels are the blue UI behind the model and the 14 deg offset is the hangar's
red key light -- neither can move a hue by 180.

Stated as a bounded result, not "colours are correct": a hue-family comparison
cannot see a gamma/sRGB error, premultiplied alpha, or a per-channel scale, so
exact fidelity stays open and needs a flat unlit known-value surface. Cubemap
face ordering untouched. BG_Earth.xpr (A8R8G8B8 cubemap face) decoding to a
coherent nebula is corroboration for the de-tile step, not proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:21:55 +00:00
38f58ea559 re: the movie/subtitle/voice link is solved statically -- INDEX called it a dynamic unknown
INDEX carried IXUD as "movie<->track link unknown (dynamic item)". It is a
config record on the disc: tables.pak schema 0x067025b9, whose keys match the
movie game-part symbols in the executable (MOVIE / TELOP / SUBTITLE /
VOICETRACK, plus SUBTITLE_FONT / SUBTITLE_Y / SUBTITLE_HEIGHT next to
silph::GamePart_Movie::Impl::OnPrepare).

101 movies mapped: 94 with a subtitle, 83 with a voice track, 21 with a telop.
Every subtitle reference was looked up in dat/movie/<lang>.pak and 93 of 94
resolve -- SUBTITLE_S12B.tbl resolves in NONE of the six languages, a dangling
reference on the retail disc rather than a decode failure.

Naming is SUBTITLE_<base>.tbl and VOICE_<base>, with the staff roll and five
supply movies (VOICE_D_450..454) as documented exceptions, so a reimplementation
can resolve by convention and fall back to the table for those six.

Deliberately NOT published: the record's ~104 script ids paired to movies.
Positional pairing drifts by three -- visible at the tail, where
S24/S27_SUPPLY_ACROPOLIS would have to map to hokyu_LS_s24A/s27A and do not --
because the IDXD pool dedupes repeated values and an id whose movie was already
named contributes no token. The CSV is keyed by movie file instead.

For the intro-movie work: ADV.wmv has VOICE_ADV and no subtitle, no telop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:10:08 +00:00
bb9e75fe6a re: the title-menu screens read the auto-save (slot 01), and the high score is computed not stored
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>
2026-08-11 21:00:16 +00:00
df45a1986b re: SHAB is the per-stage record table -- record 0 is Stage 01, and c is its best time
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>
2026-08-11 20:50:06 +00:00
98b3cf56ac re: name the save read path, and locate the save state at game_object+304
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>
2026-08-11 20:24:47 +00:00
bc42c0970e re: the save screen's key table is compiled into the executable -- a 4th difficulty, 3 states, 16 stages
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>
2026-08-11 20:13:01 +00:00
2da9b8a9b8 re: the whole GHAD block is neither stage nor difficulty -- sixteen probe saves
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>
2026-08-11 20:01:26 +00:00
eac3435cc7 re: +36/+52/+56 are neither stage nor difficulty -- refuted with eleven probe saves
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>
2026-08-11 19:37:06 +00:00
6e0fbe52a6 docs: retire the last three sentences that predate the solved index space
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:01:43 +00:00
841156a95d re: the develop blob's index space is SOLVED -- it follows strings.tbl, cut items and all
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>
2026-08-11 19:01:14 +00:00
dc97dc215d docs: reconcile the 'free items are granted' wording with the 0 P purchase the probe caught
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:10:58 +00:00
f5f95be0a1 re: saves can be written back, which settles what the develop blob's 4s mean and refutes the tail
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>
2026-08-11 18:10:28 +00:00
f88f4f6847 re: the Arsenal develop economy, and the save blob's index space (first 32 confirmed)
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>
2026-08-11 17:38:45 +00:00
6ebf2ba00a re: capture the Hangar screen — it is a loadout picker, not a craft data sheet
The Hangar lists NOSE / MAIN WEAPON 1-3 with the mounted weapon and a derived
'Gross Weight: Light'. It shows no craft stat fields at all, so the Hangar is
not a route to the unit definition's defaulted fields — the Arsenal DATA SHEET
(weapons only) and the live-RAM route remain the only two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:19:21 +00:00
f0ceda1c6f re: developing one Arsenal weapon separates Points from its twin and names the save blob
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>
2026-08-11 17:16:57 +00:00
5cc995e864 docs/re: INDEX — reflect the named save fields and the pure-state result 2026-08-11 06:02:31 +00:00
882dbc2ae2 re: a second save names Points, flight time and clear ratio — and shows the payload is pure state
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.
2026-08-11 06:02:21 +00:00
398e8ae0af re: the save file is a GDHA/zlib chunk stream — layout read off the title's own serializer
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.
2026-08-11 05:40:26 +00:00
5c3e3dfe47 re: making turrets targets does not save the escort — the effect is inside the noise
The Stage-02 outcome write-up ended by naming "turrets near the asset must
become targets" as the fix worth ~50% of the escort damage. That was an
inference from a co-presence attribution, not a measurement. Run it and it
does not hold.

Also corrects the run labelling: pilot.py gained the SYLPH_KILL_TURRETS gate
two minutes before mission02 started, so mission02 was already a treatment run,
not a second baseline. Only mission01 (0 of 3968 pilot frames targeting a
turret) is the baseline.

At a common t=428s: baseline 46.9% escort hull, treatment 44.5% and 53.0%. The
two runs of the same arm differ by more than either differs from the baseline,
and the escort still reaches zero at t~590-670s in all three. So the
transferable finding is the power limit: one 430s flight cannot resolve an
effect below ~9 percentage points, and every single-run pilot conclusion,
including this one's, is inside it.

What does reproduce: the assault is scripted (onset 166/167/166s), and the
e007/e010 damage split is 50/50 in all three arms including the one that never
fires at a turret -- so that attribution measures the wave script, not us.

Also records that the viewer's include_external hypothesis in BACKLOG is dead
(it defaults true and is threaded through unchanged).
2026-08-11 05:29:52 +00:00
f4d59c5783 re: Stage 02 is lost at ~11 minutes, and the pilot's survival rule is what guarantees it
First session whose deliverable was the mission's ENDING rather than a
measurement (mission_run.sh, 500 s, hull of every entity at 2 Hz). The ACROPOLIS
is untouched to t=170 s then falls at ~53 HP/s with no let-up, reaching zero at
t=640-720 s — so "no mission completed" is not an artifact of the 240 s
time-boxes, and not of the 600 s cap on a blocking tool call. A longer session
would only watch the loss arrive.

Attributing the damage by co-presence, exactly two classes are ever near the
asset: e007 turrets (8483) and e010 bombers (8334). pilot.py treats turrets as
keep-out zones at 2500 units and never as targets — the rule that made it
survive — so roughly half the escort damage comes from the one class it is
designed to avoid. Survival and the objective are in direct conflict and the
pilot resolves it entirely for survival: WARSHIPS 0000, WARPLANES 0009,
REMAINING OB rising 004 -> 008, our hull untouched at 1500/1500 with 120
missiles spent. That is unspent risk budget, not a good run.

Also corrects launch_mission.sh: a harness-tracked BACKGROUND task does not keep
the display alive (lost 11 s in, at the turn boundary) — the
one-blocking-foreground-call rule stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:57:43 +00:00
530555de9f re: the weapons are the control — sibling-default inheritance is unit-schema-specific, not engine-wide
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>
2026-08-10 20:09:14 +00:00
69b4a2e569 re: a unit field left unset on disc is not a global constant — Size_Y inherits Size_X
Attacks the 21/110 unit-coverage limit from the cheap side: if a defaulted field
always took one runtime value, the captured units would pin it for all 110. Only
6 of 24 confirmed defaulted fields behave that way. The other 18 vary per unit,
so the default is computed.

Asking which OTHER field of the same unit holds that value — counting only
non-zero cases, and checking the two fields sit at different offsets so the
layout solver cannot be aliasing them — gives four rules. Size_Y <- Size_X is
solid: seven unrelated ships (e105 600, e106 300, e108 80, e201 300, f101 400,
f105 700, f106 200) each omit it on disc and each shows its own Size_X live,
while the two fields differ freely when both are on disc. Size_Radius fits both
min(X,Z) and the median of the three axes and cannot yet be separated;
e010_ADAN_Attacker_S is what rules out the simpler Size_X rule. FCSRange and
DefencePoint rest on two independent units each and ship as HYPOTHESIS.

Applied across the disc the rules recover 65 (unit, field) values in units that
have never been visited. Falsification test recorded: load any uncaptured stage
and compare one predicted value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:44:24 +00:00
ca500c171e ship: index-less brg/eng/sld parts never matched their GN frame — 34 ships assembled without a bridge
Tier 3 matched a part to its hardpoint by trailing index, so `e105_brg`
compared "01" == "" against GN_Bridge_01 and fell through silently. The runtime
capture is what exposed it: the game draws the bridge and places it at
[0, 70, -1850] rel e105_bdy_01, and assemble_ship emitted nothing there.

With no index to match on, take the lowest-numbered frame of the category.
Diffing assemble_ship part counts across every container: 34 (stage, ship)
entries gain parts — e102 +2 (bridge and engine), e104 +1, e105 +1, Stages
02-29. ship_audit is unchanged, so nothing regressed, and the capture now agrees
to dT 0.03 / dR 0.000.

Also fixes the diff itself: correlate_frames compared static against a rotation
sampled from the first block, which can belong to another INSTANCE of the class.
Scoped to the position-agreeing cluster, e105_eng_01 goes 1.711 -> 0.000 and
both e106 nacelles to 0.000. The one remaining rotation delta (e106_wep_02_01,
0.134) is a turret whose rotation varies by 0.182 between blocks that agree on
its position — the runtime disagrees with itself more than with the assembler.
The new rotVar column makes that distinction visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:16:06 +00:00
3d9f21f030 re: control the range, segment the frames — f105/e105/e106 all match static assembly
A capture at controlled range (ship_capture_close.sh: lock a capital ship, close
on it, F10 per range band) finally draws capital-ship hulls at full detail. Two
correctness fixes were needed before the numbers meant anything:

* one F10 log is ~14 frames with no delimiter, and WV_ref^-1 . WV_p only cancels
  the camera within one frame — segment_frames splits on vertex-buffer
  recurrence, and correlate_frames cross-checks the blocks against each other
  instead of trusting a single shot;
* aggregate by consensus, not median: a stage holds several ships of one class
  sharing vertex buffers, so a block can mix two instances.

Result: f105, e105 and e106 reproduce assemble_ship to <=0.43 units in
translation and 0.000 in rotation for every part that does not move. The e106
rules generalise, and the viewer bug report now points at the viewer. Narrow
leftovers: e105_brg is missing from assemble_ship, e105_eng_01 rotation differs
by 1.711.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:19:26 +00:00
1d4b35df0f re: the Stage-02 capture drew no capital ship at all — invert the match, then control range
Inverting the capture↔part question (invert_capture over one container,
vcount_index over all 166) identifies every large draw in the 2026-07-31
capture: the player's own DeltaSaber (10891 verts), its weapon packs, the
backdrop and particles. Of f101/e105/e106 only 1-3 of 15-37 resources have a
drawn vcount, each a 44-225-vertex far-LOD/effect piece whose count collides
with dozens of unrelated resources.

So the zero-correlation was not an LOD-list gap, not over-strict position
validation and not a different draw path: the ships were too far away to be
drawn. approach_capture.py flies at a locked capital ship and presses F10 per
range band, stamping each capture with its distance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:04:58 +00:00
Claude (auto-RE)
bbfeb1c387 re: ship-placement generalisation — static audit + first Stage-02 F10 capture (WIP)
Working the BACKLOG item "capital ships assemble wrong in the viewer".

- ship_audit over all 22 stage containers: only ONE outlier ship
  (f002_bdy_05, Stage_S03/S27, dist 6540 vs cluster spread 1071), so static
  assembly is not grossly broken class-wide. Recurring MULTIKEY joint tracks on
  f104/f105/f106/e102 are the standing hypothesis for class-specific error —
  e106, the one validated ship, has none.
- new tools/re-capture/ship_capture_session.sh: one blocking session that boots
  Stage 02 and fires N F10 ship-captures with screenshots. Boot to in-flight was
  24 s; 3 of 5 presses produced logs (2964/3111/3668 draws).
- NEGATIVE, unexplained: correlate_capture matched ZERO parts for f101/f105/
  f106/e105. Documented with the collected facts and the next step (invert the
  match: largest capture vcounts -> which decoded part has that count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:16:20 +00:00
107 changed files with 17302 additions and 130 deletions

View File

@@ -12,7 +12,12 @@
ship e106 ref=e106_bdy_04
e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667
e106_bdy_02 -1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
# 2026-08-12: this rotation was diag(-1,1,1) - the X-flip `correlate` bakes in
# when a draw's buffer is the mirror of the decoded one. With distinct anchor
# assignment the twins now decode to their OWN buffers (the container carries
# both halves), so the mirror lives in the data and the placement is plain
# identity. Re-emitting from the capture agrees: `1 0 0 0 1 0 0 0 1 264.04343`.
e106_bdy_02 1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878
e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0
e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748

View File

@@ -0,0 +1,177 @@
# Unit / vessel definition object layout — offset, field, kind.
#
# Read out of the title's own loader, not inferred: `sub_82341A20` builds every
# key as `addi r4, r30, -N` with `r30 = 0x82088f94`, so the field NAME for each
# store is a string in the executable image; pairing each key with the first
# store after its accessor call gives the offset. The accessor for floats is
# `sub_822FC5A8`, which returns 0.0 when the pool has no value for the key.
#
# Verified against live objects dumped from a running Stage 02: 406 field values
# agree with the disc records and 0 disagree, over 11 objects spanning both the
# UNIT and VESSEL schemas (see docs/re/live-unit-definitions.md).
#
# Two conventions matter for a reimplementation:
# * angle fields are DEGREES on disc and RADIANS in this object;
# * a field the disc record omits is left at the accessor's miss value (0.0 for
# floats) unless something later derives it — `Size_Y` takes `Size_X`.
#
# offset kind field
24 str Model
28 str CollisionModel
48 f32 Size_X
52 f32 Size_Y
56 f32 Size_Z
64 f32 Color_R
68 f32 Color_G
72 f32 Color_B
80 f32 Size_Radius
84 f32 HP
88 f32 HQRatio
92 f32 ShieldRatio
96 f32 ThrusterRatio
100 word IsDestructible
104 str NamePlate
112 word MountedShieldGenerator
116 f32 ResistanceToOptics
120 f32 ResistanceToShell
124 f32 ResistanceToExplosion
128 f32 ResistanceToPlayer
132 f32 ResistanceParalyze
136 str HUDMarkerID
156 f32 MinimumVelocity
160 f32 MaximumVelocity
164 f32 CruisingVelocity
168 f32 Acceleration
172 f32 Deceleration
176 f32 AV_PitchPlus_Max
180 f32 AV_PitchPlus_Min
184 f32 AA_PitchPlus_Max
188 f32 AA_PitchPlus_Min
192 f32 AV_PitchMinus_Max
196 f32 AV_PitchMinus_Min
200 f32 AA_PitchMinus_Max
204 f32 AA_PitchMinus_Min
208 f32 AV_Yaw_Max
212 f32 AV_Yaw_Min
216 f32 AA_Yaw_Max
220 f32 AA_Yaw_Min
224 f32 AV_Roll_Max
228 f32 AV_Roll_Min
232 f32 AA_Roll_Max
236 f32 AA_Roll_Min
248 f32 SideThrustVelocity_Max
252 f32 SideThrustAcceleration
256 f32 MaximumBank_Normal
260 f32 YawDragFactor
264 f32 PitchDragFactor
268 f32 RollDragFactor
272 f32 DragFactorThreshold
276 f32 ArterBurner_Vc
280 f32 ReverseThrust_Vc
284 f32 ArterBurner_Acc
288 f32 ReverseThrust_Acc
292 f32 AccPitchFactor
296 f32 DecPitchFactor
300 f32 AV_AxisMode_Max
304 f32 AV_AxisMode_Min
308 f32 AA_AxisMode_Max
312 f32 AA_AxisMode_Min
316 f32 PowerCutConsumeShield
320 f32 PowerCutDeceleration
324 f32 AB_ConsumeShield_Begin
328 f32 AB_ConsumeShield
332 f32 AB_AV_PitchPlus
336 f32 AB_AA_PitchPlus
340 f32 AB_AV_PitchMinus
344 f32 AB_AA_PitchMinus
348 f32 AB_AV_Yaw
352 f32 AB_AA_Yaw
356 f32 AB_AV_Roll
360 f32 AB_AA_Roll
364 word SideRoll
368 f32 SideRoll_Time
372 f32 SideRoll_Length
376 word BarrelRoll
380 f32 BarrelRoll_CountMinimum
384 f32 BarrelRoll_CountMaximum
388 f32 BarrelRoll_Time
392 f32 BarrelRoll_Radius
396 word TurnAttack
400 f32 TurnAttack_CutoffRatio
404 f32 TurnAttack_DoubleRatio
408 f32 CutoffTimeMin
412 f32 CutoffTimeMax
416 f32 TurnAttack_DoubleTimeMin
420 f32 TurnAttack_DoubleTimeMax
424 word TurnAway
428 f32 Turn_AngularVelocity
432 f32 TurnAway_Time_Minimum
436 f32 TurnAway_Time_Maximum
440 word BoostAway
444 f32 BoostAway_Time_Minimum
448 f32 BoostAway_Time_Maximum
452 word HoldPosition
456 f32 HoldPosition_LengthMin
460 f32 HoldPosition_LengthMax
464 f32 HoldPosition_MinimumTime
468 f32 HoldPosition_MaximumTime
472 f32 HoldPosition_SideRatio
476 f32 HoldPosition_BackRatio
480 f32 HoldPosition_CutoffRatio
484 f32 HoldPosition_CancelTime
488 word Slalom
492 f32 Slalom_CutoffRatio
496 f32 Slalom_TurnCount_Min
500 f32 Slalom_TurnCount_Max
504 word Through
508 f32 Through_CutoffRatio
512 f32 Through_AngleMinimum
516 f32 Through_AngleMaximum
520 f32 Through_Time1Max
524 f32 Through_Time1Min
528 f32 Through_Time2Max
532 f32 Through_Time2Min
536 f32 Through_LengthMinimum
540 f32 Through_LengthMaximum
544 word SolidCutoff
548 f32 SolidCutoff_Ratio
552 f32 SolidCutoff_LengthMin
556 f32 SolidCutoff_LengthMax
560 f32 HomingResistAdjustment
564 f32 UsingChaffRatio
568 f32 MaxValue
572 f32 ChargeDelay
576 f32 ChargeDelay_Break
580 f32 ChargeSpeed
584 f32 Delay
588 f32 DelayAdjustment
596 str LowerHPFxModel
604 str ExplosionFxModel
612 str DestroyMotionName
620 str ExplosionMotionName
624 f32 DestroyMotionTime
628 f32 DryMass
632 f32 GrossMass
636 word Thruster
640 word SideThruster
644 word ExplosionSE
648 word JumpIn
652 word JumpOut
656 word ShipEnvironmentSE
660 word LowerHPSE
664 f32 LowerHPThresholdRatio
668 f32 SELength
672 f32 RadarRange
676 f32 FCSRange
680 f32 FiringRange
684 word MountedFCS
692 f32 AttackVesselPoint
696 f32 AttackCraftPoint
700 f32 DefencePoint
736 str ShieldRecoverEffectName
740 str ShieldHitEffectName
744 str JumpIn
748 str JumpOut
752 str FadeIn
756 str FadeOut
760 str Effect_Paralyze

View File

@@ -0,0 +1,22 @@
//! Time repeated per-ship decodes of one container, as the viewer does.
use std::collections::HashSet;
use std::time::Instant;
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{is_base_part, ship_id_of};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let names = sylpheed_formats::mesh::xbg7_resource_names(&bytes);
let ids: Vec<String> = {
let mut v: Vec<String> = names.iter().filter(|n| is_base_part(n))
.filter_map(|n| ship_id_of(n).map(|s| s.to_string())).collect();
v.sort(); v.dedup(); v.truncate(5); v
};
for id in &ids {
let want: HashSet<String> = names.iter()
.filter(|n| ship_id_of(n) == Some(id.as_str())).cloned().collect();
let t = Instant::now();
let got = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("{id}: {} models in {:?}", got.len(), t.elapsed());
}
}

View File

@@ -0,0 +1,53 @@
//! Does a resource's DESCRIPTOR carry its bounding box?
//!
//! The last cross-container disagreements are 24-vertex bound boxes swapping
//! identities; no anchoring rule can pin them (see docs). If the descriptor
//! states the box, that is the missing information. This decodes the resource,
//! takes the box its geometry actually spans, and searches the descriptor for
//! those float values.
//!
//! Usage: bounds_in_descriptor <container.xpr> <resource>...
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { continue };
println!(
"{} descriptor 0x{d0:x}..0x{d1:x} ({} bytes), box lo{:?} hi{:?}",
m.name,
d1 - d0,
lo.map(|v| v.round()),
hi.map(|v| v.round())
);
// Where in the descriptor does each bound value appear (±0.01)?
let targets: Vec<(&str, f32)> = vec![
("lo.x", lo[0]), ("lo.y", lo[1]), ("lo.z", lo[2]),
("hi.x", hi[0]), ("hi.y", hi[1]), ("hi.z", hi[2]),
];
for (label, v) in targets {
let mut at: Vec<usize> = Vec::new();
let mut o = d0;
while o + 4 <= d1 {
let f = f32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
if (f - v).abs() <= 0.01 * (1.0 + v.abs()) {
at.push(o - d0);
}
o += 4;
}
println!(" {label:5} {v:10.3} at descriptor offsets {:x?}", &at[..at.len().min(6)]);
}
}
}

View File

@@ -0,0 +1,112 @@
//! Which container did each captured draw come from, and where in it?
//!
//! Extends the single-container `--map` check in `shared_vbase_check` to a whole
//! `resource3d` directory. For each container it indexes every 4-byte-aligned
//! position triple, looks up each draw's first dumped position, confirms the run
//! at a fixed stride, and reports the modal `vbase offset`. A container the
//! engine loaded shows one dominant constant; an unrelated one shows noise.
//!
//! The output is capture-named ground truth for anchors far beyond the one ship
//! `Stage_S01` gave us.
//!
//! Usage: capture_truth_scan <resource3d_dir> <capture.log>...
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let dir = &a[1];
let mut draws: Vec<CapturedDraw> = Vec::new();
let mut seen = std::collections::HashSet::new();
for log in &a[2..] {
let text = std::fs::read_to_string(log).expect("log");
let mut d = parse_capture(&text);
if d.is_empty() {
d = parse_drawlog(&text);
}
for x in d {
// One entry per buffer; the logs are already deduped per transform.
if x.pos.len() >= 8 && x.vcount >= 20 && seen.insert((log.clone(), x.vbase)) {
draws.push(x);
}
}
}
eprintln!("{} distinct (log, vbase) draws to place", draws.len());
let mut files: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut placed = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
// Index quantised position triples. Junk floats (NaN/huge) are skipped,
// which prunes most of a texture-heavy container.
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let mut o = 0usize;
while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6
{
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
}
o += 4;
}
let mut deltas: HashMap<i64, Vec<(u32, u32)>> = HashMap::new();
for d in &draws {
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
// ±1 in each axis: the log rounds, our file value may round the
// other way at a tie.
for dx in -1..=1i64 {
for dy in -1..=1i64 {
for dz in -1..=1i64 {
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else {
continue;
};
for &off in cands {
for stride in (12..=64).step_by(4) {
let ok = (1..4).all(|j| {
let at = off as usize + j * stride;
at + 12 <= bytes.len()
&& (0..3).all(|c| {
(be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4
})
});
if ok {
deltas
.entry(d.vbase as i64 - off as i64)
.or_default()
.push((d.vbase, d.vcount));
break;
}
}
}
}
}
}
}
let mut top: Vec<_> = deltas.into_iter().collect();
top.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
if let Some((delta, hits)) = top.first() {
if hits.len() >= 3 {
placed += hits.len();
println!(
"{:<22} base=0x{:<10X} buffers={}",
f.file_name().unwrap().to_string_lossy(),
delta,
hits.len()
);
}
}
}
eprintln!("{placed} draws placed in a container");
}

View File

@@ -0,0 +1,34 @@
//! How much of the "decoded geometry" belongs to SCENE COMPOSITES rather than to
//! real meshes? A composite (`rou_*`, `e_rou_*`) carries node transforms; the
//! anchor scan nevertheless finds a block for it, and that pseudo-geometry lands
//! in coverage and consistency counts.
//! Usage: composite_geometry <resource3d_dir>
use sylpheed_formats::mesh::{scene_world_nodes, Xbg7Model};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut total, mut composite, mut composite_named) = (0usize, 0usize, 0usize);
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
total += 1;
let has_nodes = !scene_world_nodes(&bytes, &m.name).is_empty();
let named = m.name.starts_with("rou_") || m.name.starts_with("e_rou_");
if has_nodes {
composite += 1;
}
if named {
composite_named += 1;
}
}
}
println!(
"{total} decoded; {composite} have scene nodes (a composite), {composite_named} are named rou_/e_rou_"
);
}

View File

@@ -0,0 +1,87 @@
//! Minority report: which container decodes a shared resource differently from
//! all the others?
//!
//! Cross-container consistency has been measured as "do the spans agree", which
//! only says a resource is inconsistent — not which copy is wrong. With three or
//! more copies the majority is the reference, and the minority names the
//! container AND the resource to look at. That is what caught the `n054`/`n056`
//! shift chain after the exact-coverage fix.
//!
//! Usage: consensus_check <resource3d_dir> [--list]
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::{BTreeMap, HashMap};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let list = std::env::args().any(|a| a == "--list");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// name -> [(container, verts, tris, span)]
let mut seen: BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
continue;
}
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
let span = [
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64,
];
seen.entry(m.name.clone()).or_default().push((where_.clone(), v, t, span));
}
}
let (mut resources, mut minority) = (0usize, 0usize);
let mut rows: Vec<String> = Vec::new();
for (name, list_) in &seen {
// Only compare decodes that agree on how much geometry they found, and
// only where a majority can exist.
if list_.len() < 3 || !list_.iter().all(|e| e.1 == list_[0].1 && e.2 == list_[0].2) {
continue;
}
let mut votes: HashMap<[i64; 3], usize> = HashMap::new();
for e in list_ {
*votes.entry(e.3).or_default() += 1;
}
let (best, n) = votes.iter().max_by_key(|(_, n)| **n).unwrap();
if *n * 2 <= list_.len() {
continue; // no majority — cannot call anyone the odd one out
}
resources += 1;
for e in list_.iter().filter(|e| e.3 != *best) {
minority += 1;
rows.push(format!(
"{:<22} {name:<26} v={:<6} {:?} vs the other {n} containers' {:?}",
e.0, e.1, e.3, best
));
}
}
if list {
for r in &rows {
println!("{r}");
}
}
println!(
"{minority} minority decodes across {resources} resources that have a majority"
);
}

View File

@@ -16,7 +16,7 @@
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
//! e.g. SYLPHEED_ISO="/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of
//! e.g. SYLPHEED_ISO="/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of
//! Deception (USA, Europe) (En,Ja).iso" \
//! cargo run --release --example correlate_capture -- \
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit

View File

@@ -0,0 +1,280 @@
//! Correlate a capture **per frame** and cross-check the frames against each
//! other — the placement is only believable if independent frames agree.
//!
//! `correlate_capture` treats one capture log as one set of draws. It is not:
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
//! cancels the camera within a single frame (see
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
//!
//! So: segment, correlate each frame independently, then report per part the
//! median translation and the spread across frames. A part whose spread is a
//! few units is measured; a part that swings by hundreds is not, whatever the
//! single-shot number said.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{
correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
};
use sylpheed_formats::xiso::open_iso;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len();
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
if positional.len() < 3 {
eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
std::process::exit(2);
}
let (log, stage, id) = (positional[0], positional[1], positional[2]);
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
let min_parts: usize = args
.iter()
.position(|a| a == "--min-parts")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
let frames = segment_frames(&draws);
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
let base_parts: Vec<String> = names
.iter()
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
.cloned()
.collect();
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
for p in &base_parts {
for suf in ["_m", "_l", "_d"] {
let c = format!("{p}{suf}");
if names.contains(&c) {
want.insert(c);
}
}
}
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
let m = models.iter().find(|m| m.name == name)?;
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
};
// part -> [T per frame], and how many frames placed it at all.
let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
let mut used_frames = 0usize;
for (fi, fr) in frames.iter().enumerate() {
let mut keys: Vec<PartKey> = Vec::new();
for part in &base_parts {
let variants =
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
let union: Vec<[f32; 3]> =
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
for cand in &variants {
if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32;
if fr.iter().any(|d| d.vcount == vcount) {
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
}
}
}
}
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
if ship.parts.len() < min_parts {
continue;
}
// Placements are expressed in the REFERENCE part's frame, so blocks that
// fell back to a different reference (because the requested one was not
// drawn in that block) are in a different coordinate system entirely.
// Averaging them together is what makes an otherwise clean result look
// like it disagrees by exactly the distance between the two references.
if !ship.reference.contains(ref_sub) {
println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
continue;
}
used_frames += 1;
println!(
" block {fi:2} ({:4} draws): ref={} parts={}",
fr.len(),
ship.reference,
ship.parts.len()
);
for p in &ship.parts {
samples.entry(p.part.clone()).or_default().push(p.t);
rots.entry(p.part.clone()).or_default().push(p.m);
}
}
if used_frames == 0 {
println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
return;
}
// Aggregate by CONSENSUS, not by average. A stage holds several ships of the
// same class, they share vertex buffers, and a block can therefore contain
// one instance's full-LOD part next to another instance's `_m` copy — two
// different buffers, so nothing splits them, and the recovered translation
// then belongs to whichever instance the correlator happened to pick. Those
// are outliers by thousands of units, so a mean or a median over all blocks
// is meaningless; the largest cluster of blocks that agree with each other
// is the placement, and the rest are honestly reported as other instances.
const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
let mut best: Vec<usize> = Vec::new();
for seed in ts {
let near: Vec<usize> = ts
.iter()
.enumerate()
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
.map(|(i, _)| i)
.collect();
if near.len() > best.len() {
best = near;
}
}
let out = ts.len() - best.len();
(best, out)
};
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
let mut agree = 0usize;
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
for (part, ts) in &samples {
let (cl_idx, outliers) = cluster(ts);
let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
let med = [
median(cl.iter().map(|t| t[0]).collect()),
median(cl.iter().map(|t| t[1]).collect()),
median(cl.iter().map(|t| t[2]).collect()),
];
let spread: Vec<f32> = (0..3)
.map(|a| {
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
})
.collect();
let verdict = if cl.len() < 2 {
"single block — unverified"
} else {
agree += 1;
"AGREES"
};
// How much the ROTATION varies between blocks that agree on position.
// A part bolted to the hull reads 0 here; a part that is articulating
// (turret aiming, engine gimballing) does not — which is what separates
// "the assembler has the rotation wrong" from "the part moved".
let all_ms = rots.get(part).cloned().unwrap_or_default();
let ms: Vec<[[f32; 3]; 3]> =
cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
// Keep a rotation from INSIDE the cluster: the first sample overall can
// belong to another instance, and diffing static against that reads as a
// rotation error that is really an instance mix-up.
consensus.insert(
part.clone(),
(med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
);
let rot_var = ms
.iter()
.flat_map(|a| ms.iter().map(move |b| (a, b)))
.map(|(a, b)| {
(0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (a[i][j] - b[i][j]).abs())
.fold(0.0f32, f32::max)
})
.fold(0.0f32, f32::max);
println!(
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}",
cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() }
);
}
println!("\n{agree}/{} parts reproduce across blocks", samples.len());
// `--static <Stage_SNN.xpr>`: diff the offline assembler against this
// ground truth. Static placements are in ship space, so both sides are
// re-expressed in the reference part's frame before comparing — and the
// rotation is compared too, because "wrong orientation" is half of the
// reported viewer symptom and a translation-only check cannot see it.
let Some(si) = args.iter().position(|a| a == "--static") else { return };
let Some(spath) = args.get(si + 1) else { return };
let sbytes = std::fs::read(spath).expect("read stage container");
// `include_external = true` — the engine cluster, the bridge and cross-id
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
// primary-composite pass does not reach. With `false` an e106 assembles as
// 5 parts and the runtime capture's bridge/nacelles read as "not produced by
// assemble_ship", which is a property of the caller, not of the format.
let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
return;
};
// The reference is placed axis-aligned in every ship seen so far; if that
// ever stops holding, the rotation would have to be unwound here too.
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
let mut worst_t = 0.0f32;
let mut worst_r = 0.0f32;
for (part, (med, rm)) in &consensus {
let (med, rm) = (*med, *rm);
// A part may be instanced (mirrored twins share a resource name); take
// the static copy that lands nearest the captured one.
let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
scene.iter().filter(|p| &p.resource == part).collect();
if cands.is_empty() {
println!(" {part:18} — not produced by assemble_ship");
continue;
}
let rel = |p: &sylpheed_formats::mesh::ScenePart| {
[p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
};
let best = cands
.iter()
.min_by(|a, b| {
let d = |p: &sylpheed_formats::mesh::ScenePart| {
let r = rel(p);
(0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
};
d(a).partial_cmp(&d(b)).unwrap()
})
.unwrap();
let r = rel(best);
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
let drm = (0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
.fold(0.0f32, f32::max);
worst_t = worst_t.max(dtm);
worst_r = worst_r.max(drm);
let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
println!(
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
r[0], r[1], r[2]
);
}
println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
scene.len(), samples.len());
}

View File

@@ -0,0 +1,48 @@
//! Do real index buffers address their whole vertex pool?
//!
//! `validate_block` rejects a block whose indices reach fewer than `vtx_count4`
//! vertices ("buffer not covered"). That gate is the furthest-reached rejection
//! for a handful of resources that never decode — so the question is whether it
//! is well founded. This measures the slack on every block that DOES decode: if
//! real geometry always covers its pool, under-coverage is good evidence of a
//! wrong candidate and the gate stands.
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut worst: Vec<(i64, String)> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
for sub in &m.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
let max_idx = *sub.indices.iter().max().unwrap() as i64;
let slack = sub.positions.len() as i64 - 1 - max_idx;
*hist.entry(slack.min(20)).or_default() += 1;
if slack > 4 {
worst.push((slack, format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy())));
}
}
}
}
println!("unreferenced tail vertices (vtx_count 1 max index), over decoded sub-meshes:");
for (slack, n) in &hist {
println!(" {:>3}{} : {n}", slack, if *slack == 20 { "+" } else { " " });
}
worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s));
for (s, w) in worst.iter().take(5) {
println!(" largest slack {s}: {w}");
}
}

View File

@@ -0,0 +1,80 @@
//! Calibrate the connectivity cap against the whole disc.
//!
//! `XBG7_EDGE_CAP` sets the cap; this reports, for one setting, how much
//! geometry decodes and how self-consistent it is across containers — the two
//! numbers any change to the cap has to trade off. Run it once per cap value.
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// name -> (verts, tris) -> spans seen, exactly as mesh_consistency_disc.rs.
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize)>> = BTreeMap::new();
let (mut models, mut verts) = (0usize, 0usize);
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
continue;
}
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
models += 1;
verts += v;
if std::env::var("DUMP").is_ok() {
// Per-resource signature, so two cap settings can be diffed:
// a cap change that silently MOVES an existing anchor is the
// risk a coverage count cannot see.
println!(
"{}|{}|{v}|{t}|{}|{}|{}|{}",
f.file_name().unwrap().to_string_lossy(),
m.name,
m.meshes[0].vbuf_offset.unwrap_or(0),
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64
);
}
seen.entry(m.name.clone()).or_default().push((
[
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64,
],
v,
t,
));
}
}
let (mut shared, mut inconsistent) = (0usize, 0usize);
for (_, list) in &seen {
if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
continue;
}
shared += 1;
if list.iter().any(|e| e.0 != list[0].0) {
inconsistent += 1;
}
}
println!(
"cap={} models={models} verts={verts} shared={shared} inconsistent={inconsistent}",
std::env::var("XBG7_EDGE_CAP").unwrap_or_else(|_| "library default".into())
);
}

View File

@@ -0,0 +1,106 @@
//! Does every part of a ship sit inside the envelope its siblings describe?
//!
//! `slab_screen` compared *scale* and could not see the `e106` slab. What the eye
//! used when the render exposed it was **relationship**: a blocky mass sitting
//! apart from the hull. This measures that — assemble a ship, and for each part
//! ask how far its world box protrudes beyond the box of all the OTHER parts,
//! relative to the ship's own size. A mis-anchored block sticks out; a real part,
//! however big, is part of the silhouette.
//!
//! Usage: envelope_screen <resource3d_dir> [protrusion_fraction]
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{assemble_ship, ship_id_of};
use std::collections::{BTreeSet, HashSet};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let limit: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(0.35);
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut flagged = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let ids: BTreeSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.iter()
.filter_map(|m| ship_id_of(&m.name).map(|s| s.to_string()))
.collect();
for id in &ids {
let placed = assemble_ship(&bytes, id, true);
if placed.len() < 3 {
continue;
}
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
// World box per placement.
let mut boxes: Vec<(String, [f32; 3], [f32; 3])> = Vec::new();
for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
let w = p.apply(*q);
for k in 0..3 {
lo[k] = lo[k].min(w[k]);
hi[k] = hi[k].max(w[k]);
}
}
}
if lo[0] != f32::MAX {
boxes.push((p.resource.clone(), lo, hi));
}
}
if boxes.len() < 3 {
continue;
}
for i in 0..boxes.len() {
// Envelope of every OTHER part.
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for (j, b) in boxes.iter().enumerate() {
if i == j {
continue;
}
for k in 0..3 {
lo[k] = lo[k].min(b.1[k]);
hi[k] = hi[k].max(b.2[k]);
}
}
// Per-AXIS: a bow legitimately extends the ship along its long
// axis, so protrusion only means something measured against the
// envelope's size IN THAT AXIS. The e106 slab stuck ~1 500 out in
// Y where its siblings spanned ~800.
let mut worst = 0.0f32;
let mut worst_out = 0.0f32;
for k in 0..3 {
let size_k = hi[k] - lo[k];
if size_k <= 1.0 {
continue;
}
let out_k = (lo[k] - boxes[i].1[k]).max(boxes[i].2[k] - hi[k]).max(0.0);
if out_k / size_k > worst {
worst = out_k / size_k;
worst_out = out_k;
}
}
let (out, size) = (worst_out, 1.0f32);
let _ = size;
if worst > limit {
flagged += 1;
println!(
"{:<20} {:<22} protrudes {:>7.0} beyond its siblings ({:.0}% of the ship)",
f.file_name().unwrap().to_string_lossy(),
boxes[i].0,
out,
100.0 * worst
);
}
}
}
}
println!("{flagged} parts protrude more than {:.0}% of their ship's size", 100.0 * limit);
}

View File

@@ -0,0 +1,35 @@
//! Does a FILTERED decode agree with the full one?
//!
//! Distinct anchor assignment resolves collisions against the set of resources
//! being decoded — so `models_named` (a filtered subset, used by the ship
//! assembler and the viewer) can reach a different answer from a whole-container
//! decode. This measures that directly.
//! Usage: filter_consistency <container.xpr> [resource...]
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let full = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
let names: Vec<String> = if a.len() > 2 {
a[2..].to_vec()
} else {
full.iter().map(|m| m.name.clone()).collect()
};
let mut differ = 0usize;
for n in &names {
let want: HashSet<String> = std::iter::once(n.clone()).collect();
let one = Xbg7Model::models_named(&bytes, &want, &|| false);
let (Some(f), Some(s)) = (full.iter().find(|m| &m.name == n), one.first()) else {
continue;
};
let off = |m: &Xbg7Model| m.meshes.first().and_then(|s| s.vbuf_offset).unwrap_or(0);
if off(f) != off(s) {
differ += 1;
if differ <= 10 {
println!("{n}: full decode at 0x{:x}, filtered at 0x{:x}", off(f), off(s));
}
}
}
println!("{differ} of {} resources decode differently when filtered", names.len());
}

View File

@@ -0,0 +1,27 @@
//! Where could a resource's index buffer be, given the vertex buffer a runtime
//! capture proves the engine drew from? Tests the anchor scan's adjacency
//! assumption against ground truth.
use sylpheed_formats::mesh::{debug_find_index_buffer, debug_resource_params};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
for pair in a[2..].iter() {
let (name, off) = pair.split_once('@').unwrap();
let vb = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
let params = debug_resource_params(&bytes, name);
let markers = params.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
println!("{name} @ 0x{vb:x} markers={markers:?}");
let hits = debug_find_index_buffer(&bytes, name, vb);
if hits.is_empty() {
println!(" no index buffer anywhere in the container validates this block");
}
let mut hits = hits;
hits.sort_by_key(|(_, d)| d.abs());
for (ib, d) in hits.iter().take(8) {
println!(" ib 0x{ib:x} vb-ib = {d} bytes");
}
if hits.len() > 8 {
println!("{} total", hits.len());
}
}
}

View File

@@ -0,0 +1,44 @@
//! Does the container hold an X-mirrored copy of a resource's decoded buffer?
//!
//! The twin invariant (see `twin_mirror_audit`) flags `…_01`/`…_02` pairs that
//! decode to unrelated geometry. If the mirror of one twin's buffer exists
//! somewhere else in the container, that offset is where the other twin belongs
//! and the pair is a mis-anchor; if it does not exist, the pair is simply not a
//! mirrored pair.
//!
//! Usage: find_mirror <container.xpr> <resource>...
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
for m in &models {
let pos: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).take(8).collect();
if pos.len() < 8 {
continue;
}
let anchored = m.meshes[0].vbuf_offset.unwrap_or(0);
let (mut direct, mut mirror) = (Vec::new(), Vec::new());
for o in (0..bytes.len().saturating_sub(12 + 8 * 24)).step_by(4) {
for (flip, out) in [(1.0f32, &mut direct), (-1.0f32, &mut mirror)] {
if (0..8).all(|k| {
let at = o + k * 24;
(be(at) - flip * pos[k][0]).abs() <= 1e-4
&& (be(at + 4) - pos[k][1]).abs() <= 1e-4
&& (be(at + 8) - pos[k][2]).abs() <= 1e-4
}) {
out.push(o);
}
}
}
println!(
"{:<20} anchored 0x{anchored:x} direct copies {:x?} mirrored copies {:x?}",
m.name, direct, mirror
);
}
}

View File

@@ -0,0 +1,60 @@
//! Which gate stops the resources that never decode?
//! Usage: gate_histogram <resource3d_dir> [max_resources]
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, HashSet};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let cap: usize = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut hist: BTreeMap<usize, (usize, String)> = BTreeMap::new();
let mut done = 0usize;
for f in &files {
if done >= cap {
break;
}
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let got: HashSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.into_iter()
.map(|m| m.name)
.collect();
for n in names.iter().filter(|n| !got.contains(*n)) {
if done >= cap {
break;
}
done += 1;
if let Some((rank, why)) = debug_best_rejection(&bytes, n) {
let e = hist.entry(rank).or_insert((0, String::new()));
e.0 += 1;
if e.1.is_empty() {
e.1 = format!("{n}: {why}");
}
}
}
}
println!("furthest gate reached, over {done} resources that never decode:");
let label = |r: usize| match r {
0 => "no gate reached",
1 => "index out of range",
2 => "buffer not covered by indices",
3 => "degenerate / implausible positions",
4 => "connectivity (mean edge / diagonal)",
5 => "winding consistency",
9 => "grouped pool (different path)",
_ => "?",
};
for (r, (n, ex)) in &hist {
println!(" {:<38} {n:>5} e.g. {ex}", label(*r));
}
}

View File

@@ -0,0 +1,156 @@
//! Invert the capture↔part match: instead of asking, per ship part, "is there a
//! draw with this vertex count?", ask of the **capture's** biggest draws "which
//! decoded resource in this stage container has that vertex count?".
//!
//! This is the diagnostic for the 2026-07-31 negative result (Stage_S02 capture,
//! zero parts correlated). It separates three hypotheses:
//! 1. LOD/variant vcount not covered by the correlator's variant list
//! → the big draws DO map to named resources, just not to the `_m`/`_l`/`_d`
//! set the correlator tries;
//! 2. position validation over-rejects
//! → the vcounts match the very parts we asked for (so the vcount key was
//! fine and the rejection happened later);
//! 3. a different draw path (instanced/batched/merged buffers)
//! → the big draws match NO resource in the container at all.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example invert_capture -- \
//! <capture.log> <Stage_SNN> [top_n] [--all]
//! `--all` lists every capture vcount, not just the `top_n` (default 40) largest.
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use sylpheed_formats::xiso::open_iso;
use std::collections::{HashMap, HashSet};
use std::path::Path;
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
let all = args.iter().any(|a| a == "--all");
if positional.len() < 2 {
eprintln!("usage: invert_capture <capture.log> <Stage_SNN> [top_n] [--all]");
std::process::exit(2);
}
let (log, stage) = (positional[0], positional[1]);
let top_n: usize = positional.get(2).and_then(|s| s.parse().ok()).unwrap_or(40);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
println!("parsed {} draws (draw-logger format)", draws.len());
} else {
println!("parsed {} draws (F10 capture format)", draws.len());
}
// Decode EVERY geometry resource in the stage container, not just one ship's.
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
println!("{stage}.xpr: {} XBG7 resources", names.len());
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("decoded {} models", models.len());
// vcount -> resource names with that many vertices.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(v as u32).or_default().push(m.name.clone());
}
// Per-submesh counts too: a draw may be one sub-mesh of a multi-mesh resource.
let mut by_sub_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
for (i, s) in m.meshes.iter().enumerate() {
if m.meshes.len() > 1 {
by_sub_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{}#{i}", m.name));
}
}
}
// Capture vcounts, de-duped by (vbase, vcount) so a re-drawn part counts once
// per distinct buffer.
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
let matched_draws: usize = draws
.iter()
.filter(|d| by_vcount.contains_key(&d.vcount) || by_sub_vcount.contains_key(&d.vcount))
.count();
println!(
"\n{} distinct vcounts; {}/{} draws have a vcount present in {stage}.xpr ({:.1}%)",
vcounts.len(),
matched_draws,
draws.len(),
100.0 * matched_draws as f64 / draws.len().max(1) as f64
);
let shown = if all { vcounts.len() } else { top_n.min(vcounts.len()) };
println!("\nlargest capture vcounts (draws / distinct vbufs) → matching resources:");
for &v in vcounts.iter().take(shown) {
let n = draw_count[&v];
let b = bufs[&v].len();
let mut hit: Vec<String> = by_vcount.get(&v).cloned().unwrap_or_default();
let sub: Vec<String> = by_sub_vcount.get(&v).cloned().unwrap_or_default();
hit.extend(sub.into_iter().map(|s| format!("{s} (sub)")));
let label = if hit.is_empty() {
"— no resource".to_string()
} else {
let mut h = hit.clone();
h.sort();
h.truncate(6);
format!("{}{}", h.join(", "), if hit.len() > 6 { ", …" } else { "" })
};
println!(" vcount {v:6} draws {n:4} bufs {b:3} {label}");
}
// `--ship <id>`: every resource of one ship family, with its vertex count and
// whether the capture drew it — this is what shows an all-`_l` (far-LOD) frame.
if let Some(i) = args.iter().position(|a| a == "--ship") {
if let Some(id) = args.get(i + 1) {
let mut rows: Vec<(String, u32, usize)> = models
.iter()
.filter(|m| m.name.contains(id.as_str()))
.map(|m| {
let v = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
(m.name.clone(), v, draw_count.get(&v).copied().unwrap_or(0))
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
let drawn = rows.iter().filter(|r| r.2 > 0).count();
println!("\n{id} resources in {stage}.xpr ({drawn}/{} with a drawn vcount):", rows.len());
for (name, v, n) in rows {
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "".into() });
}
}
}
// The other direction, for orientation: the container's biggest resources and
// whether the capture ever drew that many vertices.
let mut sizes: Vec<(u32, String)> = models
.iter()
.map(|m| (m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32, m.name.clone()))
.collect();
sizes.sort_unstable_by(|a, b| b.0.cmp(&a.0));
println!("\nlargest resources in {stage}.xpr → drawn in the capture?");
for (v, name) in sizes.iter().take(top_n.min(sizes.len())) {
let n = draw_count.get(v).copied().unwrap_or(0);
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "not drawn".into() });
}
}

View File

@@ -0,0 +1,90 @@
//! Where does each declared field sit inside the engine's live definition object?
//!
//! A live object carries no names, so the mapping has to be inferred: take a unit
//! whose disc record SETS a field, look for that value in the dumped words of the
//! matching live object, and keep the offsets that agree across several units.
//!
//! Run: live_offsets <disc-root> <live-dump.txt> <ID=0xVA> ...
//! e.g. … UN_f106_TCAF_Destroyer=0xbd3ee300 UN_f105_TCAF_Cruiser=0xbd40e200
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().expect("disc root");
let dump = a.next().expect("live dump");
let pairs: Vec<(String, String)> = a
.filter_map(|s| s.split_once('=').map(|(i, v)| (i.to_string(), v.to_string())))
.collect();
// live: va -> offset -> f32
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in std::fs::read_to_string(&dump).expect("dump").lines() {
if let Some(rest) = line.strip_prefix("=== ") {
cur = rest.trim().to_string();
continue;
}
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() >= 4 && f[1].starts_with('+') {
if let (Ok(off), Ok(val)) =
(usize::from_str_radix(&f[1][1..], 16), f[3].parse::<f32>())
{
live.entry(cur.clone()).or_default().insert(off, val);
}
}
}
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
// key -> offset -> how many units agree
let mut votes: BTreeMap<String, BTreeMap<usize, usize>> = BTreeMap::new();
let mut units = 0usize;
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(id) = obj.get_raw("ID") else { continue };
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
let Some(words) = live.get(va) else { continue };
units += 1;
let mut numeric = 0usize;
let mut hit = 0usize;
for key in obj.tokens() {
let Some(v) = obj.get_f32(key) else { continue };
if !v.is_finite() || v == 0.0 {
continue; // zero matches everywhere and says nothing
}
numeric += 1;
let mut found = false;
for (off, w) in words {
if (*w - v).abs() <= v.abs() * 1e-6 {
*votes.entry(key.clone()).or_default().entry(*off).or_default() += 1;
found = true;
}
}
if found {
hit += 1;
}
}
eprintln!(" {id}: {numeric} numeric fields set on disc, {hit} found in the dumped window");
}
println!("{units} units correlated against their live objects\n");
println!("{:<28} {:>8} offsets agreeing (votes)", "field", "unique?");
for (key, offs) in &votes {
let best = offs.iter().max_by_key(|(_, n)| **n).unwrap();
if *best.1 < units.max(2) {
continue; // needs every correlated unit to agree
}
let list: Vec<String> = offs
.iter()
.filter(|(_, n)| **n == *best.1)
.map(|(o, n)| format!("+{o:03x}×{n}"))
.collect();
println!(
"{:<28} {:>8} {}",
key,
if list.len() == 1 { "unique" } else { "ambig" },
list.join(" ")
);
}
}

View File

@@ -0,0 +1,53 @@
//! How many copies of a captured buffer does a container hold?
//!
//! The twins showed that two resources decoding to one buffer can mean the
//! container really holds two (mirrored) copies and our scan found only one.
//! This asks that question for any draw: give it a `vbase`, and it reports every
//! offset whose leading vertices match the draw's dumped positions — directly, and
//! X-mirrored.
//!
//! Usage: locate_draw <container.xpr> <capture.log> <vbase-hex>...
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let text = std::fs::read_to_string(&a[2]).expect("log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
for want in &a[3..] {
let vb = u32::from_str_radix(want.trim_start_matches("0x"), 16).expect("hex vbase");
let Some(d) = draws.iter().find(|d| d.vbase == vb) else {
println!("vbase 0x{vb:08X}: not in this log");
continue;
};
let n = d.pos.len().min(8);
let mut direct = Vec::new();
let mut mirror = Vec::new();
for o in (0..bytes.len().saturating_sub(12 + 64 * 24)).step_by(4) {
for (flip, out) in [(1.0f32, &mut direct), (-1.0f32, &mut mirror)] {
let hit = (0..n).all(|k| {
let at = o + k * 24;
(be(at) - flip * d.pos[k][0]).abs() <= 1e-4
&& (be(at + 4) - d.pos[k][1]).abs() <= 1e-4
&& (be(at + 8) - d.pos[k][2]).abs() <= 1e-4
});
if hit {
out.push(o);
}
}
}
println!(
"vbase 0x{vb:08X} vcount={}: {} direct copy/copies {:x?}, {} mirrored {:x?}",
d.vcount,
direct.len(),
&direct[..],
mirror.len(),
&mirror[..]
);
}
}

View File

@@ -0,0 +1,20 @@
//! Dump a composite's scene nodes with their scale — the transform the static
//! assembler copies verbatim into a placement.
//! Usage: node_scale <container.xpr> <composite name>
use sylpheed_formats::mesh::scene_world_nodes;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
for n in scene_world_nodes(&bytes, &a[2]) {
// Row norms of `m`: a rotation has all three at 1.0; anything else is a
// scale baked into the matrix, which `s` does not show.
let norm = |r: [f32; 3]| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
println!(
"{:<26} s[{:7.3}{:7.3}{:7.3}] |m rows|[{:7.3}{:7.3}{:7.3}] t[{:9.1}{:9.1}{:9.1}]",
n.resource,
n.s[0], n.s[1], n.s[2],
norm(n.m[0]), norm(n.m[1]), norm(n.m[2]),
n.t[0], n.t[1], n.t[2]
);
}
}

View File

@@ -0,0 +1,29 @@
//! Raw token sequence around a key, for one record — the ground truth for
//! "is this field actually defaulted, or is our reader missing it?"
//! Run: pool_window <disc-root> <ID substring> <key>
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::args().nth(1).unwrap();
let want = std::env::args().nth(2).unwrap();
let key = std::env::args().nth(3).unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
if !id.contains(&want) { continue; }
let t = o.tokens();
println!("=== {id} get_f32({key}) = {:?}", o.get_f32(&key));
for (i, tok) in t.iter().enumerate() {
if tok == &key {
let lo = i.saturating_sub(6);
let hi = (i + 7).min(t.len());
for j in lo..hi {
println!(" [{j}]{} {:?}", if j == i { " <-- key" } else { " " }, t[j]);
}
println!();
}
}
}
}

View File

@@ -0,0 +1,111 @@
//! Dump a UI screen's full layout from one RATC bundle: the element declaration
//! table (what is drawn, in back-to-front order, with pivots and parent links)
//! plus the placement region that follows it (per element: a keyframe group of
//! scale / tint / X / Y).
//!
//! See docs/re/structures/ui-rat-layout.md. Run:
//! cargo run -p sylpheed-formats --example screen_layout -- <PAK> [0xHASH]
//! With no hash, the largest RATC entry in the pak is used.
use sylpheed_formats::pak::PakArchive;
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let mut args = std::env::args().skip(1);
let pak = args.next().expect("usage: screen_layout <pak> [0xHASH]");
let want = args.next();
let arc = PakArchive::open(&pak).expect("open pak");
let bytes = match want {
Some(h) if h.starts_with("0x") => {
let h = u32::from_str_radix(h.trim_start_matches("0x"), 16).expect("hash");
arc.read_by_hash(h).expect("entry").expect("decompress")
}
Some(name) => arc.read_by_name(&name).expect("entry present").expect("decompress"),
None => arc
.entries()
.iter()
.filter_map(|e| arc.read(e).ok())
.filter(|b| b.len() > 16 && &b[..4] == b"RATC")
.max_by_key(|b| b.len())
.expect("no RATC entry"),
};
assert_eq!(&bytes[..4], b"RATC", "not a RATC bundle");
let count = be32(&bytes, 0x14) as usize;
let mut names = Vec::with_capacity(count);
let mut meta = Vec::with_capacity(count);
for i in 0..count {
let e = &bytes[0x20 + i * 60..0x20 + (i + 1) * 60];
let name = String::from_utf8_lossy(&e[..28])
.trim_end_matches('\0')
.trim_end_matches(char::from(0))
.to_string();
let parent = be32(e, 32);
let kind = be32(e, 40);
let (px, py) = (be32(e, 48), be32(e, 52));
names.push(name);
meta.push((parent, kind, px, py));
}
// Placement region: groups of (u32 element index, u32 keyframe count) then
// `count` 40-byte keyframes; the X/Y sit 12 bytes into the scale/tint block.
let mut pos = 0x20 + count * 60;
// X/Y are SIGNED: off-screen animation starts are negative (e.g. -516).
let mut placements: Vec<Vec<(i32, i32, u32)>> = vec![Vec::new(); count];
for _ in 0..count {
if pos + 8 > bytes.len() { break }
let idx = be32(&bytes, pos) as usize;
let frames = be32(&bytes, pos + 4) as usize;
if idx >= count || frames == 0 || frames > 4096 { break }
let first = pos + 28; // header + lead-in, verified on the pause bundles
let mut group = Vec::with_capacity(frames);
for k in 0..frames {
let blk = first + k * 40;
if blk + 20 > bytes.len() { break }
group.push((be32(&bytes, blk + 12) as i32, be32(&bytes, blk + 16) as i32, be32(&bytes, blk + 20)));
}
placements[idx] = group;
pos = first + frames * 40 - 20;
}
println!("{} elements", count);
println!("{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} placement", "#", "element", "parent", "kind", "pivot", "kf");
for i in 0..count {
let (parent, kind, px, py) = meta[i];
let p = &placements[i];
// A group is an in → hold → out animation, so neither the first nor the
// last keyframe is where the element sits: report the MAX-DWELL one
// (longest gap to the next keyframe's time).
let shown = if p.is_empty() {
"".into()
} else if p.len() == 1 {
format!("({},{})", p[0].0, p[0].1)
} else {
let mut best = (0usize, 0u32);
for k in 0..p.len() - 1 {
let d = p[k + 1].2.saturating_sub(p[k].2);
if d > best.1 { best = (k, d) }
}
let r = p[best.0];
format!(
"rest ({},{}) t={}..{} [{}]",
r.0, r.1, r.2, p[best.0 + 1].2,
p.iter().map(|(x, y, t)| format!("{t}:{x},{y}")).collect::<Vec<_>>().join(" ")
)
};
println!(
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {}",
i,
names[i],
if parent == u32::MAX { "-".into() } else { parent.to_string() },
format!("{kind:#x}"),
format!("({px},{py})"),
p.len(),
shown
);
}
}

View File

@@ -0,0 +1,262 @@
//! Ask the runtime capture whether two resources that our decoder gives the
//! **same geometry** really are the same geometry.
//!
//! Our XBG7 anchor scan sometimes lands two different resource names on one
//! vertex buffer. Statics cannot separate "the container genuinely reuses a
//! buffer" from "the scan picked the wrong candidate" — but a capture can: the
//! engine uploads a buffer per resource and reuses one only 3.4 % of the time
//! (see docs/re/structures/xbg7-mesh.md), so a group of `k` resources our
//! decoder collapses onto one buffer should show up as `k` distinct `vbase`s
//! carrying that same vertex count and those same positions. Fewer means at
//! most one member of the group is really that geometry.
//!
//! Usage:
//! cargo run --release --example shared_vbase_check -- \
//! <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
use std::collections::{BTreeMap, BTreeSet};
/// Quantised position key — the logs print 4 decimals, so compare at that scale.
fn key(p: [f32; 3]) -> (i64, i64, i64) {
(
(p[0] as f64 * 1e4).round() as i64,
(p[1] as f64 * 1e4).round() as i64,
(p[2] as f64 * 1e4).round() as i64,
)
}
/// Where in the container does a captured buffer live? POSITION is `f32×3` big
/// endian at vertex offset 0, so a draw's dumped positions are a literal byte
/// pattern: find the first one, then confirm the next few at a fixed stride.
/// This turns a capture into ground truth for a resource we mis-anchored.
fn locate_run(bytes: &[u8], pos: &[[f32; 3]]) -> Vec<(usize, usize)> {
if pos.len() < 4 {
return Vec::new();
}
// The log prints 4 decimals, so match on value with the printing tolerance
// rather than on bytes.
let be = |b: &[u8], at: usize| f32::from_be_bytes(b[at..at + 4].try_into().unwrap());
let same = |b: &[u8], at: usize, p: [f32; 3]| {
at + 12 <= b.len() && (0..3).all(|c| (be(b, at + c * 4) - p[c]).abs() <= 1e-4)
};
let mut out = Vec::new();
for o in (0..bytes.len().saturating_sub(12)).step_by(4) {
if !same(bytes, o, pos[0]) {
continue;
}
for stride in (12..=64).step_by(4) {
if (1..4).all(|k| same(bytes, o + k * stride, pos[k])) {
out.push((o, stride));
break;
}
}
}
out
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: shared_vbase_check <Stage_SNN.xpr> <capture.log>...");
std::process::exit(2);
}
let bytes = std::fs::read(&args[1]).expect("read container");
// Every draw from every log, keyed by vertex count.
// Keep the logs apart: each is its own emulator run, so a `vbase` only
// means something within one log.
let mut logs: Vec<(String, Vec<CapturedDraw>)> = Vec::new();
for log in args[2..].iter().filter(|a| !a.starts_with("--")) {
let text = std::fs::read_to_string(log).expect("read log");
let mut d = parse_capture(&text);
if d.is_empty() {
d = parse_drawlog(&text);
}
eprintln!("{log}: {} draws", d.len());
logs.push((log.rsplit('/').next().unwrap_or(log).to_string(), d));
}
// `--map`: is a draw's guest `vbase` just the container file offset plus a
// constant? If the container is uploaded contiguously it is — and then a
// capture names the exact offset of every buffer the engine drew, which is
// ground truth the anchor scan currently has to guess at.
if args.iter().any(|a| a == "--map") {
for (log, draws) in &logs {
let mut seen: BTreeSet<u32> = BTreeSet::new();
let mut delta: BTreeMap<i64, usize> = BTreeMap::new();
let mut unfound = 0usize;
for d in draws {
if d.pos.len() < 8 || d.vcount < 20 || !seen.insert(d.vbase) {
continue;
}
let at = locate_run(&bytes, &d.pos);
if at.is_empty() {
unfound += 1;
continue;
}
for (o, _) in at {
*delta.entry(d.vbase as i64 - o as i64).or_default() += 1;
}
}
let mut top: Vec<_> = delta.iter().collect();
top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
println!("{log}: {} distinct vbases located, {unfound} not in this container", seen.len() - unfound);
for (d, n) in top.iter().take(5) {
println!(" vbase - offset = 0x{:X} ×{n}", d);
}
}
return;
}
// Decode the container and group resources by the exact geometry they got.
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
// `--truth <base>`: with the container's guest load address (from `--map`),
// every draw names a file offset. Print it against the offset our anchor
// scan chose for each resource — a direct read-out of what we got wrong.
if let Some(a) = args.iter().find_map(|a| a.strip_prefix("--truth=")) {
let base = u32::from_str_radix(a.trim_start_matches("0x"), 16).expect("base");
// Where the anchor scan actually put each sub-mesh — exact, from the
// decoder, not inferred by searching for its leading vertices (the same
// leading run occurs at several offsets in a container, so a search
// cannot tell where a resource was anchored).
let mut ours: BTreeMap<usize, Vec<(String, usize)>> = BTreeMap::new();
for m in &models {
for sub in &m.meshes {
if let Some(o) = sub.vbuf_offset {
ours.entry(o).or_default().push((m.name.clone(), sub.positions.len()));
}
}
}
if args.iter().any(|a| a == "--anchors") {
println!("{:<12} where our decode put each resource", "file offset");
for (o, v) in &ours {
for (n, c) in v {
println!("0x{o:<10x} {n} ({c} verts)");
}
}
return;
}
let mut drawn: BTreeMap<usize, u32> = BTreeMap::new();
for (_, draws) in &logs {
for d in draws {
let off = d.vbase.wrapping_sub(base) as usize;
if off < bytes.len() && d.vcount >= 20 {
drawn.insert(off, d.vcount);
}
}
}
// Which resources have the drawn vertex count, wherever we put them?
// Right size + wrong place is a different bug from never finding it.
let mut by_count: BTreeMap<usize, Vec<String>> = BTreeMap::new();
for m in &models {
let n: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_count.entry(n).or_default().push(m.name.clone());
}
// Is a capture-proven offset even a candidate the scan considers?
// Absent ⇒ the run scan misses it; present ⇒ selection picked another.
let starts: BTreeSet<usize> =
sylpheed_formats::mesh::debug_vertex_run_starts(&bytes, 24).into_iter().collect();
eprintln!("{} stride-24 candidate starts in this container", starts.len());
println!("{:<12} {:>7} {:>9} {:<44} our resources with that vcount", "file offset", "vcount", "candidate", "claimed by our decode");
for (off, vcount) in &drawn {
let who = ours
.get(off)
.map(|v| {
v.iter().map(|(n, c)| format!("{n}({c})")).collect::<Vec<_>>().join(", ")
})
.unwrap_or_else(|| "— NOBODY".into());
let same = by_count
.get(&(*vcount as usize))
.map(|v| v.join(", "))
.unwrap_or_else(|| "— none".into());
// Nearest resource we anchored at or before this offset — the
// likely owner of a buffer nobody claims.
let near = ours
.range(..=*off)
.next_back()
.map(|(o, v)| format!("{} @ -0x{:x}", v[0].0, off - o))
.unwrap_or_default();
let cand = if starts.contains(off) { "yes" } else { "NO" };
println!("0x{off:<10x} {vcount:>7} {cand:>9} {who:<44} {same:<34} {near}");
}
return;
}
let mut groups: BTreeMap<Vec<(i64, i64, i64)>, Vec<String>> = BTreeMap::new();
for m in &models {
let pos: Vec<(i64, i64, i64)> =
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).map(key).collect();
if pos.is_empty() {
continue;
}
groups.entry(pos).or_default().push(m.name.clone());
}
let shared: Vec<_> = groups.iter().filter(|(_, n)| n.len() > 1).collect();
eprintln!(
"{} models, {} distinct geometries, {} shared by >1 resource",
models.len(),
groups.len(),
shared.len()
);
for (pos, names) in shared {
let vcount = pos.len() as u32;
// A draw belongs to this geometry if every dumped position is one of
// the decoded ones (the log dumps at most the first 64).
let want: BTreeSet<(i64, i64, i64)> = pos.iter().copied().collect();
println!("\n{} ({vcount} verts, {} resources)", names.join(""), names.len());
for (log, draws) in &logs {
let hits: Vec<&CapturedDraw> = draws.iter().filter(|d| d.vcount == vcount).collect();
let all: BTreeSet<u32> = hits.iter().map(|d| d.vbase).collect();
let matching: Vec<&&CapturedDraw> = hits
.iter()
.filter(|d| !d.pos.is_empty() && d.pos.iter().all(|p| want.contains(&key(*p))))
.collect();
let ok: BTreeSet<u32> = matching.iter().map(|d| d.vbase).collect();
// A buffer we do NOT match may still be the mirrored twin: same
// geometry with x negated. That is the case our assembler papers
// over with `apply_twin_mirrors`.
let mirrored: BTreeSet<u32> = hits
.iter()
.filter(|d| !ok.contains(&d.vbase))
.filter(|d| {
!d.pos.is_empty()
&& d.pos.iter().all(|p| want.contains(&key([-p[0], p[1], p[2]])))
})
.map(|d| d.vbase)
.collect();
println!(
" {log:32} draws={:<5} vbases@vcount={:<3} ours={} mirrored={} other={}",
hits.len(),
all.len(),
ok.len(),
mirrored.len(),
all.len() - ok.len() - mirrored.len()
);
// Where does each captured buffer live in the container? One
// representative draw per vbase is enough.
let mut done: BTreeSet<u32> = BTreeSet::new();
for d in &hits {
if d.pos.len() < 8 || !done.insert(d.vbase) {
continue;
}
let kind = if ok.contains(&d.vbase) {
"ours"
} else if mirrored.contains(&d.vbase) {
"mirror"
} else {
"other"
};
let at = locate_run(&bytes, &d.pos);
let shown: Vec<String> =
at.iter().take(4).map(|(o, s)| format!("0x{o:x}/stride{s}")).collect();
println!(
" vbase=0x{:08X} [{kind:6}] in container at: {}",
d.vbase,
if shown.is_empty() { "NOT FOUND".into() } else { shown.join(" ") }
);
}
}
}
}

View File

@@ -0,0 +1,74 @@
//! Screen every ship family for a part that is wildly out of scale with its
//! siblings — the "slab" signature of a mis-anchored block.
//!
//! Rendering `e106` found such a part (`bdy_03`, 600×1600×998 beside parts of
//! ~250) that coverage, cross-container consistency, the capture oracle and the
//! twin invariant were all blind to. Eyeballing does not scale to 166 containers;
//! this does the same comparison numerically.
//!
//! Usage: slab_screen <resource3d_dir> [factor]
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let factor: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(4.0);
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut flagged = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let mut by_ship: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
if !is_base_part(&m.name) {
continue;
}
let Some(id) = ship_id_of(&m.name) else { continue };
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
continue;
}
// Compare the part's SMALLEST axis span, not its diagonal. A long
// thin part (an antenna, a 100 000-unit tether on `f002`) is
// legitimately huge in one axis and would swamp a diagonal test; a
// mis-anchored block is bulky in all three, which is what the e106
// slab looked like (600×1600×998 beside siblings of ~250).
let thin = (hi[0] - lo[0]).min(hi[1] - lo[1]).min(hi[2] - lo[2]);
by_ship.entry(id.to_string()).or_default().push((m.name.clone(), thin));
}
for (id, parts) in &by_ship {
if parts.len() < 3 {
continue; // no meaningful median
}
let mut d: Vec<f32> = parts.iter().map(|(_, x)| *x).collect();
d.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = d[d.len() / 2];
for (name, diag) in parts {
if *diag > median * factor {
flagged += 1;
println!(
"{:<22} {name:<22} min-axis {diag:>8.0} vs ship median {median:>8.0} ({:.1}×)",
f.file_name().unwrap().to_string_lossy(),
diag / median
);
}
}
}
}
println!("{flagged} parts flagged at {factor}× the ship median");
}

View File

@@ -0,0 +1,13 @@
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts};
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let (markers, stride) = debug_resource_params(&bytes, &a[2]).expect("resource");
println!("{}: stride={stride} markers={markers:?}", a[2]);
let starts = debug_vertex_run_starts(&bytes, stride);
println!("{} candidate starts at stride {stride}", starts.len());
for off in &a[3..] {
let o = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
println!(" 0x{o:x} in candidate list: {}", starts.binary_search(&o).is_ok());
}
}

View File

@@ -0,0 +1,34 @@
//! Per-sub-mesh vertex/index/coverage dump for one resource.
//! Usage: submesh_dump <container.xpr> <resource>...
use sylpheed_formats::mesh::{debug_resource_params, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let want: HashSet<String> = a[2..].iter().cloned().collect();
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
let markers = debug_resource_params(&bytes, &m.name).map(|(mk, _)| mk).unwrap_or_default();
println!("{}{} sub-meshes decoded, {} markers declared", m.name, m.meshes.len(), markers.len());
for (i, s) in m.meshes.iter().enumerate() {
let max_idx = s.indices.iter().max().copied().unwrap_or(0) as usize;
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
println!(
" #{i:<2} at 0x{:<9x} verts {:<6} idx {:<6} max_idx {:<6} slack {} span [{:.0} {:.0} {:.0}]",
s.vbuf_offset.unwrap_or(0),
s.positions.len(),
s.indices.len(),
max_idx,
s.positions.len() as i64 - 1 - max_idx as i64,
hi[0] - lo[0],
hi[1] - lo[1],
hi[2] - lo[2]
);
}
}
}

View File

@@ -0,0 +1,17 @@
//! Does the capture-proven offset validate for the resource that should own it?
use sylpheed_formats::mesh::debug_try_anchor;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
// The production scan tries pads 0..=3; pass a bigger one to ask whether the
// block would validate at all with a wider index/vertex gap.
let max_pad: usize = std::env::var("MAX_PAD").ok().and_then(|v| v.parse().ok()).unwrap_or(3);
for pair in a[2..].iter() {
let (name, off) = pair.split_once('@').unwrap();
let off = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
match debug_try_anchor(&bytes, name, off, max_pad) {
Some((v, i, pad)) => println!("{name:22} @ 0x{off:x} ACCEPTED v={v} idx={i} pad={pad}"),
None => println!("{name:22} @ 0x{off:x} rejected"),
}
}
}

View File

@@ -0,0 +1,119 @@
//! Do port/starboard twins decode to mirror images of each other?
//!
//! A runtime capture proved the container stores both halves of the `e106` hull
//! as separate, X-reflected buffers. That gives a **capture-free invariant**:
//! a `…_01`/`…_02` pair of equal vertex count should decode to geometry that is
//! an exact X-mirror — never to identical geometry (that is the collapse the
//! distinct-assignment fix targets), and never to something unrelated (that is a
//! mis-anchor no count-based metric can see).
//!
//! Usage: twin_mirror_audit <resource3d_dir>
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn key(p: [f32; 3]) -> (i64, i64, i64) {
(
(p[0] * 1e3).round() as i64,
(p[1] * 1e3).round() as i64,
(p[2] * 1e3).round() as i64,
)
}
/// Elementwise equality with a tolerance. Truncating keys is too strict for a
/// mirrored pair: the halves are authored, not bit-negated, so they differ in
/// the last digits and an exact key test reports them as unrelated.
fn near(p: [f32; 3], q: [f32; 3]) -> bool {
(0..3).all(|c| (p[c] - q[c]).abs() <= 1e-3 * (1.0 + q[c].abs()))
}
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut same, mut mirrored, mut unrelated, mut related) = (0usize, 0usize, 0usize, 0usize);
let mut examples: Vec<String> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
let by_name: BTreeMap<&str, &Xbg7Model> =
models.iter().map(|m| (m.name.as_str(), m)).collect();
for m in &models {
let Some(stem) = m.name.strip_suffix("_01") else { continue };
let Some(t) = by_name.get(format!("{stem}_02").as_str()) else { continue };
let a: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).collect();
let b: Vec<[f32; 3]> = t.meshes.iter().flat_map(|s| s.positions.clone()).collect();
if a.len() != b.len() || a.is_empty() {
continue;
}
let ident = a.iter().zip(&b).all(|(p, q)| near(*p, *q));
let mirr = a.iter().zip(&b).all(|(p, q)| near([-p[0], p[1], p[2]], *q));
// A pair that is neither may still be RELATED: mirrored on another
// axis, or the same point cloud in a different vertex order. Only a
// pair that is none of these is evidence of a mis-anchor.
let mirr_y = a.iter().zip(&b).all(|(p, q)| near([p[0], -p[1], p[2]], *q));
let mirr_z = a.iter().zip(&b).all(|(p, q)| near([p[0], p[1], -p[2]], *q));
let set = |v: &Vec<[f32; 3]>| {
let mut s: Vec<_> = v.iter().map(|p| key(*p)).collect();
s.sort_unstable();
s
};
let same_cloud = set(&a) == set(&b);
// …and the same point cloud after mirroring, for a pair whose
// halves are authored in different vertex order.
let mirrored_cloud = {
let am: Vec<[f32; 3]> = a.iter().map(|p| [-p[0], p[1], p[2]]).collect();
set(&am) == set(&b)
};
if !ident && !mirr && (mirr_y || mirr_z || same_cloud || mirrored_cloud) {
related += 1;
continue;
}
// Identical GEOMETRY is only a collapse if it also comes from the
// same buffer: a container may store a part twice unmirrored, and
// then two twins on two copies decode identically and correctly.
let shared_buffer = m.meshes[0].vbuf_offset.is_some()
&& m.meshes[0].vbuf_offset == t.meshes[0].vbuf_offset;
if ident && !shared_buffer {
related += 1;
continue;
}
if ident {
same += 1;
if examples.len() < 6 {
examples.push(format!(
"identical: {} / {}_02 in {}",
m.name,
stem,
f.file_name().unwrap().to_string_lossy()
));
}
} else if mirr {
mirrored += 1;
} else {
unrelated += 1;
if examples.len() < 6 {
examples.push(format!(
"unrelated: {} / {}_02 in {}",
m.name,
stem,
f.file_name().unwrap().to_string_lossy()
));
}
}
}
}
println!("twin pairs of equal vertex count: {}", same + mirrored + unrelated + related);
println!(" exact X-mirror (expected) : {mirrored}");
println!(" IDENTICAL, one buffer (collapse) : {same}");
println!(" related other way (Y/Z mirror, reordered): {related}");
println!(" unrelated (mis-anchor?) : {unrelated}");
for e in examples {
println!(" e.g. {e}");
}
}

View File

@@ -0,0 +1,56 @@
//! Which XBG7 resources never decode, and how big is that population?
//!
//! Coverage has been reported as "resources decoded" without a denominator. This
//! prints both, per container and in total, and names the misses so the gate
//! attribution (`why_rejected`) has a work list.
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use std::collections::HashSet;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let show = std::env::args().nth(2).is_some();
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let (mut total, mut decoded, mut no_decl) = (0usize, 0usize, 0usize);
let mut misses: Vec<String> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let got: HashSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.into_iter()
.map(|m| m.name)
.collect();
for n in &names {
total += 1;
if got.contains(n) {
decoded += 1;
} else if debug_resource_params(&bytes, n).is_none() {
// No vertex declaration / no index markers: the anchor scan
// never even considers these, so they are a different question
// from "searched and not found".
no_decl += 1;
} else {
misses.push(format!("{}|{n}", f.file_name().unwrap().to_string_lossy()));
}
}
}
println!(
"XBG7 resources: {total} total, {decoded} decoded ({:.1}%), {no_decl} without a usable descriptor, {} searched-and-missed",
100.0 * decoded as f64 / total as f64,
misses.len()
);
if show {
for m in &misses {
println!("{m}");
}
}
}

View File

@@ -0,0 +1,31 @@
//! Every key/value a named unit record actually sets on disc.
//!
//! The live definition object in guest RAM has no field names; to find where a
//! field like `HQRatio` sits inside it, anchor on a unit whose disc record DOES
//! set that field and look for the value. This prints those anchors.
//! Run: unit_fields <disc-root> <substring of ID>...
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::args().nth(1).expect("disc root");
let want: Vec<String> = std::env::args().skip(2).collect();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(id) = obj.get_raw("ID") else { continue };
if !want.iter().any(|w| id.contains(w.as_str())) {
continue;
}
println!("=== {id} (schema {:08x})", obj.schema_hash);
// The pool interleaves VALUE before KEY (see the module docs), so the
// pairs read (t[i] = value, t[i+1] = key).
let t = obj.tokens();
let mut i = 0;
while i + 1 < t.len() {
println!(" {:<30} {}", t[i + 1], t[i]);
i += 2;
}
}
}

View File

@@ -0,0 +1,33 @@
//! (HP, Size_X, Size_Y, Size_Z) for every craft unit and vessel on the disc.
//!
//! A live definition object in guest RAM carries HP at `+0x54` and the sizes at
//! `+0x30/34/38` but no name in its first words, so identity has to come from the
//! values themselves: this is the lookup table for that match.
//! Run: unit_signatures <disc-root>
use sylpheed_formats::game_data::{load_units, load_vessels};
use sylpheed_formats::PakArchive;
fn main() {
let disc = std::env::args().nth(1).expect("disc root");
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
println!("{:<34} {:>9} {:>8} {:>8} {:>8} kind", "id", "hp", "size_x", "size_y", "size_z");
for u in load_units(&pak) {
println!(
"{:<34} {:>9} {:>8} {:>8} {:>8} unit",
u.id.unwrap_or_default(),
u.hp.map(|v| v.to_string()).unwrap_or("".into()),
u.size_x.map(|v| v.to_string()).unwrap_or("".into()),
u.size_y.map(|v| v.to_string()).unwrap_or("".into()),
u.size_z.map(|v| v.to_string()).unwrap_or("".into()),
);
}
for v in load_vessels(&pak) {
println!(
"{:<34} {:>9} {:>8} {:>8} {:>8} vessel",
v.id.unwrap_or_default(),
v.hp.map(|x| x.to_string()).unwrap_or("".into()),
v.size_x.map(|x| x.to_string()).unwrap_or("".into()),
v.size_y.map(|x| x.to_string()).unwrap_or("".into()),
v.size_z.map(|x| x.to_string()).unwrap_or("".into()),
);
}
}

View File

@@ -0,0 +1,22 @@
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::args().nth(1).unwrap();
let want = std::env::args().nth(2).unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
if !id.contains(&want) { continue; }
println!("=== {id}");
let mut seen = std::collections::BTreeSet::new();
for k in o.tokens() {
if let Some(v) = o.get_f32(k) {
if v != 0.0 && seen.insert(k.clone()) {
println!(" {:<28} {:<12} 0x{:08x}", k, v, v.to_bits());
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
//! Global "which resource has N vertices?" index over every `.xpr` container in
//! an extracted `resource3d` directory, answered for the vcounts a capture log
//! actually drew.
//!
//! Companion to `invert_capture`: that one asks the question inside a single
//! stage container, this one asks it across ALL containers — so a draw whose
//! geometry lives in `Common.xpr`, a `rou_*` weapon pack or a `BG_*` backdrop is
//! still identified instead of coming back "no resource".
//!
//! Usage:
//! cargo run --release --example vcount_index -- <resource3d_dir> <capture.log> [top_n]
//! cargo run --release --example vcount_index -- <resource3d_dir> --vcounts 10891,6000
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{HashMap, HashSet};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vcount_index <resource3d_dir> <capture.log|--vcounts a,b,c> [top_n]");
std::process::exit(2);
}
let dir = &args[1];
// Which vertex counts are we asking about, and how often was each drawn?
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
if args[2] == "--vcounts" {
for v in args[3].split(',').filter_map(|s| s.trim().parse::<u32>().ok()) {
draw_count.insert(v, 0);
}
} else {
let text = std::fs::read_to_string(&args[2]).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
eprintln!("parsed {} draws", draws.len());
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
}
let top_n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
// Decode every container once; keep only the vcount → names mapping.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.expect("read dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "xpr"))
.collect();
files.sort();
let mut total_res = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let container = f.file_stem().unwrap().to_string_lossy().to_string();
for m in &models {
total_res += 1;
let whole: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(whole as u32).or_default().push(format!("{container}:{}", m.name));
if m.meshes.len() > 1 {
for (i, s) in m.meshes.iter().enumerate() {
by_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{container}:{}#{i}", m.name));
}
}
}
}
eprintln!("indexed {} resources from {} containers", total_res, files.len());
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
println!("\nvcount draws bufs resources anywhere in resource3d/");
for v in vcounts.into_iter().take(top_n) {
let n = draw_count[&v];
let b = bufs.get(&v).map(|s| s.len()).unwrap_or(0);
let hit = by_vcount.get(&v).cloned().unwrap_or_default();
let label = if hit.is_empty() {
"— NONE".to_string()
} else {
let mut h = hit.clone();
h.sort();
let shown = h.len().min(8);
format!("{}{}", h[..shown].join(", "), if h.len() > shown { format!(", … ({} total)", h.len()) } else { String::new() })
};
println!("{v:6} {n:5} {b:4} {label}");
}
}

View File

@@ -0,0 +1,70 @@
//! Check the code-derived offset→field map against the live dump, then read out
//! the runtime value of every field the disc record leaves defaulted.
//! Run: verify_fieldmap <disc-root> <live-dump> <ID=0xVA>...
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
// Offsets read out of sub_82341A20's own key strings (stfs stores only).
// Offsets and names recovered from `sub_82341A20` itself: each key is built as
// `addi r4, r30, -N` (r30 = 0x82088f94, so the name is a string in the image),
// and the value lands in the first store into the object after the accessor
// call. 125 float-typed fields.
const MAP: &[(usize, &str)] = &[(48, "Size_X"), (52, "Size_Y"), (56, "Size_Z"), (64, "Color_R"), (68, "Color_G"), (72, "Color_B"), (80, "Size_Radius"), (84, "HP"), (88, "HQRatio"), (92, "ShieldRatio"), (96, "ThrusterRatio"), (116, "ResistanceToOptics"), (120, "ResistanceToShell"), (124, "ResistanceToExplosion"), (128, "ResistanceToPlayer"), (132, "ResistanceParalyze"), (156, "MinimumVelocity"), (160, "MaximumVelocity"), (164, "CruisingVelocity"), (168, "Acceleration"), (172, "Deceleration"), (176, "AV_PitchPlus_Max"), (180, "AV_PitchPlus_Min"), (184, "AA_PitchPlus_Max"), (188, "AA_PitchPlus_Min"), (192, "AV_PitchMinus_Max"), (196, "AV_PitchMinus_Min"), (200, "AA_PitchMinus_Max"), (204, "AA_PitchMinus_Min"), (208, "AV_Yaw_Max"), (212, "AV_Yaw_Min"), (216, "AA_Yaw_Max"), (220, "AA_Yaw_Min"), (224, "AV_Roll_Max"), (228, "AV_Roll_Min"), (232, "AA_Roll_Max"), (236, "AA_Roll_Min"), (248, "SideThrustVelocity_Max"), (252, "SideThrustAcceleration"), (256, "MaximumBank_Normal"), (260, "YawDragFactor"), (264, "PitchDragFactor"), (268, "RollDragFactor"), (272, "DragFactorThreshold"), (276, "ArterBurner_Vc"), (280, "ReverseThrust_Vc"), (284, "ArterBurner_Acc"), (288, "ReverseThrust_Acc"), (292, "AccPitchFactor"), (296, "DecPitchFactor"), (300, "AV_AxisMode_Max"), (304, "AV_AxisMode_Min"), (308, "AA_AxisMode_Max"), (312, "AA_AxisMode_Min"), (316, "PowerCutConsumeShield"), (320, "PowerCutDeceleration"), (324, "AB_ConsumeShield_Begin"), (328, "AB_ConsumeShield"), (332, "AB_AV_PitchPlus"), (336, "AB_AA_PitchPlus"), (340, "AB_AV_PitchMinus"), (344, "AB_AA_PitchMinus"), (348, "AB_AV_Yaw"), (352, "AB_AA_Yaw"), (356, "AB_AV_Roll"), (360, "AB_AA_Roll"), (368, "SideRoll_Time"), (372, "SideRoll_Length"), (380, "BarrelRoll_CountMinimum"), (384, "BarrelRoll_CountMaximum"), (388, "BarrelRoll_Time"), (392, "BarrelRoll_Radius"), (400, "TurnAttack_CutoffRatio"), (404, "TurnAttack_DoubleRatio"), (408, "CutoffTimeMin"), (412, "CutoffTimeMax"), (416, "TurnAttack_DoubleTimeMin"), (420, "TurnAttack_DoubleTimeMax"), (428, "Turn_AngularVelocity"), (432, "TurnAway_Time_Minimum"), (436, "TurnAway_Time_Maximum"), (444, "BoostAway_Time_Minimum"), (448, "BoostAway_Time_Maximum"), (456, "HoldPosition_LengthMin"), (460, "HoldPosition_LengthMax"), (464, "HoldPosition_MinimumTime"), (468, "HoldPosition_MaximumTime"), (472, "HoldPosition_SideRatio"), (476, "HoldPosition_BackRatio"), (480, "HoldPosition_CutoffRatio"), (484, "HoldPosition_CancelTime"), (492, "Slalom_CutoffRatio"), (496, "Slalom_TurnCount_Min"), (500, "Slalom_TurnCount_Max"), (508, "Through_CutoffRatio"), (512, "Through_AngleMinimum"), (516, "Through_AngleMaximum"), (520, "Through_Time1Max"), (524, "Through_Time1Min"), (528, "Through_Time2Max"), (532, "Through_Time2Min"), (536, "Through_LengthMinimum"), (540, "Through_LengthMaximum"), (548, "SolidCutoff_Ratio"), (552, "SolidCutoff_LengthMin"), (556, "SolidCutoff_LengthMax"), (560, "HomingResistAdjustment"), (564, "UsingChaffRatio"), (568, "MaxValue"), (572, "ChargeDelay"), (576, "ChargeDelay_Break"), (580, "ChargeSpeed"), (584, "Delay"), (588, "DelayAdjustment"), (624, "DestroyMotionTime"), (628, "DryMass"), (632, "GrossMass"), (664, "LowerHPThresholdRatio"), (668, "SELength"), (672, "RadarRange"), (676, "FCSRange"), (680, "FiringRange"), (692, "AttackVesselPoint"), (696, "AttackCraftPoint"), (700, "DefencePoint")];
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().unwrap();
let dump = a.next().unwrap();
let pairs: Vec<(String, String)> =
a.filter_map(|s| s.split_once('=').map(|(i, v)| (i.into(), v.into()))).collect();
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in std::fs::read_to_string(&dump).unwrap().lines() {
if let Some(r) = line.strip_prefix("=== ") { cur = r.trim().into(); continue; }
let f: Vec<&str> = line.split_whitespace().collect();
// dump columns: addr +off hex u32 f32 -- the float is f[4], not f[3]
if f.len() >= 5 && f[1].starts_with('+') {
if let (Ok(o), Ok(v)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>()) {
live.entry(cur.clone()).or_default().insert(o, v);
}
}
}
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let (mut agree, mut disagree) = (0, 0);
let mut defaults: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
let Some(w) = live.get(va) else { continue };
for (off, key) in MAP {
let Some(got) = w.get(off) else { continue };
match o.get_f32(key) {
Some(want) => {
// Angle fields are stored in RADIANS at runtime and degrees on
// disc, so a match is either the raw value or its conversion.
let rad = want * std::f32::consts::PI / 180.0;
let ok = (want - got).abs() <= want.abs() * 1e-4
|| (rad - got).abs() <= rad.abs() * 1e-4;
if ok { agree += 1 } else {
disagree += 1;
println!(" MISMATCH {id} {key}: disc {want}, live +{off} = {got}");
}
}
None => defaults.entry((*key).into()).or_default().push((id.into(), *got)),
}
}
}
println!("\nmap check: {agree} fields agree with the disc, {disagree} disagree\n");
println!("runtime value where the disc record DEFAULTS the field:");
for (key, hits) in &defaults {
let vals: Vec<String> = hits.iter().map(|(_, v)| format!("{v}")).collect();
let uniq: std::collections::BTreeSet<&String> = vals.iter().collect();
println!(" {:<22} {:<28} ({} units)", key,
uniq.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "), hits.len());
}
}

View File

@@ -0,0 +1,14 @@
//! Why does the decoder refuse a grouped-pool resource at a given pool start?
//! Usage: why_rejected <container.xpr> <resource>@<vb0-hex>...
use sylpheed_formats::mesh::debug_grouped_report;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
for pair in &a[2..] {
let (name, off) = pair.split_once('@').expect("name@hex");
let vb0 = usize::from_str_radix(off.trim_start_matches("0x"), 16).expect("hex");
for line in debug_grouped_report(&bytes, name, vb0) {
println!("{line}");
}
}
}

View File

@@ -68,6 +68,10 @@ pub mod localization;
// Whole-ship assembly from XBG7 part families (capital ships as split parts).
pub mod ship;
/// The runtime layout of a unit / vessel definition object, read out of the
/// title's loader and verified against a live mission (see the module docs).
pub mod unit_layout;
// Exact capital-ship placement from a runtime F10 ship-capture (ground truth).
pub mod ship_capture;

View File

@@ -1,9 +1,18 @@
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad)
//! frames concatenated back-to-back.
//! `LSTA` — a display list: a header followed by N inline elements concatenated
//! back-to-back, each either a [`T8aD`](crate::t8ad) sprite or a `PRMD`
//! primitive (a flat coloured quad — the same primitive the UI bundles use to
//! dim a scene).
//!
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame
//! from its slice up to the next frame (or end).
//! The `count` at `@0x04` is **exact, and counts both kinds**: across all 64
//! lists on the disc, `count == T8aD frames + PRMD primitives` with no
//! exceptions (measured 2026-08-11). An earlier note here said "a few entries
//! disagree with the actual frame count" — they do not; that comparison was
//! counting sprites against a total that includes primitives.
//!
//! [`parse`] walks by magic and returns the **sprites**, deliberately skipping
//! `PRMD` entries, so its result length is `count` only for lists that hold no
//! primitives. Six lists do (in `GP_DEBRIEFING_PILOTLOG`, `GP_MISSION_SELECT`),
//! each with exactly one.
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
@@ -15,8 +24,9 @@ pub fn is_lsta(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
}
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD
/// variant) are skipped. Returns `None` only for non-LSTA input.
/// Decode the inline T8aD sprites, skipping `PRMD` primitives. Returns `None`
/// only for non-LSTA input. Every one of the 1 281 sprite frames on the disc
/// decodes (measured 2026-08-11, after the T8aD rectangle-list fix).
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
if !is_lsta(bytes) {
return None;
@@ -48,12 +58,21 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
mod tests {
use super::*;
/// A faithful one-rectangle T8aD frame: base header, a 1-entry offset table,
/// then the rectangle header (dst 0,0, size w×h) and its pixels. (Before
/// 2026-08-11 this fixture wrote no offset-table entry at all and the decoder
/// read "pixels" from inside the header — the test only ever checked the
/// dimensions, so it passed anyway.)
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
let mut b = vec![0u8; 64];
let mut b = vec![0u8; 0x2c];
b[0..4].copy_from_slice(&T8AD_MAGIC);
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table → rect at 0x30
for v in [0u32, 0, w, h] {
b.extend_from_slice(&v.to_be_bytes()); // dst X, dst Y, width, height
}
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
b
}

View File

@@ -115,6 +115,12 @@ pub struct GameMesh {
pub indices: Vec<u32>,
/// Sub-mesh / node name from the descriptor, when available.
pub name: Option<String>,
/// Byte offset of this sub-mesh's vertex buffer inside the container, when
/// the decode path knows it. The content-anchored stage path does — and a
/// runtime capture names the same offset (a draw's `vbase` is this plus the
/// container's load address), so this is what makes an anchor checkable
/// against ground truth. See `examples/shared_vbase_check.rs`.
pub vbuf_offset: Option<usize>,
}
/// A model = the set of sub-meshes recovered from one XPR2 container's first
@@ -376,6 +382,7 @@ impl Xbg7Model {
uvs,
indices,
name: None,
vbuf_offset: Some(vb),
});
off = align16(ve) - base;
}
@@ -456,11 +463,32 @@ impl Xbg7Model {
Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None)
}
/// Decode the whole container (cached), then hand back the requested subset.
///
/// The decode itself must always see every resource — distinct assignment
/// resolves collisions against the whole population, and pruning first made
/// the answer depend on the request (see the module history). That makes a
/// single-resource query as expensive as a full decode, so the full decode is
/// memoised per container: the viewer asks for one ship's parts at a time and
/// would otherwise re-anchor 6 000 resources per ship.
fn anchor_models_filtered(
bytes: &[u8],
min_consistency: f32,
should_cancel: &(dyn Fn() -> bool + Sync),
wanted: Option<&std::collections::HashSet<String>>,
) -> Vec<Xbg7Model> {
let Some(w) = wanted else {
return Self::anchor_models_uncached(bytes, min_consistency, should_cancel, None);
};
let full = full_decode_cached(bytes, min_consistency, should_cancel);
full.iter().filter(|m| w.contains(&m.name)).cloned().collect()
}
fn anchor_models_uncached(
bytes: &[u8],
min_consistency: f32,
should_cancel: &(dyn Fn() -> bool + Sync),
wanted: Option<&std::collections::HashSet<String>>,
) -> Vec<Xbg7Model> {
let mut out = Vec::new();
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
@@ -516,11 +544,13 @@ impl Xbg7Model {
}
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
if let Some(w) = wanted {
if !w.contains(&name) {
continue;
}
}
// NOTE: `wanted` is NOT applied here. Distinct assignment resolves
// collisions against the whole set of resources, so pruning first
// made a filtered decode depend on *which* subset was asked for —
// measured 2026-08-12: 27 of 356 resources in `Stage_S02` came out
// at a different offset when requested alone. The filter is applied
// to the OUTPUT instead, so a subset is always a subset of the
// container's own answer.
resources.push(Res {
name,
markers,
@@ -556,6 +586,7 @@ impl Xbg7Model {
// preserves resource order, so the output is identical to the sequential
// decode. `should_cancel()` is polled per resource so a superseded load
// stops promptly.
let empty_taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
let decode_one = |r: &Res| -> Option<Xbg7Model> {
if should_cancel() {
return None;
@@ -567,13 +598,23 @@ impl Xbg7Model {
// simple props take this path; `min_consistency` behaviour is
// exactly as before.
let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
.into_iter()
.collect()
anchor_pool_mesh(
bytes,
starts,
index_count,
vtx_count,
&r.decl,
min_consistency,
&empty_taken,
0,
)
.into_iter()
.collect()
} else {
// Several sub-meshes sharing grouped index/vertex pools → the
// deterministic grouped-pool decode (hero ships et al.).
let grouped = anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl);
let grouped =
anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl, &empty_taken);
if !grouped.is_empty() {
grouped
} else {
@@ -582,9 +623,18 @@ impl Xbg7Model {
// to the original single-block adjacency anchor on the first
// marker so coverage is never *below* the pre-grouped decode.
let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
.into_iter()
.collect()
anchor_pool_mesh(
bytes,
starts,
index_count,
vtx_count,
&r.decl,
min_consistency,
&empty_taken,
0,
)
.into_iter()
.collect()
}
};
(!meshes.is_empty()).then(|| Xbg7Model {
@@ -594,18 +644,585 @@ impl Xbg7Model {
};
#[cfg(not(target_arch = "wasm32"))]
{
let decoded: Vec<(usize, Xbg7Model)> = {
use rayon::prelude::*;
out = resources.par_iter().filter_map(decode_one).collect();
}
resources
.par_iter()
.enumerate()
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
.collect()
};
#[cfg(target_arch = "wasm32")]
{
out = resources.iter().filter_map(decode_one).collect();
let decoded: Vec<(usize, Xbg7Model)> = resources
.iter()
.enumerate()
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
.collect();
// ── Distinct assignment ──────────────────────────────────────────
//
// Selection above is per-resource and greedy: each takes the first
// candidate that validates, so two resources can claim ONE buffer while
// a valid buffer sits unused. A runtime capture proves that is wrong for
// the mirrored `e106_bdy_0{1,2}_l` twins — the container holds both
// halves (`0x3b3ee8` and its X-mirror `0x3c55d8`) and the engine draws
// each from its own — and both offsets validate for both names, so the
// correct block merely lost the first-match race.
//
// So: walk the decodes in container order, let the first claimant keep a
// buffer, and re-anchor any later resource that wanted the same one,
// skipping everything already claimed. Only single-sub-mesh resources
// (the adjacency-anchor path) take part; grouped-pool models are left
// exactly as they were.
let mut taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
// Opt-in monotone pass: the last offset handed to each (stride, vtx, idx)
// signature. Resources that share a signature are interchangeable to the
// validator — many containers hold dozens of identical 24-vertex bound
// boxes — so file order is the only thing that can pin which is which.
let monotone = std::env::var("XBG7_MONOTONE").is_ok();
let mut last_by_sig: std::collections::HashMap<(usize, usize, usize), usize> =
std::collections::HashMap::new();
let mut models: Vec<Xbg7Model> = Vec::with_capacity(decoded.len());
for (i, mut m) in decoded {
let r = &resources[i];
let starts = &starts_by_stride[&r.decl.stride];
if monotone && m.meshes.len() == 1 && r.markers.len() == 1 {
let (vc, ic) = r.markers[0];
let sig = (r.decl.stride, vc, ic);
let floor = last_by_sig.get(&sig).map_or(0, |o| o + 1);
if m.meshes[0].vbuf_offset.map_or(false, |o| o < floor) {
if let Some(alt) = anchor_pool_mesh(
bytes,
starts,
ic,
vc,
&r.decl,
min_consistency,
&taken,
floor,
) {
m.meshes[0] = alt;
}
}
if let Some(o) = m.meshes[0].vbuf_offset {
last_by_sig.insert(sig, o);
}
}
if let Some(vb) = m.meshes[0].vbuf_offset {
if taken.contains(&vb) {
if m.meshes.len() == 1 && r.markers.len() == 1 {
let (vtx_count, index_count) = r.markers[0];
if let Some(alt) = anchor_pool_mesh(
bytes,
starts,
index_count,
vtx_count,
&r.decl,
min_consistency,
&taken,
0,
) {
m.meshes[0] = alt;
}
} else {
// Grouped pool: re-place the WHOLE pool past everything
// claimed, or keep what we had.
let alt = anchor_grouped_meshes(
bytes, data_base, starts, &r.markers, &r.decl, &taken,
);
if !alt.is_empty() {
m.meshes = alt;
}
}
// No free candidate → keep the collided decode rather than
// drop the resource; coverage never regresses.
}
for sub in &m.meshes {
if let Some(vb2) = sub.vbuf_offset {
taken.insert(vb2);
}
}
}
models.push(m);
}
if let Some(w) = wanted {
models.retain(|m| w.contains(&m.name));
}
out = models;
out
}
}
/// Diagnostic: would the anchor scan accept `vb` as the vertex buffer of the
/// named resource's first sub-mesh? A runtime capture proves which offset the
/// engine drew from, so this answers whether the correct block is *acceptable*
/// and merely lost the first-match race, or is rejected outright by
/// `validate_block`. Returns `(vtx_count, index_count, accepted_pad)`.
pub fn debug_try_anchor(
bytes: &[u8],
name: &str,
vb: usize,
max_pad: usize,
) -> Option<(usize, usize, usize)> {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return None;
}
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur).ok()?;
const DIR_BASE: usize = 0x10;
for _ in 0..header.num_resources {
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
if &e.type_tag != b"XBG7" {
continue;
}
let desc = e.data_offset as usize + DIR_BASE;
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
if desc >= bytes.len() || desc_end <= desc {
continue;
}
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
if rname != name {
continue;
}
let d = &bytes[desc..desc_end];
let markers = all_index_markers(d);
let decl = parse_vertex_decl(d)?;
let (vtx_count, index_count) = *markers.first()?;
let idx_bytes = index_count * 2;
for pad in 0..=max_pad {
if vb < idx_bytes + pad {
continue;
}
let mc = if pad == 0 { 0.0 } else { 0.85 };
if validate_block(
bytes,
vb - idx_bytes - pad,
vb,
vtx_count,
index_count,
&decl,
mc,
true,
) {
return Some((vtx_count, index_count, pad));
}
}
return None;
}
None
}
/// How far did the anchor scan get for a resource it failed to place?
///
/// Runs the same candidate loop the decoder runs and keeps the **furthest**
/// rejection — the candidate that passed the most gates before failing. Over the
/// resources that never decode, the distribution of these says which gate to
/// work on, instead of tuning one threshold and re-measuring.
pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)> {
let (decl, markers) = decl_of(bytes, name)?;
let starts = debug_vertex_run_starts(bytes, decl.stride);
#[allow(clippy::type_complexity)]
let rank = |why: &str| -> usize {
if why.contains("out of range") {
1
} else if why.contains("buffer not covered") {
2
} else if why.contains("not finite") || why.contains("degenerate") {
3
} else if why.contains("connectivity") {
4
} else if why.contains("winding") {
5
} else {
0
}
};
// A grouped-pool resource is placed by its PIVOT sub-mesh, so it needs the
// grouped candidate loop; running the single-block loop on `markers[0]`
// would report a gate the decoder never consulted for it.
if markers.len() > 1 {
let n = markers.len();
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
for &(_, ic) in &markers {
rel_ib.push(acc_i);
acc_i = align4(acc_i + ic * 2);
}
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
let (vck, ick) = markers[kmax];
let off_v: usize = markers.iter().take(kmax).map(|&(vc, _)| vc * decl.stride).sum();
let mut best = (0usize, String::from("no pool start reached any gate"));
for &vb0 in &starts {
for pad in 0..=3usize {
if vb0 < span + pad {
continue;
}
let ib0 = vb0 - span - pad;
match validate_block_report(
bytes,
ib0 + rel_ib[kmax],
vb0 + off_v,
vck,
ick,
&decl,
0.85,
true,
) {
Ok(()) => return None, // the pivot would have anchored
Err(why) => {
let r = rank(&why);
if r > best.0 {
best = (r, why);
}
}
}
}
}
return Some(best);
}
let (vtx_count, index_count) = *markers.first()?;
let idx_bytes = index_count * 2;
let mut best = (0usize, String::from("no candidate reached any gate"));
for &vb in &starts {
for pad in 0..=3usize {
if vb < idx_bytes + pad {
continue;
}
// Mirror production exactly: the pad-0 path now carries the winding
// floor too. Reporting at 0.0 would accept blocks the decoder
// rejects and point at the wrong gate.
let mc = if pad == 0 { pad0_consistency() } else { 0.85 };
if let Err(why) = validate_block_report(
bytes,
vb - idx_bytes - pad,
vb,
vtx_count,
index_count,
&decl,
mc,
true,
) {
let r = rank(&why);
if r > best.0 {
best = (r, why);
}
} else {
return None; // it would have decoded — not a miss
}
}
}
Some(best)
}
/// Diagnostic: why does the decoder refuse a grouped-pool resource at a given
/// pool start? Recomputes the pool layout exactly as [`anchor_grouped_meshes`]
/// does and reports the pivot sub-mesh's verdict for each index/vertex pad —
/// so a capture-proven pool that the decoder rejects names the gate to fix.
pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec<String> {
let Some((decl, markers)) = decl_of(bytes, name) else {
return vec!["no such XBG7 resource".into()];
};
let n = markers.len();
if n == 0 {
return vec!["no index markers".into()];
}
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
for &(_, ic) in &markers {
rel_ib.push(acc_i);
acc_i = align4(acc_i + ic * 2);
}
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
let (vck, ick) = markers[kmax];
let mut off_v = 0usize;
for &(vc, _) in markers.iter().take(kmax) {
off_v += vc * decl.stride;
}
let mut out = vec![format!(
"{name}: {n} sub-meshes, pivot #{kmax} ({vck} verts, {ick} idx), pool span {span}"
)];
for pad in 0..=3usize {
if vb0 < span + pad {
out.push(format!(" pad {pad}: pool start is before the index pool"));
continue;
}
let ib0 = vb0 - span - pad;
// Same gates the production pivot test uses: strict winding (0.85)
// AND connectivity. Reporting at 0.0 would accept blocks the decoder
// rejects and send the reader chasing the wrong gate.
let verdict = validate_block_report(
bytes,
ib0 + rel_ib[kmax],
vb0 + off_v,
vck,
ick,
&decl,
0.85,
true,
);
out.push(match verdict {
Ok(()) => format!(" pad {pad}: ACCEPTED"),
Err(why) => format!(" pad {pad}: {why}"),
});
}
out
}
/// Diagnostic: for a resource whose vertex buffer is *known* (a runtime capture
/// names it), where could its index buffer be? The anchor scan assumes the index
/// buffer sits immediately before the vertex buffer; this scans the whole
/// container instead and returns every offset that validates as this resource's
/// index buffer. An empty result means the block is unreadable at that `vb` for
/// another reason; a hit far from `vb` means the adjacency assumption is what
/// fails. Returns `(ib_offset, signed distance vb - ib)` pairs.
pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usize, i64)> {
let Some(decl) = decl_of(bytes, name) else {
return Vec::new();
};
let markers = decl.1;
let decl = decl.0;
let Some(&(vtx_count, index_count)) = markers.first() else {
return Vec::new();
};
let mut out = Vec::new();
let end = bytes.len().saturating_sub(index_count * 2);
let mut ib = 0usize;
while ib < end {
// Cheap prefilter: the first few indices must be in range, and a real
// index buffer is not a run of zeros.
let ok = (0..6).all(|k| (be16(bytes, ib + k * 2) as usize) < vtx_count)
&& (0..6).any(|k| be16(bytes, ib + k * 2) != 0);
// `SOFT_IB=1` drops the connectivity requirement, to separate "no index
// buffer fits" from "our connectivity test is too strict".
let strict = std::env::var("SOFT_IB").is_err();
if ok && validate_block(bytes, ib, vb, vtx_count, index_count, &decl, 0.0, strict) {
out.push((ib, vb as i64 - ib as i64));
}
ib += 2;
}
out
}
/// The connectivity cap: a searched block whose mean triangle edge exceeds this
/// fraction of its bounding-box diagonal is rejected.
///
/// **1.0 since 2026-08-12 — effectively inert.** The winding-consistency gate
/// ([`pad0_consistency`]) replaced this as the primary structural test: it is an
/// objective topology signal rather than a shape heuristic, and swapping them
/// decodes **143 more** resources with **17 fewer** cross-container
/// inconsistencies while the capture oracle stays at 46/46. The cap is kept as a
/// knob and a backstop against absurd blocks. History below.
///
/// **0.42 from 2026-08-12**, raised from 0.28 on runtime evidence. A capture
/// names the blocks the engine really draws, and the old cap rejected one of them
/// outright — `e106_eng_02_l`, a 24-triangle LOD, measures **0.417**, because a
/// coarse mesh's edges *are* a large fraction of its own size. Swept against the
/// 46 capture-named `Stage_S02` buffers (with distinct anchor assignment): 0.28
/// anchors 40 exactly and leaves 4 unclaimed, 0.42 anchors **45 and leaves none**,
/// and nothing above 0.42 improves further — so this is the least permissive
/// value that captures the whole measured gain. `XBG7_EDGE_CAP` overrides it (see
/// docs/re/structures/xbg7-mesh.md).
fn edge_cap() -> f32 {
std::env::var("XBG7_EDGE_CAP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0)
}
/// Winding floor for the grouped-pool **pivot** (default `0.85`). The single-block
/// path settled at 0.70 on measurement; this is the same question for the pivot,
/// and `XBG7_GROUPED_CONSISTENCY` sweeps it.
fn grouped_consistency() -> f32 {
std::env::var("XBG7_GROUPED_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.85)
}
/// Coverage requirement, as `max_index + N >= vtx_count`. **`1` since
/// 2026-08-12** — i.e. the indices must reach the pool's last vertex exactly.
///
/// The old `4` tolerated three unreferenced tail vertices, and that slack was a
/// mis-anchor tell rather than a real variation: 8 580 of 8 629 decoded
/// sub-meshes cover their pool exactly, and `e106_bdy_03` in `Stage_S02` was one
/// of the few that did not — slack 3, decoding to a 600×1600×998 slab visible in
/// a render, where three other containers give 276×236×941. Requiring exact
/// coverage moves it onto the block those containers agree on. Costs 3 resources
/// disc-wide; capture oracle unchanged at 46/46. `XBG7_COVER_SLACK` overrides
/// (note `0` rejects everything — the comparison is `max_idx + N >= vtx_count`).
fn cover_slack() -> usize {
std::env::var("XBG7_COVER_SLACK").ok().and_then(|v| v.parse().ok()).unwrap_or(1)
}
/// Smallest bounding-box extent a block may have (default `0.5`). An absolute
/// floor on a format with no unit convention is a scale assumption, so it is a
/// knob: `XBG7_MIN_EXTENT`.
fn min_extent() -> f32 {
std::env::var("XBG7_MIN_EXTENT").ok().and_then(|v| v.parse().ok()).unwrap_or(0.5)
}
/// Use a scale-free collinearity test for degeneracy instead of the absolute
/// triangle-area one (`XBG7_REL_DEGEN=1`). More principled in the abstract — an
/// absolute area threshold calls a small object's every triangle degenerate —
/// but measured on this disc it decodes **no more** resources and raises
/// cross-container inconsistency 39 → 44, so it is **not** the default.
fn rel_degen() -> bool {
std::env::var("XBG7_REL_DEGEN").is_ok()
}
/// Winding-consistency floor for the pad-0 single-block anchor.
///
/// **0.70 since 2026-08-12.** A triangle's face normal should agree with its
/// vertices' stored normals almost always (≈1.0) or almost never (≈0.0, inverted
/// winding); a mis-carve wires arbitrary vertices and lands near 0.5. Gating on
/// `max(na, 1na)` is therefore an objective topology test, where the
/// connectivity cap it replaces is a shape heuristic that provably rejected a
/// capture-proven block. Measured over the disc, with connectivity inert:
///
/// | floor | resources decoded | shared inconsistent |
/// |---|---|---|
/// | 0.60 | 6 214 | 53 |
/// | **0.70** | **6 212** | **39** |
/// | 0.80 | 5 770 | 1 |
/// | 0.85 | 5 770 | 0 |
///
/// 0.70 dominates the previous connectivity-only default (6 069 / 56) on both
/// axes with the capture oracle unchanged, so it ships. The cliff at 0.80 buys
/// perfect cross-container consistency for 442 resources — recorded rather than
/// taken, since consistency is the weaker witness (see the docs).
fn pad0_consistency() -> f32 {
std::env::var("XBG7_PAD0_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.70)
}
/// Triangle count below which the looser [`small_cap`] applies. `0` (default)
/// disables the split, so the flat [`edge_cap`] governs every block.
fn small_tris() -> usize {
std::env::var("XBG7_SMALL_TRIS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)
}
/// The connectivity cap for blocks below [`small_tris`] triangles.
fn small_cap() -> f32 {
std::env::var("XBG7_EDGE_CAP_SMALL").ok().and_then(|v| v.parse().ok()).unwrap_or(0.45)
}
/// Internal: the descriptor parameters the diagnostics need.
fn decl_of(bytes: &[u8], name: &str) -> Option<(VertexDecl, Vec<(usize, usize)>)> {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return None;
}
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur).ok()?;
const DIR_BASE: usize = 0x10;
for _ in 0..header.num_resources {
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
if &e.type_tag != b"XBG7" {
continue;
}
let desc = e.data_offset as usize + DIR_BASE;
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
if desc >= bytes.len() || desc_end <= desc {
continue;
}
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
if rname != name {
continue;
}
let d = &bytes[desc..desc_end];
return Some((parse_vertex_decl(d)?, all_index_markers(d)));
}
None
}
/// Diagnostic: a resource's `(vtx_count, idx_count)` markers and vertex stride,
/// as the stage anchor scan reads them from the descriptor.
pub fn debug_resource_params(bytes: &[u8], name: &str) -> Option<(Vec<(usize, usize)>, usize)> {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return None;
}
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur).ok()?;
const DIR_BASE: usize = 0x10;
for _ in 0..header.num_resources {
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
if &e.type_tag != b"XBG7" {
continue;
}
let desc = e.data_offset as usize + DIR_BASE;
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
if desc >= bytes.len() || desc_end <= desc {
continue;
}
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
if rname != name {
continue;
}
let d = &bytes[desc..desc_end];
return Some((all_index_markers(d), parse_vertex_decl(d)?.stride));
}
None
}
/// Memoised whole-container decode, keyed by a cheap fingerprint of the bytes
/// plus the consistency setting. Holds the last few containers; a stage decode is
/// a handful of MB, and the alternative is re-anchoring every resource for every
/// ship the viewer shows.
fn full_decode_cached(
bytes: &[u8],
min_consistency: f32,
should_cancel: &(dyn Fn() -> bool + Sync),
) -> std::sync::Arc<Vec<Xbg7Model>> {
use std::sync::{Arc, Mutex, OnceLock};
// Fingerprint: length plus three sampled 4 KB windows. Two different
// containers agreeing on all of that is not a case this format produces.
let mut fp: u64 = 0xcbf2_9ce4_8422_2325 ^ bytes.len() as u64;
let windows = [0usize, bytes.len() / 2, bytes.len().saturating_sub(4096)];
for w in windows {
for b in bytes.iter().skip(w).take(4096) {
fp = (fp ^ *b as u64).wrapping_mul(0x100_0000_01b3);
}
}
let key = (fp, min_consistency.to_bits());
#[allow(clippy::type_complexity)]
static CACHE: OnceLock<Mutex<Vec<((u64, u32), Arc<Vec<Xbg7Model>>)>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
if let Ok(c) = cache.lock() {
if let Some((_, v)) = c.iter().find(|(k, _)| *k == key) {
return Arc::clone(v);
}
}
let models = Arc::new(Xbg7Model::anchor_models_uncached(
bytes,
min_consistency,
should_cancel,
None,
));
if let Ok(mut c) = cache.lock() {
c.push((key, Arc::clone(&models)));
if c.len() > 4 {
c.remove(0);
}
}
models
}
/// Diagnostic: the candidate vertex-buffer starts the stage anchor scan will
/// consider for a given `stride`, for one container. A runtime capture names the
/// offsets the engine really drew from (see `examples/shared_vbase_check.rs`), so
/// asking whether a proven offset is in this list separates the two possible
/// root causes of a mis-anchor: **absent** ⇒ the run scan misses it, **present**
/// ⇒ the scan sees it and the selection picks another.
pub fn debug_vertex_run_starts(bytes: &[u8], stride: usize) -> Vec<usize> {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return Vec::new();
}
let mut cur = Cursor::new(bytes);
let Ok(header) = Xpr2Header::read(&mut cur) else {
return Vec::new();
};
let data_base = header.header_size as usize;
if data_base >= bytes.len() {
return Vec::new();
}
vertex_run_starts(bytes, data_base, stride)
}
/// Scan the data section for offsets that begin a `stride`-sized unit-normal
/// vertex run (NORMAL is `f16×4` at vertex offset +12). A run *start* is an
/// offset whose normal is unit while the preceding stride slot's is not — i.e.
@@ -652,9 +1269,23 @@ fn anchor_pool_mesh(
vtx_count: usize,
decl: &VertexDecl,
min_consistency: f32,
taken: &std::collections::HashSet<usize>,
min_vb: usize,
) -> Option<GameMesh> {
let idx_bytes = index_count * 2;
for &vb in starts {
// Monotone assignment (opt-in): resources of one signature are laid out
// in descriptor order, so a later one may not take an earlier block.
if vb < min_vb {
continue;
}
// A buffer another resource already claimed is not a candidate: the
// engine draws each part from its own buffer (proved for the mirrored
// `e106_bdy_0{1,2}_l` twins by a runtime capture), so two resources
// landing on one offset means at least one of them is wrong.
if taken.contains(&vb) {
continue;
}
// The index buffer sits just before the vertex buffer, which is 4-byte
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb
// idx_bytes pad`). pad 0 is the immediate-adjacency case (all stages so
@@ -667,8 +1298,14 @@ fn anchor_pool_mesh(
continue;
}
let ib = vb - idx_bytes - pad;
// `XBG7_PAD0_CONSISTENCY` adds a winding requirement to the pad-0
// path, which has none by default. Winding agreement is an objective
// topology signal (≈1.0 or ≈0.0 for a real mesh, ≈0.5 for a
// mis-carve) where the connectivity cap is a shape heuristic with a
// capture-proven false positive — so it is the candidate replacement
// for that cap. Off by default; see docs/re/structures/xbg7-mesh.md.
let mc = if pad == 0 {
min_consistency
min_consistency.max(pad0_consistency())
} else {
min_consistency.max(0.85)
};
@@ -699,17 +1336,43 @@ fn validate_block(
min_consistency: f32,
strict_connectivity: bool,
) -> bool {
validate_block_report(
bytes,
ib,
vb,
vtx_count,
index_count,
decl,
min_consistency,
strict_connectivity,
)
.is_ok()
}
/// [`validate_block`], but naming the gate that rejected a block. A runtime
/// capture can prove a block is real; when the decoder still refuses it, this
/// says which test is wrong rather than leaving a threshold to be guessed at.
fn validate_block_report(
bytes: &[u8],
ib: usize,
vb: usize,
vtx_count: usize,
index_count: usize,
decl: &VertexDecl,
min_consistency: f32,
strict_connectivity: bool,
) -> Result<(), String> {
let stride = decl.stride;
let idx_bytes = index_count * 2;
if ib + idx_bytes > bytes.len() {
return false;
return Err("index buffer runs past the container".into());
}
let vtx_bytes = match vtx_count.checked_mul(stride) {
Some(v) => v,
None => return false,
None => return Err("vertex byte count overflows".into()),
};
if vb + vtx_bytes > bytes.len() {
return false;
return Err("vertex buffer runs past the container".into());
}
// ── Full index validation: every index in range, uses ~all vertices. ──
@@ -717,12 +1380,14 @@ fn validate_block(
for k in 0..index_count {
let i = be16(bytes, ib + k * 2) as u32;
if i >= vtx_count as u32 {
return false;
return Err(format!("index {i} out of range (vtx_count {vtx_count})"));
}
max_idx = max_idx.max(i);
}
if (max_idx as usize) + 4 < vtx_count {
return false;
if (max_idx as usize) + cover_slack() < vtx_count {
return Err(format!(
"indices reach only {max_idx} of {vtx_count} vertices (buffer not covered)"
));
}
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
@@ -747,7 +1412,7 @@ fn validate_block(
for (a, slot) in pc.iter_mut().enumerate() {
let x = bef(bytes, base + a * 4);
if !x.is_finite() || x.abs() > 1.0e6 {
return false;
return Err(format!("position component {x} is not finite/plausible"));
}
*slot = x;
lo[a] = lo[a].min(x);
@@ -761,7 +1426,21 @@ fn validate_block(
u[2] * w[0] - u[0] * w[2],
u[0] * w[1] - u[1] * w[0],
];
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
// Degeneracy = collinear vertices, which is a SCALE-FREE property:
// compare the cross-product magnitude to the two edge lengths that
// produced it (i.e. sin of the angle between them). The old absolute
// `area < 1e-9` test called a small object's every triangle degenerate —
// `g005` spans 0.346 units and scored 7 of 8 — so it rejected tiny props
// for being tiny. `XBG7_ABS_DEGEN=1` restores the absolute test.
let cross = (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt();
let un = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
let wn = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
let is_degenerate = if rel_degen() {
cross < 1.0e-6 * un * wn || un == 0.0 || wn == 0.0
} else {
0.5 * cross < 1.0e-9
};
if is_degenerate {
degenerate += 1;
} else if let Some(no) = decl.normal_offset {
// Stored-normal agreement: the face normal should point the way
@@ -788,8 +1467,10 @@ fn validate_block(
t += tstep;
}
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
return false; // too flat, or >30% degenerate → not this block
if extent < min_extent() || sampled == 0 || degenerate * 10 > sampled * 3 {
return Err(format!(
"extent {extent:.3} (min 0.5), {degenerate}/{sampled} degenerate (max 30%)"
));
}
// Connectivity check: a correctly-anchored mesh has triangle edges that
// are SMALL relative to its overall size (~0.050.15 of the bbox
@@ -805,8 +1486,17 @@ fn validate_block(
.sqrt()
.max(1e-6);
let mean_edge = edge_sum / (sampled as f32 * 3.0);
if mean_edge / diag > 0.28 {
return false;
// A COARSE block is coarse by construction: a 24-triangle LOD's edges
// are a large fraction of its own size, which is why the flat cap has a
// capture-proven false positive (`e106_eng_02_l`, ratio 0.417). Under
// `XBG7_SMALL_TRIS` blocks below that triangle count get the looser
// `XBG7_EDGE_CAP_SMALL` instead — a targeted relaxation, off by default.
let cap = if tris < small_tris() { small_cap() } else { edge_cap() };
if mean_edge / diag > cap {
return Err(format!(
"connectivity: mean_edge/diag {:.3} > cap {cap:.2}",
mean_edge / diag
));
}
}
// Winding-consistency gate (same as `from_xpr2`): a correctly-anchored
@@ -818,10 +1508,13 @@ fn validate_block(
if nrm_counted > 0 && min_consistency > 0.0 {
let na = nrm_agree as f32 / nrm_counted as f32;
if na.max(1.0 - na) < min_consistency {
return false;
return Err(format!(
"winding consistency {:.3} < {min_consistency:.2}",
na.max(1.0 - na)
));
}
}
true
Ok(())
}
/// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several*
@@ -854,6 +1547,7 @@ fn anchor_grouped_meshes(
starts: &[usize],
markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
decl: &VertexDecl,
taken: &std::collections::HashSet<usize>,
) -> Vec<GameMesh> {
let n = markers.len();
if n == 0 {
@@ -889,6 +1583,11 @@ fn anchor_grouped_meshes(
let (vck, ick) = markers[kmax];
for &vb0 in starts {
// Distinct assignment: a pool another resource already claimed is not a
// candidate (see the collision resolution in `anchor_models_filtered`).
if taken.contains(&vb0) {
continue;
}
for pad in 0..=3usize {
if vb0 < span + pad {
continue;
@@ -903,7 +1602,7 @@ fn anchor_grouped_meshes(
// collapses well below 0.85, so only the true pad/pivot passes.
let ib_k = ib0 + rel_ib[kmax];
let vb_k = vb0 + off_v[kmax];
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, grouped_consistency(), true) {
continue;
}
@@ -929,7 +1628,28 @@ fn anchor_grouped_meshes(
if !ok && i > kmax {
break; // chain diverged — emit the validated prefix, no garbage
}
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
// Sub-meshes BEFORE the pivot are emitted even when they fail the
// quality gates (a tiny flat lead part is legitimately poor), but
// an index that addresses past its own vertex buffer is not a
// quality question — it is unusable. Measured 2026-08-12: 18
// sub-meshes disc-wide carried indices up to 364 vertices past
// the end (`coverage_audit`), which any renderer would fault on.
// Same two structural requirements the searched path enforces:
// every index inside the buffer, and the indices reaching the
// end of it. Real geometry covers its pool exactly — 8 586 of
// 8 636 decoded sub-meshes reference their last vertex, none
// more than 3 short (`coverage_audit`) — so a sub-mesh whose
// indices stop well short is reading the wrong block, not a
// sparse one.
let mut max_idx = 0usize;
let in_range = (0..ic).all(|k| {
let i = be16(bytes, ib + k * 2) as usize;
max_idx = max_idx.max(i);
i < vc
});
if in_range && max_idx + cover_slack() >= vc {
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
}
vb += vc * stride;
}
return meshes;
@@ -976,6 +1696,7 @@ fn read_pool_mesh(
uvs,
indices,
name: None,
vbuf_offset: Some(vb),
}
}

View File

@@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
continue;
};
if let Some(frame) =
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx)
{
// An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
// compare `"01" == ""` and fall through, so the bridge was silently
// dropped from the assembly while the game draws it — caught by a runtime
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
// With no index to match on, take the lowest-numbered frame of the
// category; an indexed part still matches its own index only.
let mut cands: Vec<&ScenePart> = frames
.iter()
.filter(|f| {
f.resource.contains(gncat)
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
})
.collect();
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
if let Some(frame) = cands.first().copied() {
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
placed_res.insert(part.clone());
}
@@ -603,18 +615,44 @@ mod tests {
}
}
}
// ── Extras: the direction this test could not previously fail in. ──
// The loop above walks the CAPTURE's parts and looks each up in ours, so
// a static placement with no counterpart was invisible to it — which is
// how a resource decoded 100x too large (`e303_wep_01`, 2026-08-12) sat
// here unnoticed. Pin the set instead: the capture legitimately misses
// repeated instances of a shared resource (vbase dedup), so `e303_wep_01`
// is expected; anything else appearing only in the static assembly is a
// regression.
let captured: std::collections::BTreeSet<&str> =
cap.parts.iter().map(|p| p.part.as_str()).collect();
let extra: std::collections::BTreeSet<&str> = placed
.iter()
.map(|p| p.resource.as_str())
.filter(|r| !captured.contains(r))
.collect();
let allowed: std::collections::BTreeSet<&str> = ["e303_wep_01"].into_iter().collect();
assert_eq!(
extra, allowed,
"static placements with no counterpart in the runtime capture"
);
// Multi-instance coverage the capture couldn't see (vbase dedup).
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
// The mirrored starboard hull reflects (det < 0), the port one doesn't.
// NEITHER hull reflects: the twins' geometry is mirrored on the disc,
// so both placements are proper rotations. This flipped on 2026-08-12 —
// while both twins decoded to one buffer, `apply_twin_mirrors` had to
// synthesise the reflection here; a runtime capture showed the container
// holds both halves, and distinct anchor assignment now hands each twin
// its own (see docs/re/structures/xbg7-mesh.md).
let det = |m: &[[f32; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
};
let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored");
assert!(det(&one("e106_bdy_02").m) > 0.0, "starboard hull plain (mirror is in the data)");
assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
}

View File

@@ -183,6 +183,41 @@ pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
/// collapses to a single entry here — fine, capital ships are the target.)
/// Split a capture into blocks that are guaranteed to share one camera.
///
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
/// camera **only when both draws come from the same frame** — mix frames and
/// the residual is the camera's motion between them. With a static ship and a
/// static camera that error is invisible, which is how the single validated
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
/// units, and two frames of the same ship then disagree about where its parts
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
/// `[0, 0, -1090]`).
///
/// The split rule is the recurrence itself: a vertex buffer that appears again
/// starts a new block. Splitting too eagerly is harmless (a block is still one
/// camera, just with fewer parts in it) and it separates two instances of the
/// same class as a bonus; failing to split is what corrupts the result.
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
let mut cur: Vec<CapturedDraw> = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
for d in draws {
if !seen.insert(d.vbase) {
out.push(std::mem::take(&mut cur));
seen.clear();
seen.insert(d.vbase);
}
cur.push(d.clone());
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let mut out = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
@@ -682,10 +717,17 @@ mod tests {
assert_eq!(e106.reference, "e106_bdy_04");
assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed");
let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap();
// Port/starboard hull pair: X = ∓264, the starboard copy mirrored.
// Port/starboard hull pair: X = ∓264, **both plain**. The mirror is
// baked into the disc data, not into the placement: a runtime capture
// shows the container carrying two 119-vertex buffers whose contents are
// exact X-reflections, each drawn from its own address (see
// docs/re/structures/xbg7-mesh.md). Until 2026-08-12 both twins decoded
// to ONE buffer and this row carried diag(-1,1,1) to compensate; with
// distinct anchor assignment they decode to their own, and re-emitting
// from the capture produces identity here.
assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1);
assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1);
assert_eq!(get("e106_bdy_02").m[0][0], -1.0);
assert_eq!(get("e106_bdy_02").m[0][0], 1.0);
assert_eq!(get("e106_bdy_01").m[0][0], 1.0);
// Bridge: centreline, above and aft of the hull reference.
let brg = get("e106_brg_01");

View File

@@ -1,20 +1,28 @@
//! `T8aD` — the game's 2D UI/HUD texture format.
//!
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as **256×256 raster
//! tiles in row-major order** — each tile prefixed by a 16-byte tile header, edge
//! tiles clipped to the image bounds. Fully reversed 2026-07-17 from the file
//! header and verified against the running game (title screen).
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as a list of
//! **arbitrary sub-rectangles**, each with its own destination origin and size.
//! Reversed 2026-07-17 (verified against the running game's title screen) and
//! **corrected 2026-08-11**, when the per-tile header turned out to carry the
//! rectangle's placement rather than being opaque flags.
//!
//! ```text
//! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32)
//! 0x14 4 width (BE u32) the full surface
//! 0x18 4 height (BE u32)
//! 0x1c 4 tile count (BE u32) = ceil(w/256) * ceil(h/256)
//! 0x2c tiles*4 offset table: absolute byte offset of each row-major tile
//! <off> 16 per-tile header (flags + tile w/h), then:
//! <off+16> tile_w * tile_h * 4 bytes of A8R8G8B8 pixels, row-major
//! 0x1c 4 rectangle count (BE u32) — NOT ceil(w/256)*ceil(h/256)
//! 0x2c count*4 offset table: absolute byte offset of each rectangle
//! <off> 16 rectangle header, four BE u32: dst X, dst Y, width, height
//! <off+16> width * height * 4 bytes of A8R8G8B8 pixels, row-major
//! ```
//!
//! Most surfaces happen to be stored as full-width 256-tall bands, which is why
//! treating the file as a 256×256 grid decoded 96 % of the disc correctly. It is
//! not the model, though: a dialogue strip declares 524×63 and stores **one**
//! 173×25 rectangle at (175,20), and `pdmes010` stores two — (59,6,256,54) and
//! (315,6,149,54), the second beginning exactly `16 + 256*54*4` bytes after the
//! first. Anything the rectangles do not cover stays transparent.
//!
//! Surfaces ≤256px wide are a single tile column, so the first tile's pixels sit
//! at `44 + tiles*4 + 16 = 64` — which is why the old "type→header size 64/84/…"
//! rule (header = 44 + tiles*20) happened to decode small textures correctly: for
@@ -43,18 +51,11 @@ fn be32(b: &[u8], off: usize) -> u32 {
}
/// Side of the square storage tile, in texels, and the per-tile header size.
const TILE: usize = 256;
const TILE_HDR: usize = 16;
/// Bytes of per-rectangle header before its pixels: dst X, dst Y, w, h.
const RECT_HDR: usize = 16;
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses).
///
/// Layout (reversed from the header + verified against the running game):
/// a 44-byte base header, then a `tiles`-entry big-endian u32 **offset table** at
/// `0x2c`, where `tiles` = the field at `0x1c` = `ceil(w/256) * ceil(h/256)`.
/// Each entry is the absolute byte offset of a **row-major** 256×256 tile; every
/// tile is a 16-byte tile header followed by `tile_w*tile_h*4` A8R8G8B8 pixels,
/// edge tiles clipped to the image bounds.
/// `None` if any rectangle fails to fit the surface or the file — never guesses.
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !is_t8ad(bytes) || bytes.len() < 0x40 {
return None;
@@ -64,41 +65,45 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
return None;
}
let tiles = be32(bytes, 0x1c) as usize;
let cols = width.div_ceil(TILE);
let rows = height.div_ceil(TILE);
// The field at 0x1c must be the tile count; otherwise it's a variant we don't
// decode (e.g. DXT / palettized) — defer rather than misdecode.
if tiles == 0 || tiles != cols * rows {
let rects = be32(bytes, 0x1c) as usize;
if rects == 0 || rects > 4096 {
return None;
}
const TABLE: usize = 0x2c;
if bytes.len() < TABLE + tiles * 4 {
if bytes.len() < TABLE + rects * 4 {
return None;
}
// Anything no rectangle covers stays transparent.
let mut rgba = vec![0u8; width * height * 4];
for ty in 0..rows {
for tx in 0..cols {
let tile = ty * cols + tx;
let pixels = be32(bytes, TABLE + tile * 4) as usize + TILE_HDR;
let tw = TILE.min(width - tx * TILE);
let th = TILE.min(height - ty * TILE);
if pixels + tw * th * 4 > bytes.len() {
return None; // truncated / not the layout we expect
}
for row in 0..th {
let mut s = pixels + row * tw * 4;
let mut d = ((ty * TILE + row) * width + tx * TILE) * 4;
for _ in 0..tw {
// A8R8G8B8 → RGBA8.
rgba[d] = bytes[s + 1];
rgba[d + 1] = bytes[s + 2];
rgba[d + 2] = bytes[s + 3];
rgba[d + 3] = bytes[s];
s += 4;
d += 4;
}
for r in 0..rects {
let at = be32(bytes, TABLE + r * 4) as usize;
if at + RECT_HDR > bytes.len() {
return None;
}
let dx = be32(bytes, at) as usize;
let dy = be32(bytes, at + 4) as usize;
let rw = be32(bytes, at + 8) as usize;
let rh = be32(bytes, at + 12) as usize;
// Never guess: a rectangle must fit the surface and its pixels the file.
if rw == 0 || rh == 0 || dx + rw > width || dy + rh > height {
return None;
}
let pixels = at + RECT_HDR;
if pixels + rw * rh * 4 > bytes.len() {
return None;
}
for row in 0..rh {
let mut s = pixels + row * rw * 4;
let mut d = ((dy + row) * width + dx) * 4;
for _ in 0..rw {
// A8R8G8B8 → RGBA8.
rgba[d] = bytes[s + 1];
rgba[d + 1] = bytes[s + 2];
rgba[d + 2] = bytes[s + 3];
rgba[d + 3] = bytes[s];
s += 4;
d += 4;
}
}
}
@@ -123,7 +128,11 @@ mod tests {
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // 1 tile
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table: tile 0 @ 0x30
b.extend_from_slice(&[0u8; 16]); // 16-byte tile header → pixels at 0x40
// rectangle header: dst (0,0), size w×h
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
for i in 0..(w * h) {
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
}
@@ -143,8 +152,8 @@ mod tests {
}
#[test]
fn assembles_row_major_tiles_via_offset_table() {
// 300×1 → 2 tiles: (0,0)=256×1 red, (1,0)=44×1 blue, each +16-byte header.
fn assembles_rectangles_via_offset_table() {
// 300×1 → 2 rectangles: (0,0) 256×1 red, then (256,0) 44×1 blue.
let (w, h): (u32, u32) = (300, 1);
let mut b = vec![0u8; 0x2c];
b[0..4].copy_from_slice(&T8AD_MAGIC);
@@ -155,9 +164,9 @@ mod tests {
let off1 = off0 + 16 + 256 * 4; // tile-0 header + its 256 pixels
b.extend_from_slice(&(off0 as u32).to_be_bytes());
b.extend_from_slice(&(off1 as u32).to_be_bytes());
b.extend_from_slice(&[0u8; 16]);
for v in [0u32, 0, 256, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(0,0) 256×1
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
b.extend_from_slice(&[0u8; 16]);
for v in [256u32, 0, 44, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(256,0) 44×1
b.extend_from_slice(&[0xFF, 0, 0, 0xFF].repeat(44)); // A,R,G,B blue
let img = parse(&b).expect("decodes");
assert_eq!((img.width, img.height), (300, 1));
@@ -166,10 +175,11 @@ mod tests {
}
#[test]
fn rejects_wrong_tilecount_and_short() {
// tile-count field that isn't ceil(w/256)*ceil(h/256) → None
fn rejects_out_of_range_rect_and_short() {
// a rectangle that does not fit the declared surface → None, never guess
let mut b = synth(2, 2);
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
let at = 0x2c + 4;
b[at + 8..at + 12].copy_from_slice(&99u32.to_be_bytes()); // width 99 > 2
assert!(parse(&b).is_none());
// truncated pixel data → None
let b = synth(64, 64);

View File

@@ -0,0 +1,105 @@
//! The runtime layout of a unit / vessel definition object.
//!
//! The game parses an [`crate::idxd`] record into a fixed 880-byte object whose
//! field offsets are **not** guessable from the disc data: the record is a
//! reflective key/value pool, and the loader assigns each key to a member by
//! name. This table is that assignment, read out of the loader itself
//! (`sub_82341A20` — every key is built as `addi r4, r30, -N`, so the field name
//! for each store is a string in the executable image), and verified against
//! objects dumped from a running mission: **406 values agree with the disc
//! records, 0 disagree**, over 11 objects covering both schemas.
//!
//! Why a reimplementation wants it:
//!
//! - it names the field behind every word of a live definition object, so a
//! memory snapshot can be read directly;
//! - it says which fields a record leaves **defaulted**, and what the loader
//! leaves there — the float accessor returns `0.0` on a pool miss;
//! - it carries two conventions that are invisible on disc: **angles are degrees
//! in the data and radians in the object**, and **`Size_Y` takes `Size_X`**
//! when omitted.
//!
//! See `docs/re/live-unit-definitions.md` for the derivation and the measured
//! default values.
/// How a field is stored in the definition object.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// IEEE-754 single, big-endian.
F32,
/// Pointer to a string.
Str,
/// 32-bit word (bool / enum / count / id).
Word,
}
/// One field of the definition object.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Field {
/// Byte offset from the start of the object.
pub offset: usize,
/// Field name, exactly as the disc record spells it.
pub name: &'static str,
/// Storage kind.
pub kind: Kind,
}
const TABLE: &str = include_str!("../data/unit_definition_layout.txt");
/// Every mapped field, in offset order.
pub fn fields() -> Vec<Field> {
TABLE
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter_map(|l| {
let mut it = l.split_whitespace();
let offset = it.next()?.parse().ok()?;
let kind = match it.next()? {
"f32" => Kind::F32,
"str" => Kind::Str,
_ => Kind::Word,
};
// `name` is a &'static str because TABLE is 'static.
let name = it.next()?;
let name: &'static str = TABLE.get(
TABLE.find(name).map(|s| s..s + name.len())?,
)?;
Some(Field { offset, name, kind })
})
.collect()
}
/// The field at `offset`, if one is mapped there.
pub fn field_at(offset: usize) -> Option<Field> {
fields().into_iter().find(|f| f.offset == offset)
}
/// The offset of `name`, if it is mapped.
pub fn offset_of(name: &str) -> Option<usize> {
fields().into_iter().find(|f| f.name == name).map(|f| f.offset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_parses_and_is_ordered() {
let f = fields();
assert!(f.len() > 150, "expected the full map, got {}", f.len());
assert!(f.windows(2).all(|w| w[0].offset < w[1].offset), "offsets must be strictly increasing");
}
#[test]
fn known_fields_sit_where_the_loader_puts_them() {
// Spot-checks from the verified map; these four also anchor the
// identification of a live object (see docs/re/live-unit-definitions.md).
assert_eq!(offset_of("Size_X"), Some(48));
assert_eq!(offset_of("Size_Y"), Some(52));
assert_eq!(offset_of("Size_Z"), Some(56));
assert_eq!(offset_of("HP"), Some(84));
assert_eq!(offset_of("HQRatio"), Some(88));
assert_eq!(field_at(96).map(|f| f.name), Some("ThrusterRatio"));
assert_eq!(field_at(48).map(|f| f.kind), Some(Kind::F32));
}
}

View File

@@ -0,0 +1,144 @@
//! Cross-container consistency for XBG7 geometry.
//!
//! A geometry resource shared by several stage containers must decode to the
//! same bounds in each. This needs no ground truth, and on 2026-08-11 it found
//! **125 of 681** shared resources decoding to different bounds while reporting
//! identical vertex and triangle counts — the anchor scan locating a different
//! buffer of the same size (see `docs/re/structures/xbg7-mesh.md`).
//!
//! The test is `#[ignore]`d because the decoder does not satisfy it yet. It is
//! written as the *target* state so that fixing the anchor scan makes it pass,
//! rather than as a snapshot of the bug.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use sylpheed_formats::mesh::Xbg7Model;
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
/// Rounded (w, h, d) of a model's own geometry.
fn span(m: &Xbg7Model) -> Option<[i64; 3]> {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes {
for q in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(q[k]);
hi[k] = hi[k].max(q[k]);
}
}
}
if lo[0] == f32::MAX {
return None;
}
Some([
(hi[0] - lo[0]).round() as i64,
(hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64,
])
}
#[test]
#[ignore = "known-failing: 62 of 714 shared resources decode inconsistently (was 125 of 681; distinct anchor assignment + the 0.42 connectivity cap fixed the rest). Note this metric is the WEAKER witness — a systematic mis-anchor is consistent — see docs/re/structures/xbg7-mesh.md"]
fn shared_resources_decode_identically_in_every_container() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let dir = root.join("hidden/resource3d");
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("resource3d/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// name -> (verts, tris) -> set of spans seen
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize, String)>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let Some(sp) = span(&m) else { continue };
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
seen.entry(m.name.clone()).or_default().push((sp, v, t, where_.clone()));
}
}
let mut bad: Vec<String> = Vec::new();
for (name, list) in &seen {
if list.len() < 2 {
continue;
}
// Only compare decodes that agree on how much geometry they found;
// a differing vertex/triangle count is a different question.
if !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
continue;
}
let spans: std::collections::BTreeSet<[i64; 3]> = list.iter().map(|e| e.0).collect();
if spans.len() > 1 && bad.len() < 10 {
bad.push(format!("{name}: {spans:?}"));
}
}
assert!(
bad.is_empty(),
"{} shared resources decode to different bounds; first: {bad:?}",
bad.len()
);
}
/// Port/starboard twins must not decode to the *same* buffer.
///
/// A runtime capture showed the container stores both halves of the `e106` hull
/// as separate X-reflected buffers, so a `…_01`/`…_02` pair of equal vertex
/// count should come out mirrored (or related by another axis / vertex order) —
/// never identical, which is the collapse distinct assignment fixes. Since that
/// fix reached grouped pools too, this holds for **every** twin pair on the disc
/// — including `n206`, which was the last exception.
#[test]
fn twin_pairs_do_not_share_a_buffer() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join("hidden/resource3d"))
.expect("resource3d/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut collapsed: Vec<String> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
let by_name: BTreeMap<&str, &Xbg7Model> =
models.iter().map(|m| (m.name.as_str(), m)).collect();
for m in &models {
let Some(stem) = m.name.strip_suffix("_01") else { continue };
let Some(t) = by_name.get(format!("{stem}_02").as_str()) else { continue };
let (a, b) = (m.meshes[0].vbuf_offset, t.meshes[0].vbuf_offset);
if a.is_some() && a == b {
collapsed.push(format!("{}/{stem}_02 in {}", m.name, f.file_name().unwrap().to_string_lossy()));
}
}
}
assert!(collapsed.is_empty(), "twin pairs sharing one buffer: {collapsed:?}");
}

View File

@@ -17,7 +17,7 @@ fn res3d_dir() -> Option<PathBuf> {
}
}
let default =
PathBuf::from("/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d");
PathBuf::from("/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d");
default.is_dir().then_some(default)
}
@@ -321,7 +321,7 @@ fn hero_ship_grouped_pool_decodes() {
fn stage_models_decode() {
use sylpheed_formats::mesh::Xbg7Model;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
"/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
});
let path = format!("{dir}/Stage_S10.xpr");
let bytes = std::fs::read(&path).expect("read Stage_S10");
@@ -359,7 +359,7 @@ fn stage_models_sweep() {
use sylpheed_formats::mesh::Xbg7Model;
use std::time::Instant;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
"/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
});
let mut names: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
@@ -386,7 +386,7 @@ fn stage_models_sweep() {
fn stage_models_quality_audit() {
use sylpheed_formats::mesh::Xbg7Model;
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
"/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
});
let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap();
let models = Xbg7Model::stage_models(&bytes);

View File

@@ -19,7 +19,7 @@ fn iso_path() -> Option<PathBuf> {
}
}
let default = PathBuf::from(
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
);
default.is_file().then_some(default)
}

View File

@@ -0,0 +1,152 @@
//! Disc-wide guards for the 2D surface formats, locking in what was measured on
//! 2026-08-11. Skipped (as no-ops) when the extracted disc is absent.
//!
//! These assert *disc-wide invariants* rather than one hand-picked file, because
//! each of the findings they guard was originally missed by reasoning from a
//! sample: T8aD's "~15 % unsupported variants" were a wrong model, and LSTA's
//! "a few entries disagree with the count" was a miscount.
use std::path::{Path, PathBuf};
use sylpheed_formats::{lsta, pak::PakArchive, ratc, t8ad};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
macro_rules! skip_without_disc {
($root:ident) => {
let Some($root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
};
}
/// Every entry of every pak, plus every RATC child, as raw bytes.
fn for_each_blob(root: &Path, mut f: impl FnMut(&str, &str, &[u8])) {
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
for p in &paks {
let pak_name = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(arc) = PakArchive::open(p) else { continue };
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
f(&pak_name, &format!("{:08x}", e.name_hash), &bytes);
if ratc::is_ratc(&bytes) {
if let Some(kids) = ratc::parse(&bytes) {
for k in &kids {
if k.offset + k.size <= bytes.len() {
f(&pak_name, &k.name, &bytes[k.offset..k.offset + k.size]);
}
}
}
}
}
}
}
/// A T8aD surface is a list of sub-rectangles, and on this disc **every** one
/// decodes. Regressing the rectangle model would show up here as a decode gap,
/// which is exactly how the old 256-grid reading looked (96 %, not 100 %).
#[test]
fn every_t8ad_on_the_disc_decodes() {
skip_without_disc!(root);
let (mut total, mut ok) = (0usize, 0usize);
let mut first_failure = None;
for_each_blob(&root, |pak, name, b| {
if !t8ad::is_t8ad(b) || b.len() < 0x40 {
return;
}
total += 1;
if t8ad::parse(b).is_some() {
ok += 1;
} else if first_failure.is_none() {
first_failure = Some(format!("{pak}:{name}"));
}
});
assert!(total > 19_000, "expected the disc's ~19 216 surfaces, saw {total}");
assert_eq!(ok, total, "first failure: {first_failure:?}");
}
/// An LSTA's header count is exact and counts **both** kinds of element: T8aD
/// sprites and `PRMD` primitives. (It was long read as unreliable because the
/// comparison ignored primitives.)
#[test]
fn lsta_count_equals_sprites_plus_primitives() {
skip_without_disc!(root);
let count_magic = |b: &[u8], magic: &[u8; 4]| {
let (mut n, mut i) = (0usize, 4usize);
while i + 4 <= b.len() {
if &b[i..i + 4] == magic {
n += 1;
i += 4;
} else {
i += 1;
}
}
n
};
let (mut lists, mut exact) = (0usize, 0usize);
let mut bad = Vec::new();
for_each_blob(&root, |pak, name, b| {
if !lsta::is_lsta(b) || b.len() < 8 {
return;
}
lists += 1;
let declared = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
let sprites = count_magic(b, b"T8aD");
let prims = count_magic(b, b"PRMD");
if declared == sprites + prims {
exact += 1;
} else if bad.len() < 4 {
bad.push(format!("{pak}:{name} declared {declared} != {sprites}+{prims}"));
}
});
assert!(lists >= 60, "expected the disc's 64 LSTA lists, saw {lists}");
assert_eq!(exact, lists, "mismatches: {bad:?}");
}
/// Nested RATC records are **leaves**: they carry no child list of their own.
/// "One level deep" describes the data, not a parser limit.
#[test]
fn ratc_nesting_is_exactly_one_level() {
skip_without_disc!(root);
let mut grandchildren = 0usize;
let mut bundles = 0usize;
for_each_blob(&root, |_, _, b| {
if !ratc::is_ratc(b) {
return;
}
let Some(kids) = ratc::parse(b) else { return };
bundles += 1;
for k in &kids {
if k.offset + k.size > b.len() {
continue;
}
let sub = &b[k.offset..k.offset + k.size];
if ratc::is_ratc(sub) {
grandchildren += ratc::parse(sub).map(|g| g.len()).unwrap_or(0);
}
}
});
assert!(bundles > 2_000, "expected thousands of RATC bundles, saw {bundles}");
assert_eq!(grandchildren, 0, "a nested RATC record listed children");
}

View File

@@ -0,0 +1,96 @@
//! The definition-object layout must keep agreeing with the disc records.
//!
//! `unit_layout` was read out of the game's loader and checked against objects
//! dumped from a running Stage 02. This pins that check: every mapped float
//! field of every identified object must equal the value its disc record sets.
//! It needs no emulator — the dump is checked in under `docs/re/captures/`.
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
use sylpheed_formats::unit_layout::{fields, Kind};
/// Live objects identified in the dump, with the disc record each one is.
/// `bf001` is here because full-record agreement is what identified it: the
/// four-value signature also fitted `UN_be005_ADAN_SpaceFortress`, which
/// disagrees on `Color_R`/`Color_G`.
const IDENTIFIED: &[(&str, &str)] = &[
("UN_f106_TCAF_Destroyer", "0xbd3ee300"),
("UN_f105_TCAF_Cruiser", "0xbd40e200"),
("UN_e105_ADAN_Cruiser", "0xbd3fd800"),
("UN_e106_ADAN_Destroyer", "0xbd3e6f80"),
("UN_f101_TCAF_Acropolis", "0xbd3e1f00"),
("UN_bf001_TCAF_SchlosBase", "0xbd3b6a00"),
("UN_e007_ADAN_Turret", "0xbd3dea80"),
("UN_e010_ADAN_Attacker_S", "0xbd3faa80"),
("UN_f003_TCAF_ArrowHead", "0xbd3dbd00"),
("UN_f001_TCAF_DeltaSaber_T", "0xbd3dfc00"),
("UN_f001_TCAF_DeltaSaber_T_Player", "0xbd3da100"),
];
const DUMP: &str = include_str!("../../../docs/re/captures/stage02-live-unit-definitions-deep.txt");
fn disc_root() -> Option<String> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
if std::path::Path::new(&p).join("dat").is_dir() {
return Some(p);
}
}
let d = "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)";
std::path::Path::new(d).join("dat").is_dir().then(|| d.to_string())
}
/// `va -> offset -> value`, from the dump's `addr +off hex u32 f32` columns.
fn live() -> BTreeMap<String, BTreeMap<usize, f32>> {
let mut out: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in DUMP.lines() {
if let Some(rest) = line.strip_prefix("=== ") {
cur = rest.trim().to_string();
continue;
}
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() >= 5 && f[1].starts_with('+') {
if let (Ok(off), Ok(val)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>())
{
out.entry(cur.clone()).or_default().insert(off, val);
}
}
}
out
}
#[test]
fn mapped_fields_match_the_disc_records() {
let Some(disc) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("main pak");
let live = live();
let floats: Vec<_> = fields().into_iter().filter(|f| f.kind == Kind::F32).collect();
let (mut agree, mut bad) = (0usize, Vec::new());
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(id) = obj.get_raw("ID") else { continue };
let Some((_, va)) = IDENTIFIED.iter().find(|(i, _)| *i == id) else { continue };
let Some(words) = live.get(*va) else { continue };
for f in &floats {
let (Some(want), Some(got)) = (obj.get_f32(f.name), words.get(&f.offset)) else {
continue;
};
// Angle fields are degrees on disc and radians in the object.
let rad = want * std::f32::consts::PI / 180.0;
if (want - got).abs() <= want.abs() * 1e-4 || (rad - got).abs() <= rad.abs() * 1e-4 {
agree += 1;
} else {
bad.push(format!("{id}.{} disc {want} vs live +{} = {got}", f.name, f.offset));
}
}
}
assert!(bad.is_empty(), "{} fields disagree: {bad:?}", bad.len());
assert!(agree >= 400, "expected ≥400 agreeing fields, got {agree}");
}

View File

@@ -3677,6 +3677,7 @@ fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh {
uvs: Vec::new(),
indices,
name: Some("exhaust".to_string()),
vbuf_offset: None, // procedural, not read from a container
}
}

View File

@@ -8,7 +8,43 @@ unknown, what evidence exists, and what the first step would be. Move an item in
## Capital ships assemble wrong in the viewer
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
**Reported:** 2026-07-30, by the user. **Status:** ✅ **format-side cause found and
fixed 2026-08-12** — see below for the 2026-08-10 diagnosis this supersedes.
The remaining format-side defect this entry pointed at (a shared turret decoding
~100× too large in some containers) was real and is gone. `e303_wep_01` decoded
as a 1600×2100×4800 block in `Stage_S02`, swallowing the `e106` hull; requiring an
index buffer to cover its vertex pool **exactly** moved it to the block every
other container agrees on, and it now decodes 49×23×42 everywhere and places at
±179 on the hull. The same fix repaired `e106_bdy_03` (a 600×1600×998 slab) and
moved 29 anchors disc-wide, 22 of which had been carrying **another resource's
geometry under their own name**. See
[`structures/xbg7-mesh.md`](structures/xbg7-mesh.md).
Two things are worth carrying forward rather than closing:
- the assembler was **audited and exonerated** — every composite node carries
scale 1.0 and an orthonormal matrix, so nothing on that side inflates a part;
- **no metric caught this.** Coverage, cross-container consistency, the capture
oracle and the twin invariant were all green while a 1 600-unit slab sat through
the ship. It was found by *rendering the ship and looking at it*, and the
numeric screens written afterwards to automate that check both failed.
The 2026-08-10 diagnosis follows, and its viewer-side pointers still stand.
**Status (2026-08-10):** 🔎 **the format layer is exonerated.** Runtime captures of three classes (`f105`, `e105`,
`e106`) at controlled range reproduce `assemble_ship` to ≤0.43 units in translation
and to 0.000 in rotation for every part that does not move; see
[`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md)
§4. So look at **the viewer**: first that it passes `include_external = true`
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
5 parts instead of 11), then its own transform stack.
One real format-side bug was found on the way and is **fixed**: index-less parts
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
`e102`, `e104`, `e105` across Stages 0229 — assembled without a bridge. The other
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
The original report and its reasoning follow.
The reborn viewer builds capital ships from the split XBG7 parts via
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
@@ -44,3 +80,225 @@ class cannot hide behind `e106` passing.
handedness, node-instance recursion) is not re-breaking a correct assembly — compare
the viewer's placement against `assemble_ship`'s output directly before blaming the
format layer.
---
## Viewer: `include_external` is already on — that hypothesis is dead
**Checked 2026-08-11.** The item above names "first that it passes
`include_external = true` (`iso_loader.rs:4012`)" as the cheap first step. It
does: `ShipBrowser::show_external` defaults to `true`
(`iso_loader.rs:643`), the checkbox reads it (`ui.rs:1593`) and it is threaded
through `RequestShipRender``build_ship_model``assemble_ship` unchanged
(`ui.rs:1689`, `iso_loader.rs:4012`). So a ship rendered by the viewer is the
full external assembly, not the bare hull.
The viewer also does not have a transform stack of its own to blame: it bakes
`ScenePart::apply` straight into the vertices and rotates normals by the same
`p.m` (`iso_loader.rs:4030-4062`), so its placement is `assemble_ship`'s output
by construction. What remains unexcluded, in order of cheapness: the mirror
handling (`det < 0` reverses triangle winding only — a reflected part keeps its
reflected geometry), `Xbg7Model::models_named` resolving the wrong sub-model when
a resource name repeats, and the exhaust cones. **Next step is a visual**: the
diagnosis has run out of things it can settle by reading, so the viewer needs to
be run against a known-good class (`e106`) and its render compared with
`ship_render`'s.
---
## Viewer: the duplicate-resource-name hypothesis is dead too
**Checked 2026-08-11.** The diagnosis above left three candidates for why capital
ships assemble wrong in the viewer: mirror handling, `Xbg7Model::models_named`
resolving the wrong sub-model when a resource name repeats, and the exhaust
cones. The second is now **refuted**, and comprehensively.
`build_ship_model` resolves each placement with
`base.iter().find(|m| m.name == p.resource)` (`iso_loader.rs:4041`) — first match
wins — so a repeated resource name inside a container would silently draw the
wrong geometry. It cannot happen: decoding **every** XBG7 resource in **all 22
stage containers** gives **4 603 resources and zero repeated names**.
```
Stage_S01 62/62 Stage_S07 323/323 Stage_S13 290/290 Stage_S25 351/351
Stage_S02 304/304 Stage_S08 388/388 Stage_S14 22/22 Stage_S26 318/318
Stage_S03 214/214 Stage_S09 316/316 Stage_S15 386/386 Stage_S27 321/321
Stage_S04 179/179 Stage_S10 7/7 Stage_S16 65/65 Stage_S28 118/118
Stage_S05 92/92 Stage_S11 157/157 Stage_S24 162/162 Stage_S29 386/386
Stage_S06 266/266 Stage_S12 376/376
```
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. Every placement
resolves to the one model it names.
**So two of the three candidates are gone** (this one and `include_external`),
leaving **mirror handling** and **the exhaust cones** — and the still-untried
visual comparison, which remains the right next step.
---
## Viewer: mirror handling and the exhaust cones are cleared too — the static avenue is exhausted
**Checked 2026-08-11.** Both remaining candidates were tested across every ship
on the disc, and neither shows the reported signature.
**Mirror handling.** The concern was that `ScenePart::apply` bakes `R·(S·v)+T`
while the viewer takes its winding-flip decision from `det(m)` alone and rotates
normals by `m` alone — both ignoring `s`. A mirror encoded as a *negative scale*
would then reflect geometry without flipping winding, drawing the part
inside-out. It never happens: across **1 485 assembled parts** in all 22
containers there are **22 mirrored parts, every one with `det(m) < 0`**, and
**zero** parts with a negative scale or a non-uniform one. `apply_twin_mirrors`
writes the reflection into `m` (negating its X column), so the viewer's flip
always fires, and ignoring `s` for normals is harmless because `s` is always
uniform.
**Exhaust cones.** These are the one piece of geometry the viewer *invents* — a
cone at each `GN_Jet`/`GN_SJet` frame, because the real engine geometry is
recessed and the game draws FX there instead. If they landed wrongly they would
read exactly as "a part in the wrong place". Across **335 assembled ships, 192 of
which have exhaust frames, not one cone sits outside its hull's bounding box**
(tolerance 10 % of the axis span).
**Caveat, stated rather than glossed:** "inside the hull box" does not prove a
cone is *right* — orientation and size are untested, and a cone could be wrong
while still inside. What it does rule out is the reported symptom for that part.
So every mechanism this diagnosis proposed is now eliminated: `include_external`,
duplicate resource names, mirror handling, and cones-in-the-wrong-place. The
format and assembly layers pass every static test available, and **the visual
comparison is no longer merely the next step — it is the only remaining one.**
Render `e106` in the viewer beside `ship_render`'s output of the same
`assemble_ship` result; if they agree, the bug is in neither and the original
report needs re-grounding against a specific ship and a specific expectation.
---
## ⚠️ DIAGNOSED 2026-08-12 — a mis-decode; the locality fix was written, then withdrawn
> Resolution at the end of this entry. Kept in full because the two wrong turns
> along the way (a "stray volume", then "monotonic anchoring") are the useful part.
## ⚠️ The format layer is NOT exonerated — but the cause is a MIS-DECODE, not a stray volume
**Found 2026-08-11 by finally doing the visual**, which the notes above kept
naming as the next step. It overturns their conclusion.
Render `e106` from the static assembly and from the baked runtime capture and
compare — `ship_render` does both:
| | placements | parts |
|---|---|---|
| runtime capture (ground truth) | **8** | `bdy_01…04`, `brg_01`, `eng_01`, `eng_02`, `wep_02_01` |
| `assemble_ship(--static)` | **11** | the same 8, **plus `e303_wep_01` ×2** and a second `e106_eng_01` |
The render makes it obvious: the destroyer sits inside a white slab that dwarfs
it ([capture](captures/e106-static-assembly-volume-bug.png)). That slab is
`e303_wep_01`, and its own geometry is:
```
e303_wep_01 172 verts, 110 tris bounds X[-1000, 600] Y[-1050, 1050] Z[-2400, 2400] 1600 x 2100 x 4800
e106_wep_02_01 1002 verts, 772 tris 269 x 179 x 417 ← what a real e106 turret looks like
e106_brg_01 202 verts, 202 tris 105 x 76 x 305
```
**110 triangles, perfectly round axis-aligned bounds, and bigger than the ship it
is mounted on.**
### CORRECTION (same day, one iteration later): it is not a volume — it is a bad decode
The first reading of this was that `e303_wep_01` is a collision/trigger volume
the assembler wrongly draws. **That is wrong, and the evidence that settles it is
decoding the same resource from every container that holds it:**
```
Stage_S01 172 verts 110 tris X[-24.5, 24.5] Y[0.0, 23.4] Z[-20.8, 20.8] ← 49 × 23 × 42, a turret
Stage_S02 172 verts 110 tris X[-1000, 600] Y[±1050] Z[±2400] ← 1600 × 2100 × 4800
Stage_S03… 172 verts 110 tris 49 × 23 × 42 (correct)
Stage_S08 … 1600 × 2100 × 4800
Stage_S26 … 1600 × 2100 × 4800
```
Same resource, same vertex and triangle count, **decoding correctly in eleven
containers and wrongly in exactly three** (`Stage_S02`, `S08`, `S26`). So:
- the **placement is legitimate**`e303_wep_01` is a small shared turret,
cross-mounted on `e101` and `e106`, and at its true size it is unremarkable;
- the original author's explanation of the capture's silence (**vbase dedup**)
stands, and my "dedup would show one, not zero" objection does not survive:
with the correct decode the turret is small, ordinary geometry;
- **the defect is in the mesh decoder**, which resolved this resource's vertex
data differently in three containers.
The render and the symptom are real; the cause named in the first version of this
entry was not.
### The part that matters more than this one resource
**The decoder can produce wrong geometry without declining.** The
[XBG7 audit](structures/xbg7-mesh.md) counted 814 resources it *refuses* — a
visible, honest failure. This is the other kind: `e303_wep_01` decodes "fine" in
`Stage_S02` and is silently 100× too large. Screening for the signature (bounds
that are exact multiples of 50 with a span over 1000) flags 2232 models in each
of `S02`, `S03`, `S08`, `S26`, `S27` — **but that screen also catches legitimate
`e_rou_*` composite proxies**, so it is a candidate list, not a count of bugs.
**Next:** diff the anchor scan's chosen `vb0` for `e303_wep_01` between
`Stage_S01` (correct) and `Stage_S02` (wrong) — same resource, two outcomes, so
the divergence is directly observable — then use whatever distinguishes them to
add a post-decode sanity check, so a silent 100× mis-decode becomes a decline.
### Why this was missed
`assemble_ship` treats **every** `rou_*` node in the composite as a drawable
part, and the doc comment states the cross-id mount as intended behaviour —
`"INCLUDING repeated instances and cross-id turret mounts (rou_e303_wep_01_root
×2 on the e106 hull)"` — with
`ship::tests::static_assembly_matches_runtime_capture` asserting
`count("e303_wep_01") == 2`. The absence from the capture was explained away as
vbase dedup, but **dedup would show one instance, not zero**.
The test cannot catch it either: it walks the capture's parts and looks each up
in the static output, so **extra** static placements are invisible to it. That is
the same shape of gap as the earlier `include_external` hypothesis — a test that
can only fail one way.
### Scope, stated carefully
Sweeping all 335 assembled ships for the signature *ship-scale span with under
400 triangles* flags **20 ships and 58 placements** over 28 distinct resources
(`e005_ant_*`, `f001_ant_*`, `f002_bdy_*`, `f301_barrel`, `f303_body`,
`e303_wep_01`, …). **Only the `e106`/`e303_wep_01` case is proven** — by render,
by capture absence, and by geometry. Some of the others may be legitimately large
low-poly parts, and each needs the same three checks before being called a bug.
**Still true, and independent of the correction above:**
`static_assembly_matches_runtime_capture` walks the capture's parts and looks each
up in the static output, so **extra static placements can never fail it**. That is
worth fixing regardless — it is the same one-way-test shape as the earlier
`include_external` hypothesis.
Also unchanged: only **two** cross-id placements exist fleet-wide (`e303_wep_01`
on `e101` ×24 and `e106` ×36, across 335 assembled ships), so cross-id mounting is
a narrow, real feature rather than a systemic guess.
---
## Resolution (2026-08-12)
`anchor_pool_mesh` took the **first** candidate in file order from a
container-global scan, so a resource could be handed another resource's block
whenever both shared `(stride, vertex count, index count)`. Fixed by anchoring
each resource near its **descriptor neighbours** (two-pass: learn, then re-anchor).
- it took inconsistency **125 → 51** with coverage unchanged, and made `e106`
render correctly ([after](captures/e106-static-assembly-fixed.png))
- **but it flipped the `e106` twin-mirror decision**, which
`static_assembly_matches_runtime_capture` (ISO-gated, so it skips in a plain
`cargo test`) catches against the runtime capture — so it was **reverted**
- the user-reported "capital ships assemble wrong" is therefore **diagnosed, not
yet fixed**; see [xbg7](structures/xbg7-mesh.md) for what the real fix needs
Still open from this entry: `static_assembly_matches_runtime_capture` walks only
the capture's parts, so **extra** static placements still cannot fail it.

View File

@@ -13,17 +13,19 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) |
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)); ~15% variants deferred |
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
| T8aD 2D texture | | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
| RATC bundle | | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat``pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** |
| LSTA sprite list | | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both**`count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix |
| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
| XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool |
| Capital-ship part placement | | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot fail it |
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — [values](captures/unit-runtime-fields.csv) |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
| Arsenal develop economy | ✅/ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. The **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
| Save file (`savedata`) | ✅/❔ | [savegame-format](structures/savegame-format.md) + [`tools/re-capture/savegame.py`](../../tools/re-capture/savegame.py) | `GDHA` container, zlib payload, chunk stream (`GDAA` / phase name / `GHAD` 122 B progress block / 16×20 B slot table / trailer). **Container and layout read off the title's own serializer `0x822C00E8` and verified by a byte-identical round-trip**; the whole save is 545 B. Payload offsets are also the live save object's offsets (`save+8` GHAD, `save+136` slots). A second save made in-game names **Points** (+24), **flight time in ms** (+4) and **clear ratio %** (+8) off the game's own Details panel; the payload is a **pure function of game state** (same state saved twice = byte-identical, only the header FILETIME and its uninitialised pointer padding move), and the 16 `SHAB` records are **not** the UI's 20 save slots. Difficulty vs stage is undecided — three fields hold 2. **A third save, taken after developing exactly one Arsenal weapon** (Light Machine Gun MG I, 4000 P), moves exactly three things: `+24` Points 4101→101 (which **separates it from `+28`**, that did not move), `+8` clear ratio 5→6 (so the ratio counts *collection*, not only stages), and two entries of the 54-byte blob — `2→4` for the item bought and `0→2` for the successor the game announced as newly developable, giving the blob its alphabet ✅ *0 locked / 2 developable / 4 developed* (only the `4`s are stored — `2` is re-derived at load). **Saves can also be written back**: three derived header fields (length at `+0x30`, payload length at `+0x8c`, `adler32` at `+0x8e`) are all that stand between a parse and a hand-written save that the title loads, and [`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) re-wraps a real save byte-identically. That turned the blob's index space from blocked-on-story-progress into four probe saves — see the [economy note](arsenal-develop-economy.md) |
## Runtime / dynamic-capture technique

View File

@@ -0,0 +1,69 @@
# Session log — 2026-08-11 (autonomous run)
What changed today, in the order a reviewer would want it, with the one item
that needs **your decision** called out first.
## Needs a decision from you
**Editing save slot 01.** Save files can now be written back
([savegame_edit.py](../../tools/re-capture/savegame_edit.py) re-wraps a real save
**byte-identically**), and hand-written saves load. That unblocked several
questions by probing throwaway slots. But the **title-menu screens read the
auto-save (slot 01)**, so the remaining save-field questions (the difficulty
enum, `SHAB.b`) need slot 01 itself edited — the only save with real progress.
Not done. Recoverable (full content backup + slot 01's bytes archived), but it is
your call. Everything else below was done in throwaway slots.
## Findings, by area
### Save format
- **The develop differential**: buying one Arsenal weapon moves exactly three
fields — Points `+24` (which **separates it from `+28`**), clear ratio `+8`
(so the ratio counts *collection*, not stages), and two entries of the 54-byte
blob. Blob alphabet: `0` locked / `2` developable / `4` owned; only the `4`s
are stored, `2` is re-derived at load.
- **The blob's index space is solved** — it follows `strings.tbl`'s item order,
**cut items included**, pinned by four probe saves and closing exactly at 53.
- **`SHAB` is the per-stage record table**: record 0 = Stage 01, and `c` is its
best clear time in ms (`324773` = the `05:24.77` MISSION SELECT shows).
- **Refuted**: every GHAD scalar as stage/difficulty (16 elements probed), and
"stage = filled `SHAB` count + 1".
### Formats
- **T8aD 96 % → 100 %** of the disc: a surface is a list of **sub-rectangles**
(`dst X, dst Y, w, h` per rectangle), not a 256 grid. The "~15 % variants" were
a wrong model.
- **LSTA**: the count was never unreliable — it counts **sprites *and* `PRMD`
primitives** (64/64 exact).
- **RATC**: "one level deep" is not a limitation; nested records are **leaves
that reference siblings by name** (10 144/10 148 resolve).
- **UI screens are fully reconstructible from their bundle** — element list +
order (declaration table, with a **parent index**) and placement/animation
(keyframe region). **Validated against the running game to ±2 px.**
- **Movie ↔ subtitle ↔ voice link solved statically** (101 movies) — `INDEX` had
it filed as needing the running game.
- **XPR2 channel order confirmed** against the game (hue 23.3° vs 9.3°).
### Meshes — the user-reported viewer bug
- Root-caused, **after one wrong turn I corrected in place**: the giant slab on
`e106` is not a stray volume but an **XBG7 mis-decode** — the same resource is a
49×23×42 turret in 11 containers and 1600×2100×4800 in 3.
- **New detector**: a resource shared across containers must decode to the same
bounds. **125 of 681** shared resources fail that check — a measured lower
bound on *silent* mis-decodes (distinct from the 814 honest declines).
## Two disc defects found
`SUBTITLE_S12B.tbl` (referenced by the movie table, present in **no** language)
and `pmbase.t32` (placed by `pmbase.rat`, present **nowhere**). A reimplementation
should skip a missing asset rather than treat it as a decode failure.
## Process notes worth keeping
- Several "the data is inconsistent" comments meant **our model was incomplete**
(T8aD variants, the LSTA count). Re-test such notes rather than routing around
them.
- A test that walks the *oracle's* items and looks each up in our output **can
only fail one way** — it never sees extras. That shape hid the ship bug and,
earlier, `include_external`.
- When a diagnosis has eliminated every hypothesis and the last step is *a
visual*, do the visual first. Three iterations went by before a 30-second
render showed it.

View File

@@ -0,0 +1,255 @@
# The Arsenal develop economy, and what the save's blob indexes
**2026-08-11.** The save file's 54-byte blob was shown to be per-item development
state by [a runtime differential](structures/savegame-format.md#the-develop-differential-one-weapon-three-fields) —
one weapon bought, two entries moved, `2 → 4` for the item and `0 → 2` for the
successor. That named the *values*. This note names the *index space*, and in
passing recovers the whole develop economy from the disc.
## Where the Arsenal gets its data
`dat/GP_HANGAR_ARSENAL.pak` carries one screen-config record per language whose
fields point at the rest:
```
PATH = dat\GP_HANGAR_ARSENAL.pak+eng\
WEAPON = weapon.tbl STRINGS = strings.tbl
WEAPON_CATEGORIES = 8 Detail_Window_Known / Detail_Window_Unknown
ConditionToDevelop = 757,228 WeaponDesc = 734,502 Range = 891,73 …
```
`WEAPON_CATEGORIES = 8` is the eight tabs the screen cycles with LB/RB (GUN,
BEAM, LASER, MULTIPURPOSE MISSILE, ANTI-SHIP MISSILE, BOMB/ROCKET, CANNON,
SPECIAL). The `…= x,y` fields are screen positions for that screen's labels —
`ConditionToDevelop` is *where the conditions text is drawn*, not a condition.
### `eng\weapon.tbl` — the item table (IDXD, schema `0x03c244b5`, 66 records)
Its string pool opens with a stage list, then the literal key `WEAPONS`
followed by the item ids **in table order**:
```
No_Equipment, Machiene_Cannon_MG1, Machiene_Cannon_MG2H, Machiene_Cannon_MG3,
Machiene_Cannon_MG5H, Broad_Sword_SG1, Swing_Sword_SG2H, Twin_Sword_SG3II,
Frail_GP37, Stiletto_BG1, Dagger_BG2, Rapier_BG4H, Pilum_BP, … , Wep_83,
Wep_84, Wep_85, NullWeapon_Arm1, NullWeapon_Arm2, NullWeapon_Arm3
```
The ids are grouped by the eight categories in the same order the screen shows
them — verified item-for-item against the running game (see the all-owned probe
below). Note one **coincidence that cost time**: the run `No_Equipment … Wep_83`
is also 54 entries, the save blob's length, which invites the conclusion that this
list *is* the blob's index space. It is not — the two agree only up to index 32.
The blob follows `strings.tbl`'s order, which carries items this list omits.
Each item then has a record: `Size, Weight, Range, Power, Speed, Stage,
MissionObjective, Dependency, Points`. For the **first** record all of it reads
straight out, and it matches the running game exactly:
| field | disc | in-game DATA SHEET for Light Machine Gun MG I |
|---|---|---|
| Range | `D` | `Range Class D` |
| Power | `E` | `Damage Class E` |
| Speed | `-` | `Speed Class ` |
| Weight | `0.3` | `Weight Class Light` |
| Points | `4000` | `4000 P` on the develop row |
**Caveat, and it is a real one:** IDXD string pools store each distinct string
*once*, so from the second record onward every value that repeats an earlier
string is absent from the token stream. Record 2 (MG2H) shows only its unique
values — `0.4` and `3000` — and record 6 shows no number at all because its cost
had already been seen. So `weapon.tbl`'s per-item fields cannot be read from the
pool alone; they need the binary node/index region, which is still undecoded
(see the module docs in `idxd.rs`). The prose table below is the practical
substitute.
### `eng\strings.tbl` — the economy in prose (IXUD, UTF-16BE)
The same pak's string table holds, per item: the internal id, the display name,
the description, and a **"Conditions to obtain …"** block that spells out the
gate and the price:
> Conditions to obtain Light Swivel Machine Gun MG2H — Completed development of
> the Light Machine Gun MG1 — Must spend 3000 points to develop
All 60 of them are extracted to
[`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv)
with the stage gate, the cost and the prerequisite split out. The shape of the
economy:
- gates are **stage completion** (`Complete Stage 1 … 15`), **a predecessor
item** (`Completed development of the …`), or **an ace kill** (`Shoot down the
ace pilot in Stage 11/13/14`);
- costs run 3 000 → 350 000 points, and **twenty items have no cost at all**
(`Tomahawk Alpha Rail Gun — Complete Stage 1`). Those are still *bought*, for
**0 P** — see the probe below, which caught one at `0 P` — which is why they
read as `Developed` in a list the player never spent points in;
- two items are anomalies worth flagging: `Adhesive Mine B2A` says "Must spend
points to develop" with **no number**, and ten late entries (`Ballista GSH`,
`Designator LH`, `Smoke Marker Launcher`, `Bumble UV`, `Ballista 24 Rocket
Launcher`, `Thrush 220AM`, `Divider L3GP`, `Mace GP25H`, `Thor Gun System`,
`Spitfire BX`) have a conditions *header* and no conditions — ❔ cut content or
non-player weapons.
## How the first 32 entries were confirmed
Predicting the save state from the conditions and comparing against the actual
blob is a strong test, because it is made **before** looking at the blob. At the
captured state (Stage 1 complete, Stage 02 at standby, 4101 P) the conditions say
exactly six items are developable — Light Machine Gun MG1, Broad Sword SG1,
Dagger BG2, Pilum BP, Hound SMH, Dart 23 Rocket — and the blob's **six** `2`s sit
at WEAPONS-order indices **1, 5, 10, 12, 27, 31**, which are those six items, in
order. Nothing had to be fitted.
Indices 032 are the same item in either order, so this table stands unchanged
under the final mapping.
| blob | value | item | confirmed by |
|---|---|---|---|
| 0 | 4 | `No_Equipment` | always available |
| 1 | 2→4 | `Machiene_Cannon_MG1` | the differential: bought for 4000 P |
| 2 | 0→2 | `Machiene_Cannon_MG2H` | the differential: announced as newly developable |
| 5 | 2 | `Broad_Sword_SG1` | `BROAD SWORD SG1 5000P`, **below the fold** in the GUN list |
| 9 | 4 | `Stiletto_BG1` | `Developed`; "Initially Mounted"; mounted as NOSE WEAPON |
| 10 | 2 | `Dagger_BG2` | `DAGGER BG2 5000P` |
| 12 | 2 | `Pilum_BP` | `PILUM BP 6000P` |
| 21 | 4 | `Falcon_9AM` | `Developed`; "Initially Mounted"; mounted as MAIN WEAPON 1 |
| 22 | 4 | `Buzzard_10AM` | `Developed`; free at Stage 1 |
| 26 | 4 | `Terrier_SMH` | `Developed`; free at Stage 1 |
| 27 | 2 | `Hound_SMH` | `HOUND SMH 10000P` |
| 31 | 2 | `Dirt_23_Rocket` | `DART 23 ROCKET 3500P` ("Dart" is the localised "Dirt") |
Twelve concordances, no contradiction, over indices 031.
## The full index space ✅ — it is the **localisation** order, cut items included
Written after the probes below, which settled it. The blob is **not** indexed by
`weapon.tbl`'s `WEAPONS` id list. It is indexed by the order the items appear in
`strings.tbl` — the same order, *plus* the handful of items that exist only there:
`Adhesive Mine B2A` (a "Conditions to obtain" block whose cost is missing) and the
four late entries with a conditions *header* and no conditions. Those never appear
in the Arsenal, but they **hold slots in the save**, and that is the whole reason
the tail looked broken.
| blob | block | items (display names, in order) |
|---|---|---|
| 0 | — | `No_Equipment` |
| 18 | GUN | Light Machine Gun MG1, Light Swivel MG2H, Heavy MG3, Heavy Swivel MG5H, Broad Sword SG1, Swing Sword SG2H, Twin Sword SG3II, Flail GP37 |
| 916 | BEAM | **Stiletto BG1** ⌾, Dagger BG2, Rapier BG4H, Pilum BP, Multi Pike BP, Spear HBP, Long Spear HBP, Gray Head T76H |
| 1720 | LASER | Saber LG1, Twin Saber LG2H, Needle L1GP, Fire Arrow L2GP |
| 2125 | MPM | **Falcon 9AM** ⌾, Buzzard 10AM, Hawk 75AM, Condor 105AM, Eagle 120AM |
| 2630 | ASM | Terrier SMH, Hound SMH, Pointer SM, Piranha T53, White Shark T53R |
| 3138 | BOMB/ROCKET | **Dart 23 Rocket** ⌾, Arrow 27 Rocket, *Adhesive Mine B2A — never displayed*, EMP Mine B8E, Laser Mine B9L, Cauldron 50 Rocket, Cluster Mine B10, Maelstrom Bomb |
| 3947 | CANNON | **Tomahawk Alpha Rail Gun** ⌾, Sling 75KG, Glaive 120mm Cannon, Arbalest 155KG, Monoceros Long Cannon, Grav Cannon XGS, *+ three never-displayed slots* |
| 4853 | SPECIAL | **Jamming System** ⌾, Shield Doubler, Booster, Fire Control System, Regenerator, Cartridge Holder |
⌾ = pinned by an anchor: `9`, `21`, `39` and `48` were each **written into a save by
hand** and read back off the screen; `31` is the Dart 23 Rocket's price row in the
untouched save. The array ends exactly at 53 with the last SPECIAL item, with
nothing left over — 8+8+4+5+5+8+9+6 plus `No_Equipment` is 54.
The three never-displayed CANNON-block slots are three of `Ballista GSH`,
`Designator LH`, `Smoke Marker Launcher`, `Bumble UV` — the four cut entries
`strings.tbl` lists between Grav Cannon XGS and Jamming System. **Which three** is
🟡 undetermined and does not matter for reading a save; nothing displays them.
**This explains every anomaly in the retail save.** Its `4`s at 33 and at 45/46/47
are the cut items — `Adhesive Mine B2A` and three of the four above — shipped
flagged as owned. They never render, so no player ever sees them, and they are
exactly the five "unexplained" owned entries that made the tail look wrong. The
Tomahawk reading `Developed` at `blob[39]` while `weapon.tbl` puts it at 38 was
the same off-by-N, seen from the other side.
## How the tail was actually settled (and what was wrong first)
Past index 31 the `weapon.tbl` alignment fails, and it fails against the screen,
not against a theory — this is the evidence that sent the investigation to the
localisation order above:
- `Tomahawk_a_Rail_Gun` is WEAPONS index **38** and the CANNON list shows it
`Developed` (it is free once Stage 1 is complete) — but **blob[38] = 0**.
- The blob's remaining `4`s are at **33, 39, 45, 46, 47**. In WEAPONS order those
are `EMP_Mine_B8E`, `Sling_75KG`, `Shield_Doubler`, `Booster`,
`FireControlSystem` — and **every one of them is shown as not developed**:
BOMB/ROCKET lists only `DART 23 ROCKET 3500P`, CANNON only the Tomahawk, and
**the SPECIAL tab is entirely empty** (all rows dashed). Their conditions agree
— Shield Doubler needs Stage 12, Booster an ace kill in Stage 11, FCS Stage 6.
So five `4`s in the tail correspond to nothing the Arsenal reports as owned, and
one genuinely-owned item reads `0`. A single `+1` shift does not repair it
either: `blob[39] = 4` would put the Tomahawk right, but then `blob[33] = 4`
lands on `Arrow_27_Rocket` (Stage 2 + 5000 P, unreachable here) and 45/46/47
still land on locked SPECIAL items.
### Tested actively, with a hand-written save
Waiting for a mission payout was not necessary. The save container's derived
fields turned out to be reproducible (see
[`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) — the whole
container re-wraps **byte-identically**), so the blob could be *written* and the
result read off the screen. A probe save was built in our own throwaway slot 03
with the blob zeroed except `0, 9, 21, 33, 38, 45 = 4` and `39, 46 = 2`, and it
**loaded**.
What it showed:
- **The controls rendered.** `9` → BEAM shows `STILETTO BG1 Developed`; `21`
MULTIPURPOSE MISSILE shows `FALCON 9AM Developed`. So a hand-written `4` does
reach the screen, and indices 9 and 21 are those items — now by experiment,
not by correlation.
- **The tail probes rendered nothing.** `33` should have made a BOMB/ROCKET row
`Developed` (row 3 of that list, on screen, not below the fold) — it stayed
dashed. `45` should have made a SPECIAL row `Developed` — the SPECIAL tab
stayed entirely empty. And `38` should have been the Tomahawk — instead the
CANNON list showed `TOMAHAWK ALPHA RAIL GUN — 0 P`, i.e. *not owned*.
- **Clearing the real save's tail `4`s cost the Tomahawk its status.** In the
unedited save the Tomahawk reads `Developed` while `blob[38] = 0`; the probe
cleared `{22, 26, 39, 46, 47}` and the Tomahawk stopped being owned — so its
flag is one of the cleared entries, with **39** the positional candidate. A
uniform `+1` shift past index 32 would explain that, but it fails elsewhere:
under it the real save's `45/46/47` are Chaff Flare Dispencer / Shield Doubler
/ Booster, and the SPECIAL tab is empty in that same save.
Three further probes settled it, and the reasoning is worth keeping because the
first read of the same evidence was wrong.
1. **Every slot owned.** With all 54 entries set to `4`, every tab filled and the
lists **wrap**, which gives exact counts: GUN 8, BEAM 8, LASER 4, MPM 5, ASM 5,
BOMB/ROCKET **7**, CANNON 6, SPECIAL 6 — 43 items — and the display order is
`weapon.tbl`'s order, item for item. Note what this probe *cannot* do: with
everything owned, any surjective mapping looks the same. It bounds, it does not
pin. It also killed the "gate-unmet flags are suppressed" idea outright —
Maelstrom Bomb (Stage 6) and Grav Cannon XGS (Stage 12) both rendered
`Developed` at a Stage-2 save.
2. **One slot owned: `39`.** `blob = 0` except `9` (control) and `39` → BEAM showed
`STILETTO BG1 Developed` **and CANNON showed `TOMAHAWK ALPHA RAIL GUN
Developed`**. So the CANNON block starts at 39, not 38: exactly one slot more
than the display between Dart 23 (31) and the Tomahawk.
3. **One slot owned: `48`.** `blob = 0` except `39` and `48` → SPECIAL showed
`JAMMING SYSTEM Developed`. That fixes the SPECIAL base, and with six SPECIAL
items the array closes exactly at 53.
The earlier probe's `45 = 4` leaving SPECIAL empty — which had looked like the
mapping failing — is simply a cut item: 45 is in the CANNON block's
never-displayed tail, not in SPECIAL at all.
### Two behaviours the probe exposed
- **`2` is derived, not stored.** On load the title recomputes which items are
developable from its own conditions and *announces the difference* — zeroing
Light Machine Gun MG1 and Broad Sword SG1 produced "You can now develop Broad
Sword …" and both came back priced in the list. Only the owned (`4`) entries
are authoritative state; writing `2` is pointless, and writing `0` over an
item whose conditions are met is undone at load.
- **The free items are bought for zero, not granted.** `TOMAHAWK ALPHA RAIL GUN
— 0 P` is what an unowned no-cost item looks like. That is the real reason a
save where the player never spent points still shows items as `Developed`
(this note previously said "granted" — the mechanism is a 0 P purchase).
## Evidence
- [`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv) — all 60 conditions blocks.
- [`captures/arsenal-categories-all.png`](captures/arsenal-categories-all.png) — the eight category tabs.
- [`captures/arsenal-gun-below-fold.png`](captures/arsenal-gun-below-fold.png) — Broad Sword SG1, the entry that is not visible without scrolling.
- [`captures/arsenal-probe-save-result.png`](captures/arsenal-probe-save-result.png) — the hand-written save's result: controls at 9/21 render `Developed`, the tail probes render nothing, and the Tomahawk shows `0 P`.
- [`captures/arsenal-gun-list-predevelop.png`](captures/arsenal-gun-list-predevelop.png) · [`captures/arsenal-mg1-developed.png`](captures/arsenal-mg1-developed.png) — the differential itself.

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

View File

@@ -0,0 +1,61 @@
item,stage_required,points_cost,other_conditions,raw_text
Light Machine Gun MG1,1,4000,,Conditions to obtain Light Machine Gun MG1 - Complete Stage 1 - Must spend 4000 points to develop
Light Swivel Machine Gun MG2H,,3000,Completed development of the Light Machine Gun MG1,Conditions to obtain Light Swivel Machine Gun MG2H - Completed development of the Light Machine Gun MG1 - Must spend 3000 points to develop
Heavy Machine Gun MG3,6,6000,,Conditions to obtain Heavy Machine Gun MG3 - Complete Stage 6 - Must spend 6000 points to develop
Heavy Swivel Machine Gun MG5H,,10000,Completed development of the Heavy Machine Gun MG3,Conditions to obtain Heavy Swivel Machine Gun MG5H - Completed development of the Heavy Machine Gun MG3 - Must spend 10000 points to develop
Broad Sword SG1,1,5000,,Conditions to obtain Broad Sword SG1 - Complete Stage 1 - Must spend 5000 points to develop
Swing Sword SG2H,,10000,Completed development of the Twin Sword SG3II,Conditions to obtain Swing Sword SG2H - Completed development of the Twin Sword SG3II - Must spend 10000 points to develop
Twin Sword SG3II,,8000,Completed development of the Broad Sword SG1,Conditions to obtain Twin Sword SG3II - Completed development of the Broad Sword SG1 - Must spend 8000 points to develop
Flail GP37,6,,,Conditions to obtain Flail GP37 - Complete Stage 6
Stiletto BG1,,,Initially Mounted,Conditions to obtain Stiletto BG1 - Initially Mounted
Dagger BG2,,5000,Development Possible,Conditions to obtain Dagger BG2 - Development Possible - Must spend 5000 points to develop
Rapier BG4H,,12000,Completed development of the Dagger BG2,Conditions to obtain Rapier BG4H - Completed development of the Dagger BG2 - Must spend 12000 points to develop
Pilum BP,,6000,Development Possible,Conditions to obtain Pilum BP - Development Possible - Must spend 6000 points to develop
Multi Pike BP,,30000,Completed development of the Pilum BP,Conditions to obtain Multi Pike BP - Completed development of the Pilum BP - Must spend 30000 points to develop
Spear HBP,,10000,Completed development of the Multi Pike BP,Conditions to obtain Spear HBP - Completed development of the Multi Pike BP - Must spend 10000 points to develop
Long Spear HBP,7,350000,Completed development of the Gray Head T76H,Conditions to obtain Long Spear HBP - Complete Stage 7 - Completed development of the Gray Head T76H - Must spend 350000 points to develop
Gray Head T76H,,25000,Completed development of the Spear HBP,Conditions to obtain Gray Head T76H - Completed development of the Spear HBP - Must spend 25000 points to develop
Saber LG1,14,,,Conditions to obtain Saber LG1 - Complete Stage 14
Twin Saber LG2H,15,,,Conditions to obtain Twin Saber LG2H - Complete Stage 15
Needle L1GP,6,10000,,Conditions to obtain Needle L1GP - Complete Stage 6 - Must spend 10000 points to develop
Fire Arrow L2GP,,20000,Completed development of the Needle L1GP,Conditions to obtain Fire Arrow L2GP - Completed development of the Needle L1GP - Must spend 20000 points to develop
Falcon 9AM,,,Initially Mounted,Conditions to obtain Falcon 9AM - Initially Mounted
Buzzard 10AM,1,,,Conditions to obtain Buzzard 10AM - Complete Stage 1
Hawk 75AM,6,10000,Possess Buzzard 10AM,Conditions to obtain Hawk 75AM - Complete Stage 6 - Possess Buzzard 10AM - Must spend 10000 points to develop
Condor 105AM,6,,,Conditions to obtain Condor 105AM - Complete Stage 6
Eagle 120AM,12,15000,Completed development of the Hawk 75AM,Conditions to obtain Eagle 120AM - Complete Stage 12 - Completed development of the Hawk 75AM - Must spend 15000 points to develop
Terrier SMH,1,,,Conditions to obtain Terrier SMH - Complete Stage 1
Hound SMH,,10000,Development Possible,Conditions to obtain Hound SMH - Development Possible - Must spend 10000 points to develop
Pointer SM,,40000,Completed development of Piranha T53,Conditions to obtain Pointer SM - Completed development of Piranha T53 - Must spend 40000 points to develop
Piranha T53,,25000,Obtain White Shark T53R,Conditions to obtain Piranha T53 - Obtain White Shark T53R - Must spend 25000 points to develop
White Shark T53R,6,,,Conditions to obtain White Shark T53R - Complete Stage 6
Dart 23 Rocket,1,3500,,Conditions to obtain Dart 23 Rocket - Complete Stage 1 - Must spend 3500 points to develop
Arrow 27 Rocket,2,5000,,Conditions to obtain Arrow 27 Rocket - Complete Stage 2 - Must spend 5000 points to develop
Adhesive Mine B2A,3,MISSING,,Conditions to obtain Adhesive Mine B2A - Complete Stage 3 - Must spend points to develop
EMP Mine B8E,6,50000,,Conditions to obtain EMP Mine B8E - Complete Stage 6 - Must spend 50000 points to develop
Laser Mine B9L,6,15000,,Conditions to obtain Laser Mine B9L - Complete Stage 6 - Must spend 15000 points to develop
Cauldron 50 Rocket,14,,,Conditions to obtain Cauldron 50 Rocket - Complete Stage 14
Cluster Mine B10,6,5000,,Conditions to obtain Cluster Mine B10 - Complete Stage 6 - Must spend 5000 points to develop
Maelstrom Bomb,6,200000,,Conditions to obtain Maelstrom Bomb - Complete Stage 6 - Must spend 200000 points to develop
Tomahawk Alpha Rail Gun,1,,,Conditions to obtain Tomahawk Alpha Rail Gun - Complete Stage 1
Sling 75KG,2,5000,,Conditions to obtain Sling 75KG - Complete Stage 2 - Must spend 5000 points to develop
Glaive 120mm Cannon,6,,,Conditions to obtain Glaive 120mm Cannon - Complete Stage 6
Arbalest 155KG,6,25000,,Conditions to obtain Arbalest 155KG - Complete Stage 6 - Must spend 25000 points to develop
Monoceros Long Cannon,,,Shoot down the ace pilot in Stage 14,Conditions to obtain Monoceros Long Cannon - Shoot down the ace pilot in Stage 14
Grav Cannon XGS,12,60000,,Conditions to obtain Grav Cannon XGS - Complete Stage 12 - Must spend 60000 points to develop
Ballista GSH,,,,Conditions to obtain Ballista GSH
Designator LH,,,,Conditions to obtain Designator LH
Smoke Marker Launcher,,,,Conditions to obtain Smoke Marker Launcher
Bumble UV,,,,Conditions to obtain Bumble UV
Jamming System,3,15000,,Conditions to obtain Jamming System - Complete Stage 3 - Must spend 15000 points to develop
Shield Doubler,12,50000,,Conditions to obtain Shield Doubler - Complete Stage 12 - Must spend 50000 points to develop
Booster,,,Shoot down the ace pilot in Stage 11,Conditions to obtain Booster - Shoot down the ace pilot in Stage 11
Fire Control System,6,15000,,Conditions to obtain Fire Control System - Complete Stage 6 - Must spend 15000 points to develop
Regenerator,12,100000,,Conditions to obtain Regenerator - Complete Stage 12 - Must spend 100000 points to develop
Cartridge Holder,,,Shoot down the ace pilot in Stage 13,Conditions to obtain Cartridge Holder - Shoot down the ace pilot in Stage 13
Ballista 24 Rocket Launcher,,,,Conditions to obtain Ballista 24 Rocket Launcher
Thrush 220AM,,,,Conditions to obtain Thrush 220AM
Divider L3GP,,,,Conditions to obtain Divider L3GP
Mace GP25H,,,,Conditions to obtain Mace GP25H
Thor Gun System,,,,Conditions to obtain Thor Gun System
Spitfire BX,,,,Conditions to obtain Spitfire BX
1 item stage_required points_cost other_conditions raw_text
2 Light Machine Gun MG1 1 4000 Conditions to obtain Light Machine Gun MG1 - Complete Stage 1 - Must spend 4000 points to develop
3 Light Swivel Machine Gun MG2H 3000 Completed development of the Light Machine Gun MG1 Conditions to obtain Light Swivel Machine Gun MG2H - Completed development of the Light Machine Gun MG1 - Must spend 3000 points to develop
4 Heavy Machine Gun MG3 6 6000 Conditions to obtain Heavy Machine Gun MG3 - Complete Stage 6 - Must spend 6000 points to develop
5 Heavy Swivel Machine Gun MG5H 10000 Completed development of the Heavy Machine Gun MG3 Conditions to obtain Heavy Swivel Machine Gun MG5H - Completed development of the Heavy Machine Gun MG3 - Must spend 10000 points to develop
6 Broad Sword SG1 1 5000 Conditions to obtain Broad Sword SG1 - Complete Stage 1 - Must spend 5000 points to develop
7 Swing Sword SG2H 10000 Completed development of the Twin Sword SG3II Conditions to obtain Swing Sword SG2H - Completed development of the Twin Sword SG3II - Must spend 10000 points to develop
8 Twin Sword SG3II 8000 Completed development of the Broad Sword SG1 Conditions to obtain Twin Sword SG3II - Completed development of the Broad Sword SG1 - Must spend 8000 points to develop
9 Flail GP37 6 Conditions to obtain Flail GP37 - Complete Stage 6
10 Stiletto BG1 Initially Mounted Conditions to obtain Stiletto BG1 - Initially Mounted
11 Dagger BG2 5000 Development Possible Conditions to obtain Dagger BG2 - Development Possible - Must spend 5000 points to develop
12 Rapier BG4H 12000 Completed development of the Dagger BG2 Conditions to obtain Rapier BG4H - Completed development of the Dagger BG2 - Must spend 12000 points to develop
13 Pilum BP 6000 Development Possible Conditions to obtain Pilum BP - Development Possible - Must spend 6000 points to develop
14 Multi Pike BP 30000 Completed development of the Pilum BP Conditions to obtain Multi Pike BP - Completed development of the Pilum BP - Must spend 30000 points to develop
15 Spear HBP 10000 Completed development of the Multi Pike BP Conditions to obtain Spear HBP - Completed development of the Multi Pike BP - Must spend 10000 points to develop
16 Long Spear HBP 7 350000 Completed development of the Gray Head T76H Conditions to obtain Long Spear HBP - Complete Stage 7 - Completed development of the Gray Head T76H - Must spend 350000 points to develop
17 Gray Head T76H 25000 Completed development of the Spear HBP Conditions to obtain Gray Head T76H - Completed development of the Spear HBP - Must spend 25000 points to develop
18 Saber LG1 14 Conditions to obtain Saber LG1 - Complete Stage 14
19 Twin Saber LG2H 15 Conditions to obtain Twin Saber LG2H - Complete Stage 15
20 Needle L1GP 6 10000 Conditions to obtain Needle L1GP - Complete Stage 6 - Must spend 10000 points to develop
21 Fire Arrow L2GP 20000 Completed development of the Needle L1GP Conditions to obtain Fire Arrow L2GP - Completed development of the Needle L1GP - Must spend 20000 points to develop
22 Falcon 9AM Initially Mounted Conditions to obtain Falcon 9AM - Initially Mounted
23 Buzzard 10AM 1 Conditions to obtain Buzzard 10AM - Complete Stage 1
24 Hawk 75AM 6 10000 Possess Buzzard 10AM Conditions to obtain Hawk 75AM - Complete Stage 6 - Possess Buzzard 10AM - Must spend 10000 points to develop
25 Condor 105AM 6 Conditions to obtain Condor 105AM - Complete Stage 6
26 Eagle 120AM 12 15000 Completed development of the Hawk 75AM Conditions to obtain Eagle 120AM - Complete Stage 12 - Completed development of the Hawk 75AM - Must spend 15000 points to develop
27 Terrier SMH 1 Conditions to obtain Terrier SMH - Complete Stage 1
28 Hound SMH 10000 Development Possible Conditions to obtain Hound SMH - Development Possible - Must spend 10000 points to develop
29 Pointer SM 40000 Completed development of Piranha T53 Conditions to obtain Pointer SM - Completed development of Piranha T53 - Must spend 40000 points to develop
30 Piranha T53 25000 Obtain White Shark T53R Conditions to obtain Piranha T53 - Obtain White Shark T53R - Must spend 25000 points to develop
31 White Shark T53R 6 Conditions to obtain White Shark T53R - Complete Stage 6
32 Dart 23 Rocket 1 3500 Conditions to obtain Dart 23 Rocket - Complete Stage 1 - Must spend 3500 points to develop
33 Arrow 27 Rocket 2 5000 Conditions to obtain Arrow 27 Rocket - Complete Stage 2 - Must spend 5000 points to develop
34 Adhesive Mine B2A 3 MISSING Conditions to obtain Adhesive Mine B2A - Complete Stage 3 - Must spend points to develop
35 EMP Mine B8E 6 50000 Conditions to obtain EMP Mine B8E - Complete Stage 6 - Must spend 50000 points to develop
36 Laser Mine B9L 6 15000 Conditions to obtain Laser Mine B9L - Complete Stage 6 - Must spend 15000 points to develop
37 Cauldron 50 Rocket 14 Conditions to obtain Cauldron 50 Rocket - Complete Stage 14
38 Cluster Mine B10 6 5000 Conditions to obtain Cluster Mine B10 - Complete Stage 6 - Must spend 5000 points to develop
39 Maelstrom Bomb 6 200000 Conditions to obtain Maelstrom Bomb - Complete Stage 6 - Must spend 200000 points to develop
40 Tomahawk Alpha Rail Gun 1 Conditions to obtain Tomahawk Alpha Rail Gun - Complete Stage 1
41 Sling 75KG 2 5000 Conditions to obtain Sling 75KG - Complete Stage 2 - Must spend 5000 points to develop
42 Glaive 120mm Cannon 6 Conditions to obtain Glaive 120mm Cannon - Complete Stage 6
43 Arbalest 155KG 6 25000 Conditions to obtain Arbalest 155KG - Complete Stage 6 - Must spend 25000 points to develop
44 Monoceros Long Cannon Shoot down the ace pilot in Stage 14 Conditions to obtain Monoceros Long Cannon - Shoot down the ace pilot in Stage 14
45 Grav Cannon XGS 12 60000 Conditions to obtain Grav Cannon XGS - Complete Stage 12 - Must spend 60000 points to develop
46 Ballista GSH Conditions to obtain Ballista GSH
47 Designator LH Conditions to obtain Designator LH
48 Smoke Marker Launcher Conditions to obtain Smoke Marker Launcher
49 Bumble UV Conditions to obtain Bumble UV
50 Jamming System 3 15000 Conditions to obtain Jamming System - Complete Stage 3 - Must spend 15000 points to develop
51 Shield Doubler 12 50000 Conditions to obtain Shield Doubler - Complete Stage 12 - Must spend 50000 points to develop
52 Booster Shoot down the ace pilot in Stage 11 Conditions to obtain Booster - Shoot down the ace pilot in Stage 11
53 Fire Control System 6 15000 Conditions to obtain Fire Control System - Complete Stage 6 - Must spend 15000 points to develop
54 Regenerator 12 100000 Conditions to obtain Regenerator - Complete Stage 12 - Must spend 100000 points to develop
55 Cartridge Holder Shoot down the ace pilot in Stage 13 Conditions to obtain Cartridge Holder - Shoot down the ace pilot in Stage 13
56 Ballista 24 Rocket Launcher Conditions to obtain Ballista 24 Rocket Launcher
57 Thrush 220AM Conditions to obtain Thrush 220AM
58 Divider L3GP Conditions to obtain Divider L3GP
59 Mace GP25H Conditions to obtain Mace GP25H
60 Thor Gun System Conditions to obtain Thor Gun System
61 Spitfire BX Conditions to obtain Spitfire BX

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

View File

@@ -0,0 +1,50 @@
=== eng\prmain_scr.prt ===
23 elements
# element parent kind pivot kf placement
0 preff01.t32 - 0x0 (164,156) 5 rest (256,214) t=6..22 [6:256,214 22:256,214 31:256,214 42:256,214 1:256,214]
1 preff02.t32 - 0x0 (155,175) 7 rest (156,121) t=8..37 [4:305,240 6:193,150 7:165,128 8:156,121 37:155,120 44:155,120 2:305,240]
2 preff03.t32 - 0x0 (155,175) 7 rest (374,269) t=8..37 [4:225,150 6:337,240 7:365,262 8:374,269 37:375,270 44:375,270 3:225,150]
3 preff04.t32 - 0x0 (247,237) 6 rest (173,133) t=8..37 [4:173,133 6:173,133 8:173,133 37:173,133 44:173,133 4:173,133]
4 prwinbase.t32 - 0x0 (136,130) 5 rest (284,240) t=14..35 [6:284,240 14:284,240 35:284,240 43:284,240 5:284,240]
5 prbtn1.rat - 0x3002 (28,14) 5 rest (242,166) t=16..33 [10:242,166 16:242,166 33:242,166 37:242,166 6:242,166]
6 prbtn2.rat - 0x3002 (43,14) 5 rest (242,220) t=17..33 [11:242,220 17:242,220 33:242,220 37:242,220 7:242,220]
7 prbtn3.rat - 0x3002 (58,14) 5 rest (242,276) t=18..33 [12:242,276 18:242,276 33:242,276 37:242,276 8:242,276]
8 prbtn4.rat - 0x3002 (89,14) 5 rest (242,331) t=19..33 [13:242,331 19:242,331 33:242,331 37:242,331 9:242,331]
9 prbtn5.rat - 0x3002 (89,14) 5 rest (242,385) t=20..33 [14:242,385 20:242,385 33:242,385 37:242,385 10:242,385]
10 prbtn6.rat - 0x3002 (104,14) 5 rest (242,441) t=21..33 [15:242,441 21:242,441 33:242,441 37:242,441 11:242,441]
11 prbtn7.rat - 0x3002 (58,14) 5 rest (242,496) t=22..33 [16:242,496 22:242,496 33:242,496 37:242,496 12:242,496]
12 prbtn8.rat - 0x3002 (30,14) 5 rest (242,551) t=23..33 [17:242,551 23:242,551 33:242,551 37:242,551 13:242,551]
13 prexp1.t32 - 0x0 (277,30) 8 rest (726,143) t=31..37 [21:726,143 25:1286,143 27:866,143 28:756,143 29:731,143 31:726,143 37:726,143 14:1286,143]
14 prexp1a.t32 13 0x1 (277,30) 3 rest (1126,143) t=31..37 [31:1126,143 37:1126,143 15:1126,143]
15 prexp3.t32 - 0x0 (205,5) 5 rest (726,381) t=10..29 [10:726,381 29:726,381 31:726,381 39:726,381 16:726,381]
16 prmsg.t32 - 0x0 (330,19) 5 rest (151,645) t=21..29 [21:151,645 29:151,645 31:151,645 37:151,645 17:151,645]
17 prexp3.t32 - 0x4 (205,5) 5 rest (726,415) t=10..29 [10:726,415 29:726,415 31:726,415 39:726,415 18:726,415]
18 prexp3.t32 - 0x4 (205,5) 5 rest (726,449) t=10..29 [10:726,449 29:726,449 31:726,449 39:726,449 19:726,449]
19 prexp3.t32 - 0x4 (205,5) 5 rest (726,483) t=10..29 [10:726,483 29:726,483 31:726,483 39:726,483 20:726,483]
20 prexp3.t32 - 0x4 (205,5) 5 rest (726,517) t=10..29 [10:726,517 29:726,517 31:726,517 39:726,517 21:726,517]
21 prexp3.t32 - 0x4 (205,5) 5 rest (726,551) t=10..29 [10:726,551 29:726,551 31:726,551 39:726,551 22:726,551]
22 prexp3.t32 - 0x4 (205,5) 5 rest (726,585) t=39..1869640736 [10:726,585 29:726,585 31:726,585 39:726,585 1869640736:726,585]
=== eng\prselect_scr.prt ===
8 elements
# element parent kind pivot kf placement
0 prselect_win1.t32 - 0x0 (256,200) 10 rest (127,155) t=8..23 [4:-516,155 6:-71,155 7:81,155 8:127,155 23:134,155 24:134,155 25:127,155 27:81,155 31:-71,155 1:-516,155]
1 prselect_win2.t32 0 0x1 (256,200) 1 (135,560)
2 prselect_win1eff.t32 0 0x1 (256,200) 5 rest (71,92) t=2..14 [2:71,92 14:71,92 21:71,92 27:71,92 3:71,92]
3 prselect_win2eff.t32 0 0x1 (256,200) 5 rest (71,499) t=2..14 [2:71,499 14:71,499 21:71,499 27:71,499 4:71,499]
4 prselect_sbar.sbo 0 0x1 (256,200) 1 (614,172)
5 prselect_type_line.t32 - 0x0 (260,24) 5 rest (123,142) t=14..21 [8:123,142 14:123,142 21:123,142 25:123,142 6:123,142]
6 prselect_msg.t32 - 0x0 (468,19) 5 rest (151,645) t=11..19 [11:151,645 19:151,645 21:151,645 27:151,645 7:151,645]
7 prselect_msgbtn1.t32 - 0x2 (128,19) 5 rest (809,645) t=27..1869640736 [11:809,645 19:809,645 21:809,645 27:809,645 1869640736:809,645]
=== eng\prselect_win3.prt ===
2 elements
# element parent kind pivot kf placement
0 prselect_win3.t32 - 0x0 (245,300) 4 rest (704,39) t=10..16 [8:704,39 10:704,39 16:704,39 1:704,39]
1 prselect_win3eff.t32 - 0x0 (309,352) 4 rest (640,0) t=16..1869640736 [8:640,0 10:640,0 16:640,0 1869640736:640,0]
=== eng\prtitle.prt ===
5 elements
# element parent kind pivot kf placement
0 preff11.t32 - 0x0 (221,6) 6 rest (-160,82) t=16..35 [12:-440,82 14:-440,82 16:-160,82 35:0,82 41:0,82 1:-440,82]
1 preff12.t32 - 0x0 (75,3) 5 rest (0,72) t=20..34 [14:0,72 20:0,72 34:0,72 38:0,72 2:0,72]
2 preff13.t32 - 0x0 (53,3) 5 rest (407,78) t=22..33 [16:407,78 22:407,78 33:407,78 37:407,78 3:407,78]
3 preff14.t32 - 0x0 (52,3) 5 rest (427,68) t=24..32 [18:427,68 24:427,68 32:427,68 36:427,68 4:427,68]
4 prtitle.t32 - 0x0 (94,35) 7 rest (162,26) t=37..1869640736 [20:142,26 23:142,26 25:157,26 26:161,26 31:162,26 37:162,26 1869640736:142,26]

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

View File

@@ -0,0 +1,115 @@
id hp size_x size_y size_z kind
UN_e006_ADAN_Vindicator_MargrasF 6400 12 5 30 unit
UN_be005_ADAN_SpaceFortress 10000 2500 3100 2600 unit
UN_f001_TCAF_DeltaSaber_T 1000 10 7 29 unit
UN_f002_TCAF_DeltaSaber_W 1000 10 7 29 unit
UN_e009_ADAN_Phantom 1000 18 16 24 unit
UN_e001_ADAN_Elan_EX4 500 25 10 30 unit
UN_e011_ADAN_Attacker_B 1500 200 50 100 unit
UN_f001_TCAF_DeltaSaber_T_Ttrl 1000 10 7 29 unit
UN_f202_TCAF_Cargo 4000 1400 600 2000 unit
UN_e007_ADAN_Turret_EX4 100 20 9 22 unit
UN_e010_ADAN_Attacker_S 500 100 40 50 unit
UN_be001_ADAN_TerrafoamingUnit 10000 2600 2200 — unit
UN_e005_ADAN_ElanTypeQ_Margras 3200 24 15 38 unit
UN_e001_ADAN_Elan_GR_Violeta 1200 25 10 30 unit
UN_e004_ADAN_ElanPlus_N 800 24 10 30 unit
UN_e201_ADAN_ISCMissile 3000 300 — 2800 unit
UN_f001_TCAF_DeltaSaber_T_Player_Ttrl1 1500 10 7 29 unit
UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2 1500 10 7 29 unit
UN_f003_TCAF_ArrowHead_EX4 300 10 5 20 unit
UN_f003_TCAF_ArrowHead_EX5 300 10 5 20 unit
UN_e011_ADAN_Attacker_B_HF 2250 200 50 100 unit
UN_e015_ADAN_Puppy — 7 5.5 10 unit
Ship_Test 100 20 14 29 unit
UN_e010_ADAN_Attacker_S_HF 750 100 40 50 unit
Test_ADAN_PrometheusDriver 10000 1 — — unit
UN_f001_TCAF_DeltaSaber_T_EX5 1000 10 7 29 unit
UN_e002_ADAN_Elan_N 800 20 10 30 unit
UN_e008_ADAN_TurretPlus 300 24 9 35 unit
UN_mn040_Asteroid_Big 10000 2000 3500 — unit
UN_mn500_ADAN_FloatingMine 1000 200 — — unit
UN_S04_Asteroid_cmesh_01 10000 46900 55500 13600 unit
UN_S05_Asteroid_cmesh_01 10000 10400 13000 34400 unit
UN_S04_Asteroid_cmesh_03 10000 42800 37700 47400 unit
UN_S04_Asteroid_cmesh_04 10000 59000 56900 21800 unit
UN_S05_Asteroid_cmesh_03 10000 8300 13000 19800 unit
UN_S04_Asteroid_cmesh_05 — 26000 21800 72400 unit
UN_S05_Asteroid_cmesh_04 10000 7400 15800 30100 unit
UN_e011_ADAN_Attacker_B_EX4 1500 200 50 100 unit
UN_e910_core_ADAN_GeneratorCore 10000 2000 — — unit
UN_n001_TTRL_Box 100 0.1 — — unit
UN_e006_ADAN_Vindicator_Margras 6400 12 5 30 unit
UN_e010_ADAN_Attacker_S_EX4 500 100 40 50 unit
UN_bf001_TCAF_SchlosBase 10000 2500 3100 2600 unit
UN_f001_TCAF_DeltaSaber_T_EX5_el 1000 10 7 29 unit
UN_n001_TTRL_Box_move 100 0.1 — — unit
UN_e004_ADAN_ElanPlus_NF 800 24 10 30 unit
Test_ADAN_PrometheusDriver_InsideP2_01 10000 1 — — unit
UN_e001_ADAN_Elan 500 25 10 30 unit
UN_e007_ADAN_Turret 100 20 9 22 unit
UN_e003_ADAN_ElanPlus_Margras 1600 24 10 30 unit
UN_e901_ADAN_Boss 10000 500 1700 — unit
UN_e013_ADAN_ElanPlus_Taskent 1300 24 10 30 unit
UN_e011_ADAN_Attacker_B_HF_Wayne 3000 200 50 100 unit
UN_f003_TCAF_ArrowHead 300 10 5 20 unit
UN_f004_TCAF_DeltaSaber_A_Player 1500 10 7 29 unit
UN_e010_ADAN_Attacker_S_HF_Wayne 1000 100 40 50 unit
UN_e015_ADAN_Puppy_2 — 7 5.5 10 unit
UN_S01_Asteroid_cmesh_01a — 23000 21700 13400 unit
UN_S01_Asteroid_cmesh_02a 10000 20900 24000 23800 unit
UN_S01_Asteroid_cmesh_01b — 18000 20900 14200 unit
UN_S01_Asteroid_cmesh_02b — 20400 21800 26700 unit
UN_S01_Asteroid_cmesh_03a 10000 23600 11700 30300 unit
UN_bf002_TCAF_TransitPlatform 10000 2500 3100 4600 unit
UN_e001_ADAN_Elan_GR 800 25 10 30 unit
UN_S13_Asteroid_cmesh_01a 10000 14200 10200 14700 unit
UN_S01_Asteroid_cmesh_03b — 21800 18600 37200 unit
UN_S01_Asteroid_cmesh_04a — 32800 19900 23300 unit
UN_S13_Asteroid_cmesh_01b 10000 8000 5400 16400 unit
UN_S13_Asteroid_cmesh_02a 10000 6200 5400 11300 unit
UN_S01_Asteroid_cmesh_04b — 26400 23100 19600 unit
UN_S04_Asteroid_cmesh_02a — 25100 20800 61400 unit
UN_S13_Asteroid_cmesh_02b 10000 5100 6900 10500 unit
UN_S05_Asteroid_cmesh_02a 10000 10100 13200 9100 unit
UN_S04_Asteroid_cmesh_02b 10000 25200 19700 67000 unit
UN_S05_Asteroid_cmesh_02b 10000 8700 14900 12300 unit
UN_S08_Asteroid_cmesh_01a 10000 14700 14900 21300 unit
UN_S08_Asteroid_cmesh_01b 10000 14900 14600 15800 unit
UN_S08_Asteroid_cmesh_02a 10000 17400 13400 15200 unit
UN_S04_Asteroid_cmesh_06a — 14400 20800 45900 unit
UN_S08_Asteroid_cmesh_02b 10000 16800 10700 14200 unit
UN_S08_Asteroid_cmesh_03a — 14000 10000 13700 unit
UN_S04_Asteroid_cmesh_06b 10000 14100 16200 21500 unit
UN_S04_Asteroid_cmesh_07a — 23200 14000 67100 unit
UN_S08_Asteroid_cmesh_03b 10000 13900 15100 15700 unit
UN_S04_Asteroid_cmesh_07b 10000 25300 4900 40600 unit
UN_e008_ADAN_TurretPlus_EX4 300 24 9 35 unit
UN_f001_TCAF_DeltaSaber_T_Player 1500 10 7 29 unit
UN_f003_TCAF_ArrowHead_Yoji 400 10 5 20 unit
UN_f002_TCAF_DeltaSaber_W_Player 1500 10 7 29 unit
UN_e104_ADAN_Carrier 20000 2300 1000 3300 vessel
UN_f106_TCAF_Destroyer 10000 200 — 2000 vessel
UN_e105_ADAN_Cruiser 30000 600 — 3800 vessel
UN_e101_ADAN_SDBattleship 100000 1900 2100 12300 vessel
UN_f105_TCAF_Cruiser 30000 700 — 3800 vessel
UN_f102_TCAF_LightCarrier 20000 500 — 1600 vessel
UN_e102_ADAN_BattleshipEX 50000 1900 2300 6500 vessel
UN_e107_ADAN_AAFrigate_EX4 4000 100 200 — vessel
UN_e102_ADAN_Battleship 50000 1900 2300 6500 vessel
UN_f104_TCAF_Battleship 30000 2000 — 6000 vessel
UN_f105_TCAF_Cruiser_EX5 30000 700 — 3800 vessel
UN_e108_ADAN_ASFrigateEX 4000 80 — 350 vessel
UN_f106_TCAF_Destroyer_Inv 10000 200 — 2000 vessel
UN_e107_ADAN_AAFrigate 4000 100 200 — vessel
UN_f104_TCAF_Battleship_EX5 30000 2000 — 6000 vessel
UN_f105_TCAF_Cruiser_Inv 30000 700 — 3800 vessel
UN_f102_TCAF_LightCarrier_Inv 20000 500 — 1600 vessel
UN_e108_ADAN_ASFrigate 4000 80 — 350 vessel
UN_e106_ADAN_DestroyerEX 10000 300 — 2100 vessel
UN_f101_TCAF_Acropolis 25000 400 — 1400 vessel
UN_e105_ADAN_CruiserEX 30000 600 — 3800 vessel
UN_e101_ADAN_SDBattleshipEX 100000 1900 2100 12300 vessel
UN_e106_ADAN_Destroyer 10000 300 — 2100 vessel

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,161 @@
run,turret_rule,t_s,acropolis_hull,e007_alive,e007_killed_cum
mission01,off,0,25000.0,0,0
mission01,off,10,25000.0,5,0
mission01,off,20,25000.0,6,0
mission01,off,30,25000.0,6,0
mission01,off,40,25000.0,6,0
mission01,off,50,25000.0,8,0
mission01,off,60,25000.0,11,0
mission01,off,70,25000.0,11,3
mission01,off,80,25000.0,8,3
mission01,off,90,25000.0,8,3
mission01,off,100,25000.0,10,3
mission01,off,110,25000.0,14,3
mission01,off,120,25000.0,16,3
mission01,off,130,25000.0,17,3
mission01,off,140,25000.0,20,3
mission01,off,150,25000.0,23,3
mission01,off,160,25000.0,26,4
mission01,off,170,24509.5,33,4
mission01,off,180,24509.5,37,4
mission01,off,190,24419.5,38,5
mission01,off,200,24212.5,41,5
mission01,off,210,23447.5,40,5
mission01,off,220,23447.5,40,5
mission01,off,220,23447.5,40,5
mission01,off,230,23447.5,41,5
mission01,off,230,23447.5,41,5
mission01,off,240,22912.0,43,5
mission01,off,240,22867.0,43,5
mission01,off,250,22667.0,42,6
mission01,off,250,22647.0,42,6
mission01,off,260,22334.5,44,6
mission01,off,260,22177.9,44,6
mission01,off,270,21101.2,46,6
mission01,off,270,21101.2,45,6
mission01,off,280,20292.6,46,6
mission01,off,280,20279.1,46,6
mission01,off,290,19598.5,58,6
mission01,off,290,19598.5,58,6
mission01,off,300,18725.2,58,6
mission01,off,300,18658.5,47,6
mission01,off,310,18231.9,47,6
mission01,off,310,18231.9,47,6
mission01,off,320,17425.2,47,6
mission01,off,330,17046.9,49,6
mission01,off,340,16044.8,49,7
mission01,off,350,15461.5,50,7
mission01,off,360,14956.3,51,7
mission01,off,370,14425.5,51,8
mission01,off,380,13910.5,51,8
mission01,off,390,13418.9,49,11
mission01,off,400,13218.9,45,12
mission01,off,410,12517.9,44,12
mission01,off,420,12161.3,44,13
mission01,off,430,11601.3,44,13
mission02,on,0,25000.0,0,0
mission02,on,10,25000.0,5,0
mission02,on,20,25000.0,6,0
mission02,on,30,25000.0,5,0
mission02,on,40,25000.0,5,1
mission02,on,50,25000.0,7,1
mission02,on,60,25000.0,10,1
mission02,on,70,25000.0,7,4
mission02,on,80,25000.0,7,4
mission02,on,90,25000.0,7,4
mission02,on,100,25000.0,7,4
mission02,on,110,25000.0,9,4
mission02,on,120,25000.0,14,4
mission02,on,130,25000.0,16,4
mission02,on,140,25000.0,19,4
mission02,on,150,25000.0,19,4
mission02,on,160,25000.0,23,5
mission02,on,170,24730.0,28,5
mission02,on,180,24730.0,32,5
mission02,on,190,24730.0,36,5
mission02,on,200,24640.0,43,6
mission02,on,210,24190.0,46,6
mission02,on,220,24055.0,47,6
mission02,on,220,24055.0,47,6
mission02,on,230,24055.0,49,6
mission02,on,230,24055.0,49,6
mission02,on,240,23474.5,52,6
mission02,on,240,23474.5,52,6
mission02,on,250,23474.5,53,6
mission02,on,250,23474.5,53,6
mission02,on,260,23474.5,54,6
mission02,on,260,23474.5,54,6
mission02,on,270,22697.0,54,6
mission02,on,270,22697.0,54,6
mission02,on,280,22091.2,56,6
mission02,on,280,22091.2,55,7
mission02,on,290,21577.9,55,7
mission02,on,290,21555.4,55,7
mission02,on,300,20799.1,56,7
mission02,on,310,19781.1,55,7
mission02,on,320,18925.8,56,8
mission02,on,330,18592.5,56,8
mission02,on,340,17432.5,57,8
mission02,on,350,16932.6,59,8
mission02,on,360,16171.6,58,9
mission02,on,370,15017.6,58,11
mission02,on,380,14217.2,59,11
mission02,on,390,13322.2,59,12
mission02,on,400,12808.9,58,13
mission02,on,410,12090.6,61,16
mission02,on,420,11440.6,66,19
mission02,on,430,11127.3,55,19
mission03,on,0,25000.0,0,0
mission03,on,10,25000.0,3,0
mission03,on,20,25000.0,8,0
mission03,on,30,25000.0,5,0
mission03,on,40,25000.0,6,0
mission03,on,50,25000.0,8,0
mission03,on,60,25000.0,9,0
mission03,on,70,25000.0,16,0
mission03,on,80,25000.0,10,0
mission03,on,90,25000.0,11,0
mission03,on,100,25000.0,10,2
mission03,on,110,25000.0,15,2
mission03,on,120,25000.0,12,2
mission03,on,130,25000.0,10,4
mission03,on,140,25000.0,11,6
mission03,on,150,25000.0,14,6
mission03,on,160,25000.0,17,7
mission03,on,170,24685.0,24,7
mission03,on,180,24640.0,23,7
mission03,on,190,24640.0,27,7
mission03,on,200,24460.0,26,8
mission03,on,210,24100.0,28,8
mission03,on,220,24100.0,29,9
mission03,on,230,24100.0,28,11
mission03,on,230,24100.0,28,11
mission03,on,240,23810.0,35,11
mission03,on,240,23810.0,36,11
mission03,on,250,23630.0,36,12
mission03,on,250,23630.0,36,12
mission03,on,260,23630.0,38,13
mission03,on,260,23630.0,38,13
mission03,on,270,22998.0,39,13
mission03,on,270,22908.0,39,13
mission03,on,280,22197.5,40,13
mission03,on,280,22188.5,40,13
mission03,on,290,21602.4,38,13
mission03,on,290,21602.4,38,13
mission03,on,300,21042.4,39,13
mission03,on,300,20975.8,39,13
mission03,on,310,20529.1,41,15
mission03,on,310,20529.1,41,15
mission03,on,320,19812.5,38,17
mission03,on,320,19767.5,38,17
mission03,on,330,19434.2,39,18
mission03,on,340,18785.9,37,19
mission03,on,350,18339.2,37,19
mission03,on,360,17528.1,38,19
mission03,on,370,16901.5,37,19
mission03,on,380,16260.7,35,19
mission03,on,390,15708.2,37,19
mission03,on,400,15346.2,37,19
mission03,on,410,14412.1,36,20
mission03,on,420,13951.9,37,22
mission03,on,430,13211.9,35,23
1 run turret_rule t_s acropolis_hull e007_alive e007_killed_cum
2 mission01 off 0 25000.0 0 0
3 mission01 off 10 25000.0 5 0
4 mission01 off 20 25000.0 6 0
5 mission01 off 30 25000.0 6 0
6 mission01 off 40 25000.0 6 0
7 mission01 off 50 25000.0 8 0
8 mission01 off 60 25000.0 11 0
9 mission01 off 70 25000.0 11 3
10 mission01 off 80 25000.0 8 3
11 mission01 off 90 25000.0 8 3
12 mission01 off 100 25000.0 10 3
13 mission01 off 110 25000.0 14 3
14 mission01 off 120 25000.0 16 3
15 mission01 off 130 25000.0 17 3
16 mission01 off 140 25000.0 20 3
17 mission01 off 150 25000.0 23 3
18 mission01 off 160 25000.0 26 4
19 mission01 off 170 24509.5 33 4
20 mission01 off 180 24509.5 37 4
21 mission01 off 190 24419.5 38 5
22 mission01 off 200 24212.5 41 5
23 mission01 off 210 23447.5 40 5
24 mission01 off 220 23447.5 40 5
25 mission01 off 220 23447.5 40 5
26 mission01 off 230 23447.5 41 5
27 mission01 off 230 23447.5 41 5
28 mission01 off 240 22912.0 43 5
29 mission01 off 240 22867.0 43 5
30 mission01 off 250 22667.0 42 6
31 mission01 off 250 22647.0 42 6
32 mission01 off 260 22334.5 44 6
33 mission01 off 260 22177.9 44 6
34 mission01 off 270 21101.2 46 6
35 mission01 off 270 21101.2 45 6
36 mission01 off 280 20292.6 46 6
37 mission01 off 280 20279.1 46 6
38 mission01 off 290 19598.5 58 6
39 mission01 off 290 19598.5 58 6
40 mission01 off 300 18725.2 58 6
41 mission01 off 300 18658.5 47 6
42 mission01 off 310 18231.9 47 6
43 mission01 off 310 18231.9 47 6
44 mission01 off 320 17425.2 47 6
45 mission01 off 330 17046.9 49 6
46 mission01 off 340 16044.8 49 7
47 mission01 off 350 15461.5 50 7
48 mission01 off 360 14956.3 51 7
49 mission01 off 370 14425.5 51 8
50 mission01 off 380 13910.5 51 8
51 mission01 off 390 13418.9 49 11
52 mission01 off 400 13218.9 45 12
53 mission01 off 410 12517.9 44 12
54 mission01 off 420 12161.3 44 13
55 mission01 off 430 11601.3 44 13
56 mission02 on 0 25000.0 0 0
57 mission02 on 10 25000.0 5 0
58 mission02 on 20 25000.0 6 0
59 mission02 on 30 25000.0 5 0
60 mission02 on 40 25000.0 5 1
61 mission02 on 50 25000.0 7 1
62 mission02 on 60 25000.0 10 1
63 mission02 on 70 25000.0 7 4
64 mission02 on 80 25000.0 7 4
65 mission02 on 90 25000.0 7 4
66 mission02 on 100 25000.0 7 4
67 mission02 on 110 25000.0 9 4
68 mission02 on 120 25000.0 14 4
69 mission02 on 130 25000.0 16 4
70 mission02 on 140 25000.0 19 4
71 mission02 on 150 25000.0 19 4
72 mission02 on 160 25000.0 23 5
73 mission02 on 170 24730.0 28 5
74 mission02 on 180 24730.0 32 5
75 mission02 on 190 24730.0 36 5
76 mission02 on 200 24640.0 43 6
77 mission02 on 210 24190.0 46 6
78 mission02 on 220 24055.0 47 6
79 mission02 on 220 24055.0 47 6
80 mission02 on 230 24055.0 49 6
81 mission02 on 230 24055.0 49 6
82 mission02 on 240 23474.5 52 6
83 mission02 on 240 23474.5 52 6
84 mission02 on 250 23474.5 53 6
85 mission02 on 250 23474.5 53 6
86 mission02 on 260 23474.5 54 6
87 mission02 on 260 23474.5 54 6
88 mission02 on 270 22697.0 54 6
89 mission02 on 270 22697.0 54 6
90 mission02 on 280 22091.2 56 6
91 mission02 on 280 22091.2 55 7
92 mission02 on 290 21577.9 55 7
93 mission02 on 290 21555.4 55 7
94 mission02 on 300 20799.1 56 7
95 mission02 on 310 19781.1 55 7
96 mission02 on 320 18925.8 56 8
97 mission02 on 330 18592.5 56 8
98 mission02 on 340 17432.5 57 8
99 mission02 on 350 16932.6 59 8
100 mission02 on 360 16171.6 58 9
101 mission02 on 370 15017.6 58 11
102 mission02 on 380 14217.2 59 11
103 mission02 on 390 13322.2 59 12
104 mission02 on 400 12808.9 58 13
105 mission02 on 410 12090.6 61 16
106 mission02 on 420 11440.6 66 19
107 mission02 on 430 11127.3 55 19
108 mission03 on 0 25000.0 0 0
109 mission03 on 10 25000.0 3 0
110 mission03 on 20 25000.0 8 0
111 mission03 on 30 25000.0 5 0
112 mission03 on 40 25000.0 6 0
113 mission03 on 50 25000.0 8 0
114 mission03 on 60 25000.0 9 0
115 mission03 on 70 25000.0 16 0
116 mission03 on 80 25000.0 10 0
117 mission03 on 90 25000.0 11 0
118 mission03 on 100 25000.0 10 2
119 mission03 on 110 25000.0 15 2
120 mission03 on 120 25000.0 12 2
121 mission03 on 130 25000.0 10 4
122 mission03 on 140 25000.0 11 6
123 mission03 on 150 25000.0 14 6
124 mission03 on 160 25000.0 17 7
125 mission03 on 170 24685.0 24 7
126 mission03 on 180 24640.0 23 7
127 mission03 on 190 24640.0 27 7
128 mission03 on 200 24460.0 26 8
129 mission03 on 210 24100.0 28 8
130 mission03 on 220 24100.0 29 9
131 mission03 on 230 24100.0 28 11
132 mission03 on 230 24100.0 28 11
133 mission03 on 240 23810.0 35 11
134 mission03 on 240 23810.0 36 11
135 mission03 on 250 23630.0 36 12
136 mission03 on 250 23630.0 36 12
137 mission03 on 260 23630.0 38 13
138 mission03 on 260 23630.0 38 13
139 mission03 on 270 22998.0 39 13
140 mission03 on 270 22908.0 39 13
141 mission03 on 280 22197.5 40 13
142 mission03 on 280 22188.5 40 13
143 mission03 on 290 21602.4 38 13
144 mission03 on 290 21602.4 38 13
145 mission03 on 300 21042.4 39 13
146 mission03 on 300 20975.8 39 13
147 mission03 on 310 20529.1 41 15
148 mission03 on 310 20529.1 41 15
149 mission03 on 320 19812.5 38 17
150 mission03 on 320 19767.5 38 17
151 mission03 on 330 19434.2 39 18
152 mission03 on 340 18785.9 37 19
153 mission03 on 350 18339.2 37 19
154 mission03 on 360 17528.1 38 19
155 mission03 on 370 16901.5 37 19
156 mission03 on 380 16260.7 35 19
157 mission03 on 390 15708.2 37 19
158 mission03 on 400 15346.2 37 19
159 mission03 on 410 14412.1 36 20
160 mission03 on 420 13951.9 37 22
161 mission03 on 430 13211.9 35 23

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,102 @@
movie,telop,subtitle,voicetrack
logo1.wmv,,,
logo2.wmv,,,
logo3.wmv,,,
logo4.wmv,,,
ADV.wmv,,,VOICE_ADV
SYLPH_HD720p_8M-CBR_2ch.wmv,pwterop_s01a.prt,SYLPH_HD720p_8M-CBR_2ch.tbl,
S00A.wmv,,SUBTITLE_S00A.tbl,VOICE_S00A
S01A.wmv,,SUBTITLE_S01A.tbl,VOICE_S01A
RT01A.wmv,pwrt01.prt,SUBTITLE_RT01A.tbl,VOICE_RT01A
RT01B.wmv,,SUBTITLE_RT01B.tbl,VOICE_RT01B
RT01C_1.wmv,,SUBTITLE_RT01C_1.tbl,VOICE_RT01C_1
RT01C_2.wmv,,SUBTITLE_RT01C_2.tbl,VOICE_RT01C_2
S02A.wmv,pwterop_s02a.prt,SUBTITLE_S02A.tbl,VOICE_S02A
S02B.wmv,,SUBTITLE_S02B.tbl,VOICE_S02B
S02C.wmv,,SUBTITLE_S02C.tbl,VOICE_S02C
RT02A.wmv,pwrt02.prt,SUBTITLE_RT02A.tbl,VOICE_RT02A
RT02B.wmv,,SUBTITLE_RT02B.tbl,VOICE_RT02B
RT02C.wmv,,SUBTITLE_RT02C.tbl,VOICE_RT02C
RT02D_1.wmv,,SUBTITLE_RT02D_1.tbl,VOICE_RT02D_1
RT02D_2.wmv,,SUBTITLE_RT02D_2.tbl,VOICE_RT02D_2
hokyu_LS_s02A.wmv,,SUBTITLE_hokyu_LS_s02A.tbl,VOICE_D_450
hokyu_LS_s02H.wmv,,SUBTITLE_hokyu_LS_s02H.tbl,VOICE_D_453
S03A.wmv,,SUBTITLE_S03A.tbl,VOICE_S03A
RT03A.wmv,pwrt03.prt,SUBTITLE_RT03A.tbl,VOICE_RT03A
RT03B.wmv,,SUBTITLE_RT03B.tbl,VOICE_RT03B
RT03C.wmv,,SUBTITLE_RT03C.tbl,VOICE_RT03C
RT03D.wmv,,SUBTITLE_RT03D.tbl,VOICE_RT03D
hokyu_LS_s03A.wmv,,SUBTITLE_hokyu_LS_s03A.tbl,
hokyu_LS_s03H.wmv,,SUBTITLE_hokyu_LS_s03H.tbl,
S04A.wmv,,SUBTITLE_S04A.tbl,VOICE_S04A
S04B.wmv,,SUBTITLE_S04B.tbl,VOICE_S04B
RT04A.wmv,pwrt04.prt,SUBTITLE_RT04A.tbl,VOICE_RT04A
RT04B.wmv,,SUBTITLE_RT04B.tbl,VOICE_RT04B
hokyu_DS_s02A.wmv,,SUBTITLE_hokyu_DS_s02A.tbl,VOICE_D_452
S05A.wmv,,SUBTITLE_S05A.tbl,VOICE_S05A
RT05A.wmv,pwrt05.prt,SUBTITLE_RT05A.tbl,VOICE_RT05A
RT05B.wmv,,SUBTITLE_RT05B.tbl,VOICE_RT05B
RT05C.wmv,,SUBTITLE_RT05C.tbl,VOICE_RT05C
S06A.wmv,pwterop_s06a.prt,SUBTITLE_S06A.tbl,VOICE_S06A
S06B.wmv,,SUBTITLE_S06B.tbl,VOICE_S06B
RT06A.wmv,pwrt06.prt,SUBTITLE_RT06A.tbl,VOICE_RT06A
RT06B.wmv,,SUBTITLE_RT06B.tbl,VOICE_RT06B
RT06C.wmv,,SUBTITLE_RT06C.tbl,VOICE_RT06C
RT06D.wmv,,SUBTITLE_RT06D.tbl,VOICE_RT06D
hokyu_LS_s06A.wmv,,SUBTITLE_hokyu_LS_s06A.tbl,
hokyu_LS_s06H.wmv,,SUBTITLE_hokyu_LS_s06H.tbl,
S07A.wmv,,SUBTITLE_S07A.tbl,VOICE_S07A
S07B.wmv,,SUBTITLE_S07B.tbl,VOICE_S07B
RT07A.wmv,pwrt07.prt,SUBTITLE_RT07A.tbl,VOICE_RT07A
RT07B.wmv,,SUBTITLE_RT07B.tbl,VOICE_RT07B
RT07C.wmv,,SUBTITLE_RT07C.tbl,VOICE_RT07C
hokyu_DS_s07A.wmv,,SUBTITLE_hokyu_DS_s07A.tbl,
hokyu_DS_s07H.wmv,,SUBTITLE_hokyu_DS_s07H.tbl,VOICE_D_454
RT08A.wmv,pwrt08.prt,SUBTITLE_RT08A.tbl,VOICE_RT08A
RT08B.wmv,,SUBTITLE_RT08B.tbl,VOICE_RT08B
RT08C.wmv,,SUBTITLE_RT08C.tbl,VOICE_RT08C
hokyu_DS_s08A.wmv,,SUBTITLE_hokyu_DS_s08A.tbl,
S09B.wmv,,SUBTITLE_S09B.tbl,VOICE_S09B
RT09A.wmv,pwrt09.prt,SUBTITLE_RT09A.tbl,VOICE_RT09A
RT09B.wmv,,SUBTITLE_RT09B.tbl,VOICE_RT09B
RT09C.wmv,,SUBTITLE_RT09C.tbl,VOICE_RT09C
RT09D.wmv,,SUBTITLE_RT09D.tbl,VOICE_RT09D
hokyu_LS_s09A.wmv,,SUBTITLE_hokyu_LS_s09A.tbl,VOICE_D_451
hokyu_LS_s09H.wmv,,SUBTITLE_hokyu_LS_s09H.tbl,
S10B.wmv,,SUBTITLE_S10B.tbl,VOICE_S10B
RT10A.wmv,pwrt10.prt,SUBTITLE_RT10A.tbl,VOICE_RT10A
RT10B.wmv,,SUBTITLE_RT10B.tbl,VOICE_RT10B
S11A.wmv,,SUBTITLE_S11A.tbl,VOICE_S11A
S11C.wmv,,SUBTITLE_S11C.tbl,VOICE_S11C
RT11A.wmv,pwrt11.prt,SUBTITLE_RT11A.tbl,VOICE_RT11A
RT11B.wmv,,SUBTITLE_RT11B.tbl,VOICE_RT11B
RT11C.wmv,,SUBTITLE_RT11C.tbl,VOICE_RT11C
hokyu_LS_s11A.wmv,,SUBTITLE_hokyu_LS_s11A.tbl,
S12A.wmv,,SUBTITLE_S12A.tbl,VOICE_S12A
S12B.wmv,,SUBTITLE_S12B.tbl,VOICE_S12B
S12C.wmv,,SUBTITLE_S12C.tbl,VOICE_S12C
RT12A.wmv,pwrt12.prt,SUBTITLE_RT12A.tbl,VOICE_RT12A
RT12B_1.wmv,,SUBTITLE_RT12B_1.tbl,VOICE_RT12B_1
RT12B_2.wmv,,SUBTITLE_RT12B_2.tbl,VOICE_RT12B_2
S13A.wmv,,SUBTITLE_S13A.tbl,VOICE_S13A
S13B.wmv,,SUBTITLE_S13B.tbl,VOICE_S13B
RT13A.wmv,pwrt13.prt,SUBTITLE_RT13A.tbl,VOICE_RT13A
RT13B_1.wmv,,SUBTITLE_RT13B_1.tbl,VOICE_RT13B_1
RT13B_2.wmv,,SUBTITLE_RT13B_2.tbl,VOICE_RT13B_2
hokyu_DS_s13A.wmv,,SUBTITLE_hokyu_DS_s13A.tbl,
S14A.wmv,,SUBTITLE_S14A.tbl,VOICE_S14A
RT14A.wmv,pwrt14.prt,SUBTITLE_RT14A.tbl,VOICE_RT14A
RT14B.wmv,,SUBTITLE_RT14B.tbl,VOICE_RT14B
RT14C.wmv,,SUBTITLE_RT14C.tbl,VOICE_RT14C
hokyu_DS_s14H.wmv,,SUBTITLE_hokyu_DS_s14H.tbl,
S15A.wmv,pwterop_s15a.prt,SUBTITLE_S15A.tbl,VOICE_S15A
S15B.wmv,,SUBTITLE_S15B.tbl,VOICE_S15B
S15C.wmv,,SUBTITLE_S15C.tbl,VOICE_S15C
RT15A.wmv,pwrt15.prt,SUBTITLE_RT15A.tbl,VOICE_RT15A
RT15B.wmv,,SUBTITLE_RT15B.tbl,VOICE_RT15B
RT15C.wmv,,SUBTITLE_RT15C.tbl,VOICE_RT15C
hokyu_LS_s15A.wmv,,SUBTITLE_hokyu_LS_s15A.tbl,
S16A.wmv,pwterop_s16a.prt,SUBTITLE_S16A.tbl,VOICE_S16A
RT16C.wmv,pwrt16.prt,SUBTITLE_RT16C.tbl,VOICE_RT16C
hokyu_LS_s24A.wmv,,,
hokyu_LS_s27A.wmv,,,
1 movie telop subtitle voicetrack
2 logo1.wmv
3 logo2.wmv
4 logo3.wmv
5 logo4.wmv
6 ADV.wmv VOICE_ADV
7 SYLPH_HD720p_8M-CBR_2ch.wmv pwterop_s01a.prt SYLPH_HD720p_8M-CBR_2ch.tbl
8 S00A.wmv SUBTITLE_S00A.tbl VOICE_S00A
9 S01A.wmv SUBTITLE_S01A.tbl VOICE_S01A
10 RT01A.wmv pwrt01.prt SUBTITLE_RT01A.tbl VOICE_RT01A
11 RT01B.wmv SUBTITLE_RT01B.tbl VOICE_RT01B
12 RT01C_1.wmv SUBTITLE_RT01C_1.tbl VOICE_RT01C_1
13 RT01C_2.wmv SUBTITLE_RT01C_2.tbl VOICE_RT01C_2
14 S02A.wmv pwterop_s02a.prt SUBTITLE_S02A.tbl VOICE_S02A
15 S02B.wmv SUBTITLE_S02B.tbl VOICE_S02B
16 S02C.wmv SUBTITLE_S02C.tbl VOICE_S02C
17 RT02A.wmv pwrt02.prt SUBTITLE_RT02A.tbl VOICE_RT02A
18 RT02B.wmv SUBTITLE_RT02B.tbl VOICE_RT02B
19 RT02C.wmv SUBTITLE_RT02C.tbl VOICE_RT02C
20 RT02D_1.wmv SUBTITLE_RT02D_1.tbl VOICE_RT02D_1
21 RT02D_2.wmv SUBTITLE_RT02D_2.tbl VOICE_RT02D_2
22 hokyu_LS_s02A.wmv SUBTITLE_hokyu_LS_s02A.tbl VOICE_D_450
23 hokyu_LS_s02H.wmv SUBTITLE_hokyu_LS_s02H.tbl VOICE_D_453
24 S03A.wmv SUBTITLE_S03A.tbl VOICE_S03A
25 RT03A.wmv pwrt03.prt SUBTITLE_RT03A.tbl VOICE_RT03A
26 RT03B.wmv SUBTITLE_RT03B.tbl VOICE_RT03B
27 RT03C.wmv SUBTITLE_RT03C.tbl VOICE_RT03C
28 RT03D.wmv SUBTITLE_RT03D.tbl VOICE_RT03D
29 hokyu_LS_s03A.wmv SUBTITLE_hokyu_LS_s03A.tbl
30 hokyu_LS_s03H.wmv SUBTITLE_hokyu_LS_s03H.tbl
31 S04A.wmv SUBTITLE_S04A.tbl VOICE_S04A
32 S04B.wmv SUBTITLE_S04B.tbl VOICE_S04B
33 RT04A.wmv pwrt04.prt SUBTITLE_RT04A.tbl VOICE_RT04A
34 RT04B.wmv SUBTITLE_RT04B.tbl VOICE_RT04B
35 hokyu_DS_s02A.wmv SUBTITLE_hokyu_DS_s02A.tbl VOICE_D_452
36 S05A.wmv SUBTITLE_S05A.tbl VOICE_S05A
37 RT05A.wmv pwrt05.prt SUBTITLE_RT05A.tbl VOICE_RT05A
38 RT05B.wmv SUBTITLE_RT05B.tbl VOICE_RT05B
39 RT05C.wmv SUBTITLE_RT05C.tbl VOICE_RT05C
40 S06A.wmv pwterop_s06a.prt SUBTITLE_S06A.tbl VOICE_S06A
41 S06B.wmv SUBTITLE_S06B.tbl VOICE_S06B
42 RT06A.wmv pwrt06.prt SUBTITLE_RT06A.tbl VOICE_RT06A
43 RT06B.wmv SUBTITLE_RT06B.tbl VOICE_RT06B
44 RT06C.wmv SUBTITLE_RT06C.tbl VOICE_RT06C
45 RT06D.wmv SUBTITLE_RT06D.tbl VOICE_RT06D
46 hokyu_LS_s06A.wmv SUBTITLE_hokyu_LS_s06A.tbl
47 hokyu_LS_s06H.wmv SUBTITLE_hokyu_LS_s06H.tbl
48 S07A.wmv SUBTITLE_S07A.tbl VOICE_S07A
49 S07B.wmv SUBTITLE_S07B.tbl VOICE_S07B
50 RT07A.wmv pwrt07.prt SUBTITLE_RT07A.tbl VOICE_RT07A
51 RT07B.wmv SUBTITLE_RT07B.tbl VOICE_RT07B
52 RT07C.wmv SUBTITLE_RT07C.tbl VOICE_RT07C
53 hokyu_DS_s07A.wmv SUBTITLE_hokyu_DS_s07A.tbl
54 hokyu_DS_s07H.wmv SUBTITLE_hokyu_DS_s07H.tbl VOICE_D_454
55 RT08A.wmv pwrt08.prt SUBTITLE_RT08A.tbl VOICE_RT08A
56 RT08B.wmv SUBTITLE_RT08B.tbl VOICE_RT08B
57 RT08C.wmv SUBTITLE_RT08C.tbl VOICE_RT08C
58 hokyu_DS_s08A.wmv SUBTITLE_hokyu_DS_s08A.tbl
59 S09B.wmv SUBTITLE_S09B.tbl VOICE_S09B
60 RT09A.wmv pwrt09.prt SUBTITLE_RT09A.tbl VOICE_RT09A
61 RT09B.wmv SUBTITLE_RT09B.tbl VOICE_RT09B
62 RT09C.wmv SUBTITLE_RT09C.tbl VOICE_RT09C
63 RT09D.wmv SUBTITLE_RT09D.tbl VOICE_RT09D
64 hokyu_LS_s09A.wmv SUBTITLE_hokyu_LS_s09A.tbl VOICE_D_451
65 hokyu_LS_s09H.wmv SUBTITLE_hokyu_LS_s09H.tbl
66 S10B.wmv SUBTITLE_S10B.tbl VOICE_S10B
67 RT10A.wmv pwrt10.prt SUBTITLE_RT10A.tbl VOICE_RT10A
68 RT10B.wmv SUBTITLE_RT10B.tbl VOICE_RT10B
69 S11A.wmv SUBTITLE_S11A.tbl VOICE_S11A
70 S11C.wmv SUBTITLE_S11C.tbl VOICE_S11C
71 RT11A.wmv pwrt11.prt SUBTITLE_RT11A.tbl VOICE_RT11A
72 RT11B.wmv SUBTITLE_RT11B.tbl VOICE_RT11B
73 RT11C.wmv SUBTITLE_RT11C.tbl VOICE_RT11C
74 hokyu_LS_s11A.wmv SUBTITLE_hokyu_LS_s11A.tbl
75 S12A.wmv SUBTITLE_S12A.tbl VOICE_S12A
76 S12B.wmv SUBTITLE_S12B.tbl VOICE_S12B
77 S12C.wmv SUBTITLE_S12C.tbl VOICE_S12C
78 RT12A.wmv pwrt12.prt SUBTITLE_RT12A.tbl VOICE_RT12A
79 RT12B_1.wmv SUBTITLE_RT12B_1.tbl VOICE_RT12B_1
80 RT12B_2.wmv SUBTITLE_RT12B_2.tbl VOICE_RT12B_2
81 S13A.wmv SUBTITLE_S13A.tbl VOICE_S13A
82 S13B.wmv SUBTITLE_S13B.tbl VOICE_S13B
83 RT13A.wmv pwrt13.prt SUBTITLE_RT13A.tbl VOICE_RT13A
84 RT13B_1.wmv SUBTITLE_RT13B_1.tbl VOICE_RT13B_1
85 RT13B_2.wmv SUBTITLE_RT13B_2.tbl VOICE_RT13B_2
86 hokyu_DS_s13A.wmv SUBTITLE_hokyu_DS_s13A.tbl
87 S14A.wmv SUBTITLE_S14A.tbl VOICE_S14A
88 RT14A.wmv pwrt14.prt SUBTITLE_RT14A.tbl VOICE_RT14A
89 RT14B.wmv SUBTITLE_RT14B.tbl VOICE_RT14B
90 RT14C.wmv SUBTITLE_RT14C.tbl VOICE_RT14C
91 hokyu_DS_s14H.wmv SUBTITLE_hokyu_DS_s14H.tbl
92 S15A.wmv pwterop_s15a.prt SUBTITLE_S15A.tbl VOICE_S15A
93 S15B.wmv SUBTITLE_S15B.tbl VOICE_S15B
94 S15C.wmv SUBTITLE_S15C.tbl VOICE_S15C
95 RT15A.wmv pwrt15.prt SUBTITLE_RT15A.tbl VOICE_RT15A
96 RT15B.wmv SUBTITLE_RT15B.tbl VOICE_RT15B
97 RT15C.wmv SUBTITLE_RT15C.tbl VOICE_RT15C
98 hokyu_LS_s15A.wmv SUBTITLE_hokyu_LS_s15A.tbl
99 S16A.wmv pwterop_s16a.prt SUBTITLE_S16A.tbl VOICE_S16A
100 RT16C.wmv pwrt16.prt SUBTITLE_RT16C.tbl VOICE_RT16C
101 hokyu_LS_s24A.wmv
102 hokyu_LS_s27A.wmv

View File

@@ -0,0 +1,13 @@
11 elements
# element parent kind pivot kf placement
0 pgp_ttrl_eff11.t32 - 0x0 (34,33) 7 rest (470,121) t=9..25 [3:603,121 6:603,121 8:503,121 9:470,121 25:463,121 30:463,121 1:493,121]
1 pgp_ttrl_eff12.t32 - 0x0 (34,33) 7 rest (742,121) t=9..25 [3:609,121 6:609,121 8:709,121 9:742,121 25:749,121 30:749,121 2:719,121]
2 pgp_ttrl_eff10.t32 - 0x0 (204,60) 5 rest (436,94) t=14..23 [8:436,94 14:436,94 23:436,94 30:436,94 3:436,94]
3 pgp_ttrl_eff22.t32 - 0x0 (133,130) 5 rest (480,225) t=11..25 [5:480,225 11:480,225 25:460,225 30:460,225 4:490,225]
4 pgp_ttrl_eff23.t32 - 0x0 (138,130) 5 rest (524,271) t=11..25 [5:524,271 11:524,271 25:544,271 30:544,271 5:514,271]
5 pgp_ttrl_eff21.t32 - 0x0 (214,180) 5 rest (426,198) t=9..21 [9:426,198 21:426,198 23:426,198 30:426,198 6:426,198]
6 pgp_ttrl_title.rat - 0x0 (101,36) 6 rest (540,119) t=13..23 [5:540,119 13:540,119 23:540,119 28:540,119 30:540,119 7:540,119]
7 pgp_ttrl_btn10.rat - 0x3002 (43,21) 5 rest (546,288) t=15..23 [9:546,288 15:546,288 23:546,288 26:546,288 8:546,288]
8 pgp_ttrl_btn11.rat - 0x3002 (86,22) 5 rest (546,358) t=11..17 [11:546,358 17:546,358 23:546,358 26:546,358 9:546,358]
9 pgp_ttrl_btn12.rat - 0x3002 (110,21) 5 rest (546,428) t=13..19 [13:546,428 19:546,428 23:546,428 26:546,428 10:546,428]
10 pgp_ttrl_msg.t32 - 0x0 (190,19) 5 rest (451,545) t=28..1869640736 [16:451,545 21:451,545 23:451,545 28:451,545 1869640736:451,545]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

View File

@@ -0,0 +1,15 @@
file offset vcount candidate claimed by our decode our resources with that vcount
0x38788 181 NO — NOBODY — none
0x4b8b8 93 NO — NOBODY — none
0xb6574 41 NO — NOBODY n042_bdy_m e007_bdy_01 @ -0x24864
0xdbbac 77 NO — NOBODY — none _rou_e010_break @ -0x5050
0x133da0 76 NO — NOBODY e303_wep_01_m, n041_bdy_l _rou_e010_break @ -0x407bc
0x162840 60 NO — NOBODY — none _rou_e010_break @ -0x6f25c
0x3b3ee8 119 yes e106_bdy_01_l(119), e106_bdy_02_l(119) e106_bdy_01_l, e106_bdy_02_l e106_bdy_01_l @ -0x0
0x3c55d8 119 yes — NOBODY e106_bdy_01_l, e106_bdy_02_l e106_bdy_01_m @ -0x104e8
0x3dd2c4 146 yes e106_bdy_03_l(146) e106_bdy_03_l e106_bdy_03_l @ -0x0
0x40763c 179 yes e106_bdy_04_l(179) e106_bdy_04_l e106_bdy_04_l @ -0x0
0x40e418 51 yes — NOBODY e106_brg_01_b_02, e106_brg_01_l e106_brg_01_b_02 @ -0x5d0
0x444ccc 58 yes e106_eng_01_l(58) e106_eng_01_l e106_eng_01_l @ -0x0
0x44a32c 44 yes — NOBODY e106_eng_02_l e106_eng_02 @ -0x35b8
0x45705c 82 yes e106_wep_02_01_l(82) e106_wep_02_01_l e106_wep_02_01_l @ -0x0

View File

@@ -0,0 +1,47 @@
file offset vcount candidate claimed by our decode our resources with that vcount
0x135036c 240 yes _rou_f101(240) — none _rou_f101 @ -0x0
0x13519ec 240 NO _rou_f101(240) — none _rou_f101 @ -0x0
0x13fd6e8 25448 yes f101_bdy_01(25448) f101_bdy_01 f101_bdy_01 @ -0x0
0x192961c 13172 yes f101_bdy_02(13172) f101_bdy_02 f101_bdy_02 @ -0x0
0x1a0befc 2296 yes f101_bdy_03(2296) f101_bdy_03 f101_bdy_03 @ -0x0
0x1a22c1c 7694 yes f101_eng_01(7694) f101_eng_01 f101_eng_01 @ -0x0
0x1be5df4 628 yes f101_wep_01_m(628) f101_wep_01_m f101_wep_01_m @ -0x0
0x1bf40f4 261 yes e_rob_f001(24), e_rou_f003_Near(24), e_rou_f101_wep_01(24), e_rou_f105(24), _rou_f105_break(261), e_rou_f106(24), e_rou_f302_barrel(24), e_rou_f302_base(24), e_rou_f303_barrel(24), e_rou_f303_base(24), e_rou_e007_Far(24), e_rou_e007_Near(24), e_rou_e010_Far(24), e_rou_e010_Near(24), e_rou_e105(24), e_rou_e105_wep_01(24), e_rou_e106(24), e_rou_e106_eng(24), e_rou_e106_wep_02_01(24), e_rou_e106_wep_02_joint(24), e_rou_e108_Missile_open(24), e_rou_e201(24), e_rou_e302_barrel(24), e_rou_e302_base(24), e_rou_e303_barrel_Near(24), e_rou_e303_base_Near(24), e_rou_e501(24) f106_eng_01_l e_rob_f001 @ -0x0
0x1bf596c 2446 NO _rou_f105_break(2446) f105_bdy_01_m _rou_f105_break @ -0x0
0x1c03ebc 1960 NO _rou_f105_break(1960) f105_bdy_02_m _rou_f105_break @ -0x0
0x1c0f67c 82 NO _rou_f105_break(82) e106_wep_02_01_l _rou_f105_break @ -0x0
0x1c0fe2c 2336 NO _rou_f105_break(2336) f105_bdy_03_m _rou_f105_break @ -0x0
0x1c1d92c 220 NO _rou_f105_break(220) — none _rou_f105_break @ -0x0
0x1c1edcc 80 NO _rou_f105_break(80) f001_bdy_24_m _rou_f105_break @ -0x0
0x1c1f54c 148 NO _rou_f105_break(148) — none _rou_f105_break @ -0x0
0x1c2032c 156 NO _rou_f105_break(156) e105_bdy_01_l _rou_f105_break @ -0x0
0x1c211cc 1402 NO _rou_f105_break(1402) f105_eng_01_m _rou_f105_break @ -0x0
0x1c2953c 84 NO _rou_f105_break(84) — none _rou_f105_break @ -0x0
0x1c29d1c 192 NO _rou_f105_break(192) f105_sld_01_b01 _rou_f105_break @ -0x0
0x1c2af1c 122 NO _rou_f105_break(122) e106_eng_02_m _rou_f105_break @ -0x0
0x1c2ba8c 24 NO _rou_f105_break(24) e_rob_f001, e_rou_f003_Far, e_rou_f003_Near, e_rou_f101_wep_01, e_rou_f105, f105_sld_01_l, e_rou_f106, e_rou_f302_barrel, e_rou_f302_base, f302_01_l, e_rou_f303_barrel, e_rou_f303_base, _rou_f402_dead, e_rou_e007_Far, e_rou_e007_Near, e_rou_e010_Far, e_rou_e010_Near, e_rou_e105, e_rou_e105_wep_01, e_rou_e106, e_rou_e106_eng, e_rou_e106_wep_02_01, e_rou_e106_wep_02_joint, e_rou_e108_Missile_open, e_rou_e201, e201_bdy_03_m, e_rou_e302_barrel, e_rou_e302_base, e_rou_e303_barrel_Near, e_rou_e303_base_Near, e303_wep_02_l, e_rou_e501 _rou_f105_break @ -0x0
0x227ac94 1692 yes f106_bdy_02_l(1692) f106_bdy_02_l f106_bdy_02_l @ -0x0
0x22c2ef0 686 yes f106_bdy_03_l(686) f106_bdy_03_l f106_bdy_03_l @ -0x0
0x22ed420 261 yes f106_eng_01_l(261) f106_eng_01_l f106_eng_01_l @ -0x0
0x2326c9c 254 yes f106_sld_01_l(254) f106_sld_01_l, f106_sld_02_l, e303_wep_02_m f106_sld_01_l @ -0x0
0x233b3fc 254 yes f106_sld_02_l(254) f106_sld_01_l, f106_sld_02_l, e303_wep_02_m f106_sld_02_l @ -0x0
0x24ce5f4 32 yes f303_body_l(32) f303_body_l, e302_barrel_l f303_body_l @ -0x0
0x24fa1c8 128 yes _rou_f402(128) _rou_f402 _rou_f402 @ -0x0
0x28ea1d4 156 yes e105_bdy_01_l(156) e105_bdy_01_l e105_bdy_01_l @ -0x0
0x28f7514 107 yes e105_bdy_02_l(107) e105_bdy_02_l, e105_brg_m e105_bdy_02_l @ -0x0
0x291e1e8 185 yes e105_bdy_03_l(185) e105_bdy_03_l e105_bdy_03_l @ -0x0
0x29a4788 181 yes e105_bdy_04_l(181) e105_bdy_04_l e105_bdy_04_l @ -0x0
0x29b78b8 93 yes e105_bdy_05_l(93) e105_bdy_05_l e105_bdy_05_l @ -0x0
0x2a22574 41 yes e105_bdy_06_l(41) e105_bdy_06_l e105_bdy_06_l @ -0x0
0x2a47bac 77 yes e105_brg_l(77) e105_brg_l e105_brg_l @ -0x0
0x2a9fda0 76 yes e105_eng_01_l(76) e105_eng_01_l, e303_wep_01_m e105_eng_01_l @ -0x0
0x2ace840 60 yes e105_wep_01_l(60) e105_wep_01_l e105_wep_01_l @ -0x0
0x2d1fee8 119 yes e106_bdy_01_l(119) e106_bdy_01_l, e106_bdy_02_l e106_bdy_01_l @ -0x0
0x2d315d8 119 yes e106_bdy_02_l(119) e106_bdy_01_l, e106_bdy_02_l e106_bdy_02_l @ -0x0
0x2d492c4 146 yes e106_bdy_03_l(146) e106_bdy_03_l e106_bdy_03_l @ -0x0
0x2d7363c 179 yes e106_bdy_04_l(179) e106_bdy_04_l e106_bdy_04_l @ -0x0
0x2d7a418 51 yes e106_brg_01_l(51) e106_brg_01_b_02, e106_brg_01_l e106_brg_01_l @ -0x0
0x2db0ccc 58 yes e106_eng_01_l(58) e106_eng_01_l e106_eng_01_l @ -0x0
0x2db632c 44 yes e106_eng_02_l(44) e106_eng_02_l, e302_base_m e106_eng_02_l @ -0x0
0x2dc305c 82 yes e106_wep_02_01_l(82) e106_wep_02_01_l e106_wep_02_01_l @ -0x0
0x329077c 6000 yes n006_02(6000) n006_02 n006_02 @ -0x0

View File

@@ -0,0 +1,125 @@
map check: 406 fields agree with the disc, 0 disagree
runtime value where the disc record DEFAULTS the field:
AA_AxisMode_Max 0 (10 units)
AA_AxisMode_Min 0, 1.74533 (11 units)
AA_PitchMinus_Max 0, 0.0628319, 0.174533 (8 units)
AA_PitchMinus_Min 0, 0.0628319, 0.261799, 0.349066, 0.698132 (10 units)
AA_PitchPlus_Max 0, 0.0628319 (6 units)
AA_PitchPlus_Min 0, 0.0628319, 1.74533 (8 units)
AA_Roll_Max 0, 0.0628319, 0.174533, 0.523599, 1.0472 (10 units)
AA_Roll_Min 0, 0.0628319, 1.0472 (7 units)
AA_Yaw_Max 0, 0.0628319, 0.0872665 (7 units)
AA_Yaw_Min 0, 0.0628319, 0.261799 (7 units)
AB_AA_PitchMinus 0, 0.174533 (11 units)
AB_AA_PitchPlus 0 (10 units)
AB_AA_Roll 0, 0.523599 (11 units)
AB_AA_Yaw 0, 0.174533 (11 units)
AB_AV_PitchMinus 0 (10 units)
AB_AV_PitchPlus 0, 0.523599 (11 units)
AB_AV_Roll 0, 0.698132 (11 units)
AB_AV_Yaw 0, 0.279253 (11 units)
AB_ConsumeShield 0, 10 (11 units)
AB_ConsumeShield_Begin 0 (10 units)
AV_AxisMode_Max 0, 0.174533 (11 units)
AV_AxisMode_Min 0 (10 units)
AV_PitchMinus_Max 0, 0.0628319, 0.349066 (7 units)
AV_PitchMinus_Min 0, 0.0628319 (6 units)
AV_PitchPlus_Max 0 (1 units)
AV_PitchPlus_Min 0, 0.0628319, 1.74533 (7 units)
AV_Roll_Max 0, 0.0628319, 0.349066, 1.0472, 2.0944 (10 units)
AV_Roll_Min 0, 0.0628319, 1.0472 (7 units)
AV_Yaw_Max 0, 0.0628319, 0.174533 (7 units)
AV_Yaw_Min 0, 0.0628319, 0.349066 (7 units)
AccPitchFactor 0, 30 (5 units)
Acceleration 0 (1 units)
ArterBurner_Acc 0, 2 (10 units)
ArterBurner_Vc 0, 2 (6 units)
AttackCraftPoint 0, 0.1, 0.5, 1 (9 units)
AttackVesselPoint 0, 0.1 (3 units)
BarrelRoll_CountMaximum 0, 5 (11 units)
BarrelRoll_CountMinimum 0, 0.5 (11 units)
BarrelRoll_Radius 0 (7 units)
BarrelRoll_Time 0 (7 units)
BoostAway_Time_Maximum 0, 2 (11 units)
BoostAway_Time_Minimum 0, 1 (11 units)
ChargeDelay 1, 3, 5 (10 units)
ChargeDelay_Break 1, 10 (6 units)
ChargeSpeed 25 (3 units)
Color_B 0, 1 (3 units)
Color_G 1 (1 units)
CruisingVelocity 0 (1 units)
CutoffTimeMax 0, 5 (10 units)
CutoffTimeMin 0 (7 units)
DecPitchFactor 0, 30 (11 units)
Deceleration 0, 50 (2 units)
DefencePoint 0, 0.1, 0.25 (5 units)
Delay 0, 1.5, 3 (8 units)
DelayAdjustment 0, 1, 2 (10 units)
DestroyMotionTime 0 (1 units)
DragFactorThreshold 0, 0.5 (10 units)
DryMass 100, 10000 (8 units)
FCSRange 0, 500000 (3 units)
FiringRange 0 (11 units)
GrossMass 10000, 200 (10 units)
HQRatio 0.2, 1 (9 units)
HoldPosition_BackRatio 0, 0.1, 0.3 (11 units)
HoldPosition_CancelTime 0, 20 (10 units)
HoldPosition_CutoffRatio 0, 0.5 (11 units)
HoldPosition_LengthMax 0, 400 (8 units)
HoldPosition_LengthMin 0, 150 (10 units)
HoldPosition_MaximumTime 0, 10 (10 units)
HoldPosition_MinimumTime 0, 5 (11 units)
HoldPosition_SideRatio 0, 0.3 (11 units)
HomingResistAdjustment 0, 0.5 (11 units)
LowerHPThresholdRatio 0, 0.3 (7 units)
MaxValue 100 (3 units)
MaximumBank_Normal 0, 0.523599, 1.0472 (5 units)
MaximumVelocity 0 (1 units)
MinimumVelocity 0, 100 (7 units)
PitchDragFactor 0, 2, 3 (11 units)
PowerCutConsumeShield 0, 20 (11 units)
PowerCutDeceleration 0, 10 (11 units)
RadarRange 0 (1 units)
ResistanceParalyze 1 (1 units)
ResistanceToExplosion 0.1, 0.5, 1 (9 units)
ResistanceToOptics 0.1, 1 (4 units)
ResistanceToPlayer 0, 1 (10 units)
ResistanceToShell 0.1, 1 (8 units)
ReverseThrust_Acc 0, 0.25, 2 (9 units)
ReverseThrust_Vc 0 (1 units)
RollDragFactor 0, 2, 3 (11 units)
SELength 2000 (11 units)
ShieldRatio 1 (6 units)
SideRoll_Length 0 (7 units)
SideRoll_Time 0, 0.5, 1.5 (11 units)
SideThrustAcceleration 0, 100, 1000 (5 units)
SideThrustVelocity_Max 0, 100, 1000 (3 units)
Size_Radius 10, 50 (4 units)
Size_Y 200, 300, 400, 600, 700 (5 units)
Slalom_CutoffRatio 0 (7 units)
Slalom_TurnCount_Max 0 (11 units)
Slalom_TurnCount_Min 0 (9 units)
SolidCutoff_LengthMax 0 (7 units)
SolidCutoff_LengthMin 0, 500 (9 units)
SolidCutoff_Ratio 0, 0.5 (11 units)
Through_AngleMaximum 0, 1.0472 (11 units)
Through_AngleMinimum 0, 0.523599 (11 units)
Through_CutoffRatio 0, 0.5 (11 units)
Through_LengthMaximum 0, 500 (10 units)
Through_LengthMinimum 0, 250 (11 units)
Through_Time1Max 0, 2 (11 units)
Through_Time1Min 0, 5 (11 units)
Through_Time2Max 0, 1.5 (8 units)
Through_Time2Min 0, 5 (11 units)
ThrusterRatio 0.2, 1 (10 units)
TurnAttack_CutoffRatio 0 (7 units)
TurnAttack_DoubleRatio 0 (7 units)
TurnAttack_DoubleTimeMax 0 (7 units)
TurnAttack_DoubleTimeMin 0 (7 units)
TurnAway_Time_Maximum 0, 2, 5 (11 units)
TurnAway_Time_Minimum 0, 0.5 (11 units)
Turn_AngularVelocity 0, 0.523599 (8 units)
UsingChaffRatio 0 (11 units)
YawDragFactor 0 (1 units)

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

View File

@@ -0,0 +1,474 @@
# Live unit definitions from a running Stage 02 (2026-08-12)
The Route-B question — what value does a field that the disc leaves **defaulted**
actually take at runtime — needs the game running. This is the first snapshot of
the parsed definition objects taken straight out of guest RAM, plus the recipe
that works in the box today, because getting there was most of the work.
## The recipe that works
Everything must happen inside **one blocking foreground call**. A process that
outlives the call that started it is reaped — `nohup`, a harness background job
and a `wait`-holding wrapper were all tried and all died within a minute of the
turn ending (see the session-lifetime notes).
```bash
tools/re-capture/launch_mission.sh fly # title → LOAD → slot 01 → READY ROOM → TAKE OFF
python3 tools/re-capture/gmem.py find hex:820af844 400 # live definition objects
python3 tools/re-capture/gmem.py words <va> 96 # dump each one
```
Two environment facts that cost an hour each:
- **Audio must be `--audio --apu=sdl` with `SDL_AUDIODRIVER=dummy`.** There is no
PulseAudio server in the box, so muting (`--apu=nop`, `run-canary`'s default)
looks like the safe choice. It is not: the log then fills with
`AudioSystem::RegisterClient: CreateDriver failed for index=0`, the guest never
gets past the movie, and the window stays black for 8+ minutes. `nav_probe.sh`
and `launch_mission.sh` already pass the right pair — do not "fix" them.
- **Boot is fast once the caches are warm.** The first boot took minutes; with
the shader/code cache populated, `launch_mission.sh` reached the flight HUD in
**25 s**, which is what makes a launch-and-dump fit in a single call.
`entities2.py`, `flight_probe.py` and the other analysis scripts **do not run
here** — no `numpy` (and no `PIL` for image work). `gmem.py` is pure stdlib and
works, so the pattern is: dump raw words to disc inside the call, analyse
afterwards.
## What came out
14 live definition objects (vtable `0x820af844`), 96 words each:
[`captures/stage02-live-unit-definitions.txt`](captures/stage02-live-unit-definitions.txt),
with the flight screenshot they were taken from.
| object | `+0x30` | `+0x34` | `+0x38` | `+0x54` |
|---|---|---|---|---|
| `0xbd3b6a00` | 2500 | 3100 | 2600 | 10000 |
| `0xbd3d2d80` | 80 | 80 | 350 | 4000 |
| `0xbd3da100` | 10 | 7 | 29 | 1500 |
| `0xbd3db980` | 350 | 70 | 200 | 10000 |
| `0xbd3dbd00` | 10 | 5 | 20 | 300 |
| `0xbd3dea80` | 20 | 9 | 22 | 100 |
| `0xbd3dfc00` | 10 | 7 | 29 | 1000 |
| `0xbd3e0680` | 300 | 300 | 2800 | 3000 |
| `0xbd3e1f00` | 400 | 400 | 1400 | 25000 |
| `0xbd3e6f80` | 300 | 300 | 2100 | 10000 |
| `0xbd3ee300` | 200 | 200 | 2000 | 10000 |
| `0xbd3faa80` | 100 | 40 | 50 | 500 |
| `0xbd3fd800` | 600 | 600 | 3800 | 30000 |
| `0xbd40e200` | 700 | 700 | 3800 | 30000 |
This **corroborates** the layout solved earlier from a single in-mission
snapshot (`+0x054` = HP, `+0x30…0x38` = `Size_X/Y/Z`) rather than re-proving it:
the `+0x54` column reads as hull values across three orders of magnitude
(100 for something small, 30 000 twice for capital ships), and the sizes scale
with them. It also gives independent support to the **`Size_Y` inherits `Size_X`**
default rule — five of the fourteen have `+0x30 == +0x34` exactly
(300/300, 400/400, 200/200, 600/600, 700/700) while the rest differ.
## Identity without a name: match on the values
Nothing in the first 96 words is a name pointer, so identity comes from the
numbers themselves. `examples/unit_signatures.rs` prints `(HP, Size_X, Size_Y,
Size_Z)` for every craft unit and vessel on the disc; a live object is then the
record whose *known* fields it reproduces exactly.
Seven of the fourteen match a disc record on all four values — the player's
Delta Saber (`10, 7, 29`, HP 1500 in its player form and 1000 otherwise), the
ArrowHead, an ADAN turret, an Attacker. **The other seven match nothing, and
that is the point:** their disc records leave a field defaulted, so there is
nothing to match against. **18 of the 23 vessel records on disc are missing at
least one of these four fields**, nearly always `Size_Y`.
## ✅ Route B: 13 defaulted fields read out of the running game
Matching each disc record that defaults exactly one field against the live object
that reproduces its remaining fields resolves the missing value:
| unit | field | runtime value | live object (sx, sy, sz, hp) |
|---|---|---|---|
| `UN_e201_ADAN_ISCMissile` | `Size_Y` | **300** | `0xbd3e0680` (300, 300, 2800, 3000) |
| `UN_f106_TCAF_Destroyer` | `Size_Y` | **200** | `0xbd3ee300` (200, 200, 2000, 10000) |
| `UN_f106_TCAF_Destroyer_Inv` | `Size_Y` | **200** | `0xbd3ee300` |
| `UN_e105_ADAN_Cruiser` | `Size_Y` | **600** | `0xbd3fd800` (600, 600, 3800, 30000) |
| `UN_e105_ADAN_CruiserEX` | `Size_Y` | **600** | `0xbd3fd800` |
| `UN_f105_TCAF_Cruiser` | `Size_Y` | **700** | `0xbd40e200` (700, 700, 3800, 30000) |
| `UN_f105_TCAF_Cruiser_EX5` | `Size_Y` | **700** | `0xbd40e200` |
| `UN_f105_TCAF_Cruiser_Inv` | `Size_Y` | **700** | `0xbd40e200` |
| `UN_e108_ADAN_ASFrigate` | `Size_Y` | **80** | `0xbd3d2d80` (80, 80, 350, 4000) |
| `UN_e108_ADAN_ASFrigateEX` | `Size_Y` | **80** | `0xbd3d2d80` |
| `UN_e106_ADAN_Destroyer` | `Size_Y` | **300** | `0xbd3e6f80` (300, 300, 2100, 10000) |
| `UN_e106_ADAN_DestroyerEX` | `Size_Y` | **300** | `0xbd3e6f80` |
| `UN_f101_TCAF_Acropolis` | `Size_Y` | **400** | `0xbd3e1f00` (400, 400, 1400, 25000) |
**Every resolved value equals that unit's `Size_X`** — so the running game
confirms the `Size_Y` inherits `Size_X` rule that was derived statically from
sibling records, this time from the engine's own parsed definitions.
Two honest limits. Variants share a live object (`_Inv`, `_EX5`, `EX` map to the
same definition as their base), so the snapshot cannot distinguish them — the
value is right for the class, and per-variant differences in *other* fields are
not addressed. And this run only reaches the units Stage 02 instantiates: the
`…Ratio` / `…Count` family (`HQRatio`, `PowerRatio`, `ShieldRatio`, `HatchCount`,
`NodeCount`, `SequencingCount`, …) is defaulted on 523 records each and is
**not** resolved here, because those fields' offsets in the live object are not
yet known. Finding them is the next run: dump deeper than 96 words and look for
the constants the disc *does* set on the records that set them.
## Locating fields inside the live object
The next step — reading the `…Ratio` / `…Count` family — needs to know *where*
each field sits in the live object. The method is to anchor on a unit whose disc
record **sets** a field and look for that value in its dumped words. Anchoring on
`UN_f106_TCAF_Destroyer` (disc sets `Size_Radius 0.1`, `ThrusterRatio 0.8`,
`ResistanceParalyze 0.97`) and then checking the same offsets across five capital
ships and the player's fighter:
| offset | reading | `f106` | `f105` | `e105` | `e106` | `f101` | Delta Saber |
|---|---|---|---|---|---|---|---|
| `+0x30` | `Size_X` ✅ | 200 | 700 | 600 | 300 | 400 | 10 |
| `+0x34` | `Size_Y` ✅ | 200 | 700 | 600 | 300 | 400 | 7 |
| `+0x38` | `Size_Z` ✅ | 2000 | 3800 | 3800 | 2100 | 1400 | 29 |
| `+0x54` | `HP` ✅ | 10000 | 30000 | 30000 | 10000 | 25000 | 1500 |
| `+0x74` | 🟡 `ThrusterRatio` | 0.8 | 0.8 | 0.8 | 0.8 | 0.8 | 1 |
| `+0x84` | 🟡 `ResistanceParalyze` | 0.97 | 0.97 | 0.97 | 0.97 | 0.97 | 0.97 |
| `+0x40` | ❔ | 0.1 | 0.1 | 1 | 1 | 1 | 0.1 |
`+0x74` and `+0x84` are marked 🟡 because they rest on one anchoring unit: the
value is right for the Destroyer and constant across the others, which is
consistent with a field the rest default, but a second anchoring unit that sets
them to something *different* is what would prove it. `+0x40` is 0.1 on two units
and 1 on three, so it is a real per-unit field — just not identified.
⚠️ **The automated version finds almost nothing, and the reason is not what it
first looked like.** `examples/live_offsets.rs` correlates every `get_f32(key)`
against every dumped word and keeps offsets all units agree on. Over five units
it produced exactly **one** hit, `YawDragFactor → +0x0c`, and that hit is
**wrong**: `+0x0c` holds the integer 2 (printed as the denormal `2.8e-45`), which
collided with `YawDragFactor = 2.0`. Any float-compare against small integers has
that failure mode.
The first explanation written here — that `get_f32` is unreliable on
default-heavy records — was **wrong**, and measuring killed it: `get_f32`
resolves **4657 numeric fields per unit** (Destroyer 46, `e105` Cruiser 57,
`f105` Cruiser 49, `e106` Destroyer 51, Acropolis 45). It is the documented,
reliable API and it works.
The real problem is that **the values are not in this object**. Dumping 512 words
(2 KB) instead of 96 changes nothing at all: still exactly **4 of ~50** disc
values found, for every one of the five units. So the structure at vtable
`0x820af844` is not the parsed definition record — it carries the handful of
fields above (sizes, HP, a couple of ratios) and the rest of the record lives
elsewhere, presumably behind a pointer.
Next probe, therefore, is not a bigger window: search guest RAM for a value that
is distinctive to one unit (e.g. the Acropolis' `HP 25000` = `0x46c35000`) and see
what *other* structure holds it, then map that one.
## Where the rest of the record is — three probes, and a hypothesis
Following "search RAM for a unit-distinctive value" produced no parsed record:
1. **`HP 25000`** (`0x46c35000`, the Acropolis) — **200+ hits**, the search limit.
Too common to anchor on: it is also a generic constant.
2. **`AttackVesselPoint 0.08`** (`0x3da3d70a`, an unusual float) — 64+ hits, and
**none of the eight windows dumped around them contained the unit's other
values** (`Size_X 400`, `Size_Z 1400`, `HP 25000`, `RadarRange 60000`). One hit
sits in the executable range (`0x820b62f4`), i.e. a code/rdata constant.
3. **The ID string itself.** `UN_f101_TCAF_Acropolis` appears **twice** in RAM,
each copy referenced by a pointer exactly **16 bytes before the string**. But a
256-word window around it contains **none** of the unit's numbers — because
that region is the IDXD **string pool**, where `25000.0` is stored as *text*.
🟡 **Hypothesis: there is no flat parsed record.** IDXD is a reflective format,
and the evidence fits the engine reading values out of the pool by key when it
needs them, caching only the few it touches per frame. That is exactly what the
`0x820af844` object looks like: sizes (collision), HP (damage), a couple of
ratios — and nothing else, no matter how deep it is dumped.
If that is right, it reframes Route B. For a **defaulted** field there is no value
in the pool at all, so the default is applied by *code* at read time and can never
be found by scanning data structures. It would have to come from either the read
path (trace where a key hash is looked up and what the miss path substitutes) or
from the title's own code — which is the DuckDB static route this project already
has. The 13 `Size_Y` values recovered above worked precisely because `Size_Y` is
one of the cached, every-frame fields.
This is a hypothesis, not a finding: it explains three negative probes but has not
been confirmed by watching a read happen.
## The static route says where the defaults must be
The DuckDB image (`xenia-rs/sylpheed.db`, 12 156 functions / 1.87 M instructions)
has the reflective machinery under real names:
| address | name |
|---|---|
| `0x824486c0` | `IdxdLoad_Dispatch` |
| `0x82448d00` | `IdxdLoad_Variant2` |
| `0x82449640` | `Idxd_Parse` |
| `0x8244a2f0` | `Reflect_FindFieldIndex` |
| `0x8244a4b0` | `Reflect_SetField` |
`Reflect_FindFieldIndex` measures a C string, indexes a buffer and calls a
compare routine — it looks a field up **by name at runtime**, which is the
reflective read the hypothesis above predicted. But `Idxd_Parse` is a **text
parser** (it checks for a UTF-16 BOM, walks characters through a jump table at
`0x824497f8`) and it calls `Reflect_SetField` on a target object passed in `r3`.
That last point **weakens the "no parsed record" hypothesis**: parsing writes
values into a real object. So a parsed record does exist — my three RAM probes
simply did not find the right one — and, more usefully, it tells us where a
*defaulted* field's value comes from. `Idxd_Parse` only ever writes the fields the
text actually contains, so a defaulted field keeps **whatever the object had
before parsing** — i.e. the value its **constructor** stored.
So the defaults are neither in the disc data (by definition) nor in a table to be
scanned for: they are immediates in the constructor of each definition class.
Reading them is a disassembly job — find the allocation site above
`IdxdLoad_Dispatch`, then the constructor it calls, and read the stores. That is
the next step, and it would settle the whole `…Ratio` / `…Count` family at once
rather than one field per flight.
(The `vtables`, `classes` and `methods` tables are empty in this DB build, as
`CLAUDE.md` warns, so the class has to be reached through the call graph.)
## The constructor zero-fills, so the defaults are derived, not stored
Walking the call graph found the class: `sub_8233FAF8` (`0x8233faf8`) writes the
vtable **`0x820af844`** — the very objects dumped above — into `0(r30)` and then
initialises the instance. Every one of its 32 stores writes **zero**
(`stw r29, …` with `r29 = 0`), out to `+872` and beyond, interleaved with
string-member constructor calls.
That kills the natural next hypothesis: **the defaults are not immediates in the
constructor.** The object starts at zero, `Idxd_Parse` writes only the fields the
text contains, and yet a defaulted `Size_Y` reads back as a *non-zero* value at
runtime (200 for the Destroyer, 700 for the `f105` Cruiser — always `Size_X`).
So a defaulted value must be **derived after parsing**, which is exactly the shape
of the `Size_Y` inherits `Size_X` rule this project derived statically from sibling
records. Finding that fixup pass is what would yield the rest of the family.
Two candidate sites turned up by searching for a load of `+0x30` feeding a store
to `+0x34``0x82217e18` (in `sub_82217A90`) and `0x823081e8` — but neither is
confirmed: the first is a *conditional* assignment (it compares `+0x30` against a
register and picks between two values before storing to `+0x34`), not a plain
copy, and offsets 48/52 are generic enough that both could belong to unrelated
structures. Recorded as leads, not findings.
## The filler, and a constant that turned out not to be a default
The call graph completes: `sub_82341048` allocates **880 bytes**
(`addi r3, r0, 880``bl 0x8230C160`), calls the zero-filling constructor
`sub_8233FAF8`, and then hands the object to **`sub_82341A20`** — 15 876 bytes,
**3 969 instructions, 157 `stfs`, 892 calls**. That is the per-field filler for a
unit definition, and it is where any default has to be applied.
Inside it, one pattern repeats: `lfs f, -15176(r30)` followed by `stfs f, +N(r29)`
for N = 336, 340, 344, 348, 352, 356, 360, 428, 512, 516 (and 124…160 of a
sub-object). `r30` resolves to `0x82090000 28780 = 0x82088f94` (from
`addis r11, r0, 0x8209` / `addi r30, r11, -28780`), so the constant sits at
**`0x820854cc`**, which the executable image gives as **`0x3f800000` = 1.0**.
**That looks exactly like "write the default 1.0 into ten fields" — and it is
wrong.** Checking those offsets in the live objects first:
| offset | `f001` fighter | `e007` turret | most others |
|---|---|---|---|
| `+336` | 0.261799 (15°) | 0 | 0 |
| `+344` | 0.174533 (10°) | 0 | 0 |
| `+356` | 0.698132 (40°) | 0 | 0 |
| `+428` | 0 | 0.785398 (45°) | 0 |
| `+512` | 0 | 0.523599 (30°) | 0 |
Those are **radians** — 10°, 15°, 16°, 30°, 40°, 45°, 60° — i.e. per-unit angle
limits converted from the degrees the disc stores (`MaximumBank_Normal 60`
1.0472). So the `1.0` is part of a formula or a conditional path in that
conversion, **not** a blanket default, and the fields themselves are 0 for units
whose records omit them.
Which leaves the picture consistent with everything measured so far: **the ordinary
default is 0**, straight from the zero-filling constructor, and the interesting
non-zero defaults (`Size_Y``Size_X`) are specific derivations to be found
individually in this filler. Worth noting for the reimplementation regardless:
**angle fields are stored in radians at runtime and in degrees on disc.**
## Confirming the field really is defaulted, and where the loader leaves it
Before chasing the derivation any further it was worth checking the premise.
`examples/pool_window.rs` prints the raw token sequence around a key, and for the
Destroyer it reads:
```
[14] "200.0" [15] "Size_X" [16] "Size_Y" [17] "2000.0" [18] "Size_Z"
```
`Size_X` takes the value before it; **`Size_Y` is a bare key** — genuinely
defaulted, exactly as the value-before-key rule predicts. So the 13 values
recovered from the running game are real defaults, not a reader artefact.
The loader's own answer for a missing field is now pinned too. Each size field is
filled by the same three-call sequence — build the key string, call the float
accessor **`sub_822FC5A8`**, store the result:
```
0x82341d94 addi r4, r30, -14004 ; "Size_Y"
0x82341da0 bl 0x8217FA08 ; make key
0x82341dac bl 0x822FC5A8 ; read float
0x82341db0 stfs f1, 52(r29) ; -> Size_Y
```
and `sub_822FC5A8` loads `f31` from `0x8209fd28` = **0.0** at entry and returns it
when the pool lookup misses. **So the definition leaves a defaulted `Size_Y` at
0.0**, and nothing else in the 15 876-byte filler writes `+52`.
Two readings corrected on the way:
- The block at `0x82341e4c``0x82341ebc` that loads `+48`/`+52`/`+56` is **not** a
"was this field set" mask. It compares each against the constant at
`0x8209fd20` = **1000.0** and ORs flags into `+108` — a **size-class bitmask**
for large objects.
- Both earlier `Size_Y` candidates are refuted: `0x82217e18` conditionally picks
between two registers rather than copying, and `0x823081e8` *multiplies* `+48`
by a table constant into `+52` of a different structure.
So the runtime `Size_Y = Size_X` must be written **after** the definition is
loaded — by whatever instantiates a unit from it, or a post-load pass over the
table. `+108` itself is too generic to chase (1 129 loads image-wide).
## ✅ The field map, read out of the code — and the Ratio family with it
The filler builds each key as `addi r4, r30, -N` with `r30 = 0x82088f94`, so the
**field name for every store is a string in the image**. Pairing each key with the
`stfs` that follows gives the object layout directly, with no guessing:
| offset | field | offset | field |
|---|---|---|---|
| `+48` | `Size_X` | `+116` | `ResistanceToOptics` |
| `+52` | `Size_Y` | `+120` | `ResistanceToShell` |
| `+56` | `Size_Z` | `+124` | `ResistanceToExplosion` |
| `+64/68/72` | `Color_R/G/B` | `+128` | `ResistanceToPlayer` |
| `+80` | `Size_Radius` | `+132` | `ResistanceParalyze` |
| **`+88`** | **`HQRatio`** | `+672` | `RadarRange` |
| **`+92`** | **`ShieldRatio`** | `+676` | `FCSRange` |
| **`+96`** | **`ThrusterRatio`** | `+680` | `FiringRange` |
| `+692` | `AttackVesselPoint` | `+696` | `AttackCraftPoint` |
| `+700` | `DefencePoint` | | |
`examples/verify_fieldmap.rs` checks it against the live dump: **62 fields agree
with the disc record, 0 disagree** across five capital ships.
That turns the same dump into the answer for the family the brief asked about —
the runtime value of each field **for the units whose record omits it**:
| field | runtime value(s) seen | units |
|---|---|---|
| `Size_Y` | 200, 300, 400, 600, 700 (each `= Size_X`) | 5 |
| `HQRatio` | **0.2**, **1** | 4 |
| `ThrusterRatio` | **0.2**, **1** | 4 |
| `ShieldRatio` | **1** | 1 |
| `ResistanceToPlayer` | **1** | 5 |
| `ResistanceToShell` | **0.1** | 3 |
| `ResistanceToExplosion` | **0.5** | 3 |
| `AttackVesselPoint` | **0.1** | 2 |
| `AttackCraftPoint` | **0.1**, **0.5** | 5 |
| `DefencePoint` | **0.1**, **0.25** | 4 |
| `FiringRange` | **0** | 5 |
⚠️ Read that table as *"what the field holds at runtime for a unit that does not
set it"*, **not** as "the default constant". Several fields show **two distinct
values** across units (`HQRatio` 0.2 or 1, `AttackCraftPoint` 0.1 or 0.5), so the
value is derived per unit rather than being one global default — consistent with
`Size_Y`, which takes each unit's own `Size_X`. Only `FiringRange` (0 everywhere)
matches the loader's own miss value.
*Method note:* the first run of this check reported 32 "mismatches". They were an
off-by-one column in the dump reader — `f[3]` is the u32, `f[4]` the float — and
decoding one by hand (`1128792000` = `0x43480000` = 200.0) showed the map had been
right all along.
## ✅ The whole map verified — 154/154 — and 101 defaulted fields read out
Anchoring the key string to the **first store after the accessor call** (rather
than to the nearest store) makes the extraction exact: **159 fields**, of which
**125 are floats**. `verify_fieldmap.rs` now checks every one of them against the
live objects: **154 field values agree with the disc record, 0 disagree** across
five capital ships.
Getting there needed one rule, and the ten last "mismatches" are what taught it:
```
AV_PitchPlus_Max disc 3.6 live +176 = 0.0628319 ( = 3.6° in radians)
MaximumBank_Normal disc 60 live +256 = 1.0472 ( = 60° in radians)
```
**Angle fields are degrees on disc and radians at runtime** — the incidental
observation from the previous pass, now confirmed as an exact conversion across
every angle field and every unit.
With the map verified, the dump answers the brief's question for **101 fields**
that these records leave defaulted
([`captures/stage02-defaulted-field-runtime-values.txt`](captures/stage02-defaulted-field-runtime-values.txt)).
The shape of the answer:
- **Angle limits default to 3.6°** (`0.0628319` rad) — every `AA_*`/`AV_*`
`Pitch/Roll/Yaw` `Min`/`Max`, identical across all five units.
- **Afterburner and turn-attack fields default to 0** (`AB_*`, `TurnAttack_*`,
`AA_AxisMode_*`, `AV_AxisMode_*`).
- **`FiringRange` defaults to 0**, matching the loader's own miss value exactly.
- **The ratio family is per-unit, not constant**: `HQRatio` 0.2 or 1,
`ThrusterRatio` 0.2 or 1, `AttackCraftPoint` 0.1 or 0.5, `DefencePoint` 0.1 or
0.25, `DelayAdjustment` 0 or 2 — so these are *runtime values for units that do
not set the field*, not a single global default.
- **`Size_Y` takes that unit's own `Size_X`** (200/300/400/600/700).
That is the `…Ratio` / `…Count` family the mission brief named, answered from the
running game, with the field identities read out of the title's own code rather
than guessed.
## It generalises, it disambiguates, and variants are provably identical
Applying the same map to the other live objects — turrets, fighters, an attacker,
a station, i.e. `UNIT` records rather than `VESSEL` ones — gives **406 field
values agreeing with the disc and 0 disagreeing across 11 objects**. One object
layout serves both schemas.
Two things fell out that the four-value signature could never have given:
**It identifies the object.** `0xbd3b6a00` had two candidates on
`(HP, Size_X/Y/Z)`. Checking every mapped field separates them cleanly:
| candidate | agreement |
|---|---|
| `UN_be005_ADAN_SpaceFortress` | 12 agree, **2 disagree** (`Color_R` 1↔0, `Color_G` 0↔1) |
| **`UN_bf001_TCAF_SchlosBase`** | **14 agree, 0 disagree** |
**Variants really are identical.** Where several ids shared a signature, all of
them now agree on *every* mapped field — `UN_f003_TCAF_ArrowHead` / `_EX4` /
`_EX5` at 48/48 each, `UN_e007_ADAN_Turret` / `_EX4` at 51/51, and three Delta
Saber player variants at 49/49 (the tutorial variant `_Ttrl1` differs by one field
and is excluded). So a shared live definition between variants is a property of
the data, not a limitation of the match.
Across all 11 objects, **121 fields** are defaulted on disc and readable at
runtime. `FiringRange` (0 in all 11) and `ShieldRatio` (1 in all 6 that default
it) look like genuine constants; `HQRatio` (0.2 or 1 over 9 units),
`ThrusterRatio` (0.2 or 1 over 10) and `AttackCraftPoint` (0, 0.1, 0.5 or 1 over
9) are clearly per-unit and must not be recorded as one value.
## Landed in the crate
The map is no longer only an analysis artefact:
- **`data/unit_definition_layout.txt`** — the 159 fields (offset, kind, name),
with the provenance and the two conventions (degrees→radians; a defaulted field
keeps the accessor's miss value) in its header.
- **`sylpheed_formats::unit_layout`** — `fields()`, `field_at(offset)`,
`offset_of(name)`, so a memory snapshot can be read by name.
- **`tests/unit_layout_disc.rs`** — replays the whole check against the
checked-in dump: every mapped float of all 11 identified objects must equal its
disc value (angles compared in radians). Asserts 0 disagreements and ≥400
agreements, and needs **no emulator** — the capture is in the repo.
Full suite green: 11 test binaries, workspace builds.

View File

@@ -0,0 +1,181 @@
# Why Stage 02 is never won — the escort sinks at ~11 minutes (2026-08-10)
**Status: ✅ measured, one 500 s run.** The standing open item since 2026-07-29 was
"no mission completed". This is the first session whose deliverable was the
*ending* rather than a measurement, and it settles why: **the mission is lost
before it can be won, and the pilot's survival policy is what guarantees it.**
Run: `tools/re-capture/mission_run.sh 500 mission01` — boot → Stage 02 in flight →
`pilot.py` (escort-weighted targeting, target commitment, guided missiles) for
500 s, with every entity's hull sampled at 2 Hz and a screenshot every 30 s.
Artifacts at `/sylph-home/re/mission01/` (9 MB `mission.jsonl`, not committed).
## The escort's decay is linear, and it ends the mission
| t (s) | ACROPOLIS hull | % |
|---|---|---|
| 0160 | 25000 | 100 % |
| 180 | 24510 | 98.0 |
| 280 | 20279 | 81.1 |
| 380 | 13799 | 55.2 |
| 480 | 8541 | 34.2 |
| 485 (end) | 8182 | 32.7 |
Untouched until **t ≈ 170 s**, then **≈53 HP/s** with no let-up — so the asset
reaches zero at **t ≈ 640 s**, and the whole-run average rate puts it at 722 s.
Either way the escort is dead at **1012 minutes**, and "the ACROPOLIS is sunk"
is a defeat condition ([mission-escort-state](mission-escort-state.md)).
This also retires a suspicion: the 240 s time-box of earlier runs was *not*
hiding a win, and the ~500 s ceiling of a single blocking tool call is **not**
the binding constraint. A longer session would simply watch the loss arrive.
## What is actually killing it — and the conflict that follows
Attributing damage by co-presence (which hostiles are within 3000 units of the
asset in the sample where its hull drops, damage split evenly among the classes
present — suggestive, not per-shot proof), only **two** classes are ever near it:
| class | samples present | attributed damage |
|---|---|---|
| `UN_e007_ADAN_Turret` | 200 | 8483 |
| `UN_e010_ADAN_Attacker_S` | 194 | 8334 |
Roughly half the damage comes from **turrets** — and `pilot.py` treats turrets as
**keep-out zones at 2500 units, never as targets**. That rule is not arbitrary: a
turret is what shot down every pilot before 2026-07-30, and it is why the craft
now survives. But it means **the policy that keeps the pilot alive also
guarantees the escort dies.** Survival and the objective are in direct conflict,
and the pilot currently resolves it entirely in favour of survival.
The HUD at t≈485 s says the same thing from the game's side:
- `YOU KILLED WARSHIPS` **0000** — not one warship in 500 s, across every run ever;
- `YOU KILLED WARPLANES` **0009** — fighters only;
- `REMAINING OB` **004 → 008** — objectives are being *added* by waves faster than
any are cleared, so the pilot is not touching the objective set at all;
- SHIELD and ARMOR bars full, hull **1500/1500**, 120 missiles spent.
![Stage 02 at t≈485 s](captures/mission01-t485.png)
## The conclusion that matters
The pilot optimises the wrong thing. It maximises survival and fighter kills;
the mission scores **objectives** and **the escort**, and the fighter population
(134 → 92) is close to irrelevant to both. An untouched 1500/1500 hull at the
moment the escort passes 33 % is not a good run — it is **unspent risk budget**.
Concretely, for the next attempt, in priority order:
(**Step 1 below was run on 2026-08-11 and did not hold — see the follow-up A/B at
the end of this file before acting on it.**)
1. **Turrets near the asset must become targets**, not keep-out zones — accepting
hull damage is the only way to cut ~50 % of the incoming escort damage. The
keep-out rule should be scoped to turrets that are *not* threatening the
asset, rather than applied globally.
2. **Engage warships.** `WARSHIPS 0000` forever means the objective class has
never been attacked; `REMAINING OB` rising is the scoreboard saying so.
3. Re-check whether the escort damage rate actually falls once turrets die —
that is the experiment that tells us whether (1) is sufficient or whether the
bombers need dedicated intercept too.
## Method note, learned the hard way
A harness-tracked **background** task does *not* protect the display: the same
run launched in the background lost Xvfb 11 s in, at the turn boundary
(`skip_intro` exit 3, "DISPLAY LOST"). The comment in `launch_mission.sh` saying
the script may be run as a tracked background task is **wrong**; the
one-blocking-foreground-call rule still stands, which caps a single attempt at
the tool's 600 s timeout. And do not pipe a long run through `tail` — the first
attempt printed nothing because `timeout` killed the pipeline before it flushed;
the on-disk artifacts are what survived.
---
# Follow-up A/B (2026-08-11): making turrets targets does **not** save the escort
**Status: ✅ measured (3 runs), and it falsifies the causal claim above.** The
section above ends by naming step 1 — "turrets near the asset must become
targets" — as the fix worth ~50 % of the escort damage. That was an *inference
from an attribution*, never a measurement. It has now been run, and it does not
hold.
## First, a correction to the run labelling
`pilot.py` gained the `SYLPH_KILL_TURRETS` gate at 21:20:28 on 2026-08-10;
`/sylph-home/re/mission02` started at **21:22:25**, i.e. *after* it, with the
gate **on**. So `mission02` was never a second baseline — it is a treatment run
that the previous session produced but never reported. The arms are:
| run | turret rule | evidence (`pilot.log`) |
|---|---|---|
| `mission01` | **off** (baseline) | `tgt=e007` on **0** of 3968 lines |
| `mission02` | **on** | `tgt=e007` on 1588 of 3844 lines |
| `mission03` | **on** (this session) | `tgt=e007` on 1218 of 3514 lines |
`mission01` predates the edit, so the *only* difference between the arms is the
gate. Everything else — build, save slot, nav route, loadout — is identical.
## The result, all three runs truncated to a common t = 428 s
| run | rule | ACROPOLIS @428 s | % | decay rate | turrets seen | turrets killed | attack onset |
|---|---|---|---|---|---|---|---|
| `mission01` | off | 11735 | 46.9 % | 50.3 HP/s | 92 | 30 | 166 s |
| `mission02` | on | 11127 | 44.5 % | 52.7 HP/s | 98 | 34 | 167 s |
| `mission03` | on | 13257 | 53.0 % | 44.4 HP/s | 88 | 41 | 166 s |
**The two runs of the *same* arm differ by 8.5 percentage points (2130 HP) —
more than either differs from the baseline.** The baseline sits *between* the two
treatment runs on escort hull and on decay rate. So the effect of the turret rule
is **not resolvable at n=1 per arm**, and the honest statement is: it was not
demonstrated. Extrapolating each run's own rate, the escort still reaches zero at
**t ≈ 590670 s** in every arm — the mission is lost in all three.
The rule does do the mechanical thing it was written to do (turret kills 30 → 34,
41; 12181588 frames spent with a turret as the committed target). It just does
not convert into escort hull.
![Stage 02 at t≈430 s with the turret rule on — WARSHIPS 0000, REMAINING OB 008](captures/mission03-turretrule-t430.png)
## What *is* reproducible across all three runs
These are the numbers to build on, because they repeat to within a percent:
- **The assault on the ACROPOLIS is scripted, not emergent.** Onset at
**166 / 167 / 166 s** — ±1 s across three runs with completely different pilot
behaviour in between. Nothing the pilot does moves it.
- **The damage split is a property of the scenario, not of our targeting.**
`e007` turrets vs `e010` bombers is **50.6/49.4, 50.0/50.0, 51.0/49.0** — and
the baseline, in which we never fired at a turret at all, splits the same way.
A co-presence attribution that is invariant to whether we attack one of the two
classes is measuring the wave script, not our contribution.
- **Turrets die anyway**: 30 of 92 are already dead by t=428 s in the baseline,
from friendly fire. The turret rule adds ~411 kills on top of that, which is
why its escort-hull effect is small enough to be buried in run-to-run noise.
- `WARSHIPS 0000` and `REMAINING OB` rising (004 → 008) in **every** run,
including this one.
Per-run series: [`captures/escort-decay-ab.csv`](captures/escort-decay-ab.csv)
(10 s grid, hull + turret alive/killed counts for all three runs).
## The methodological finding, which is the transferable one
**A single 430 s flight cannot resolve an escort-hull effect smaller than ~9
percentage points.** Every pilot conclusion drawn from one run — including step 1
above, and including anything drawn from `mission03` alone — is inside the noise.
Each run costs ~10 minutes of wall clock and one blocking foreground call, so a
properly powered A/B is ~6 runs ≈ 1 hour. **Any future pilot tuning must budget
that, or not be believed.**
## What this means for the reason we wanted a win
Story progress was wanted only because unit definitions instantiate per stage, so
unit-field coverage is stuck at 21/110 ([unit struct](structures/unit-struct-runtime.md)).
This result says the pilot route to that coverage is expensive and unproven: the
escort dies on a script at ~1011 minutes, our best lever moved it by less than
the noise, and the objective class (`WARSHIPS`) has never been touched in any run
ever. **Before spending another hour on pilot tuning, the cheaper lever to price
is the save file** — if the profile's save data can be read and understood, stage
unlock state is a much shorter path to the same coverage, and the save format is
itself something the reimplementation needs. Marked `NEEDS-HUMAN` as a direction
choice; no save-file modification has been attempted.

View File

@@ -0,0 +1,75 @@
# The movie ↔ subtitle ↔ voice link ✅ (static)
[`INDEX`](INDEX.md) carried the IXUD subtitle format as 🟡 with **"movie↔track
link unknown (dynamic item)"** — i.e. a question expected to need the running
game. It does not: the link is a config record on the disc, and it verifies
against the disc's own contents.
## Where it is
`dat/tables.pak`, the IDXD record with schema `0x067025b9`. Its keys come from
the movie game-part — the executable carries them next to the symbol
`silph::GamePart_Movie::Impl::OnPrepare`:
```
PATH = dat\movie\ SUBTITLE_FONT = fra.pak+GOTHICB.TTF
SUBTITLE_Y = 600 SUBTITLE_HEIGHT = 33
MOVIE TELOP SUBTITLE VOICETRACK
```
Each movie is one group of values:
```
S02A.wmv MOVIE
fra.pak+pwterop_s02a.prt TELOP (optional overlay)
fra.pak+SUBTITLE_S02A.tbl SUBTITLE
VOICE_S02A VOICETRACK
```
`<lang>.pak+<member>` resolves inside `dat/movie/<lang>.pak`, and the six
language paks (`eng`, `fra`, `deu`, `esp`, `ita`, `jpn`) each carry the same
member names — so the prefix in the table is just the build's language and the
member name is the portable part. The recovered map is
[`captures/movie-subtitle-voice-map.csv`](captures/movie-subtitle-voice-map.csv):
**101 movies, 94 with a subtitle, 83 with a voice track, 21 with a telop.**
## Verified, and one real defect
Every subtitle reference was looked up in the language paks. **93 of 94 resolve.
`SUBTITLE_S12B.tbl` resolves in none of the six languages** — a dangling
reference on the retail disc, not a decode failure on our side. Worth knowing
before the reimplementation treats a missing subtitle table as a bug of its own.
The naming is a convention with two documented exceptions:
- subtitles are `SUBTITLE_<movie base>.tbl` — the only departure is the staff
roll, `SYLPH_HD720p_8M-CBR_2ch.wmv``SYLPH_HD720p_8M-CBR_2ch.tbl`;
- voice tracks are `VOICE_<movie base>` — except five supply-run movies
(`hokyu_LS_s02A`, `hokyu_LS_s02H`, `hokyu_DS_s02A`, `hokyu_DS_s07H`,
`hokyu_LS_s09A`), which point at shared lines `VOICE_D_450…454`.
So a reimplementation can resolve a movie's subtitle and voice **by convention**,
and should fall back to this table for those six cases rather than assuming it.
## What is *not* recoverable from this record
The record also lists ~104 **script ids** (`LOGO1`, `ADVERTISE_MOVIE`,
`STAFF_ROLL`, `MS00A`, `STAGE01_PHASE01`, `S02_SUPPLY_ACROPOLIS`, …) — the names
stage scripts use to trigger a movie. Pairing them positionally with the 101
movie groups **does not work**: the counts differ by three and the drift is
visible at the tail, where the last ids (`S24_SUPPLY_ACROPOLIS`,
`S27_SUPPLY_ACROPOLIS`) would have to map to `hokyu_LS_s24A.wmv` /
`hokyu_LS_s27A.wmv` and under positional pairing do not.
The cause is the same IDXD property that bit the Arsenal table: **the string pool
stores each distinct string once**, so an id whose movie was already named
earlier contributes no new value token and its group silently disappears. The id
list is therefore ❔ until the binary node/index region is decoded — and the CSV
is deliberately keyed by **movie file**, which is the part the data actually
supports.
## Note for the intro-movie investigation
`ADV.wmv` — the one the emulator work is about — has `VOICETRACK = VOICE_ADV`
and **no subtitle and no telop**. So a correct playback of it needs the video and
that one voice track, nothing else from this table.

View File

@@ -0,0 +1,247 @@
# Capital-ship placement — does the `e106` result generalise? (WIP, 2026-07-31)
**Status:** 🚧 **WIP, time-boxed session.** Two results so far: a static audit across all
22 stage containers (done, below) and a first in-mission F10 capture run in Stage 02
(done — three capture logs, but **no capital-ship part correlated**; see "Open").
Context: [`BACKLOG.md`](BACKLOG.md) — "Capital ships assemble wrong in the viewer",
reported 2026-07-30. The oracle and the correlator already exist
([`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md)); the open
question is whether the rules derived from the one validated ship (`e106`, Stage_S01)
hold for other classes.
## 1. Static audit across all stages (offline, reproducible)
```
cargo run --release --example ship_audit -- ../sylph_extract/hidden/resource3d
```
87 lines of output, of which:
- **Only two OUTLIER lines, and they are the same ship twice**:
`Stage_S03`/`Stage_S27`, `f002_bdy_05` centroid `[-1398 6251 918]`, `dist=6540`
vs a cluster spread of `1071`. Every other assembled ship in every other stage
has all parts inside its own cluster.
→ **The user-visible breakage is NOT a gross static-placement outlier for most
classes.** Whatever is wrong in the viewer is either subtler than "part flung far
away" (wrong rotation, wrong mirror, missing part) or lives in the viewer, not in
`assemble_ship`. `f002_bdy_05` is a genuine, separate, reproducible static bug.
- **MULTIKEY**: joint tracks with more than one keyframe, which `read_trs9`'s
single-key read does not model. Recurring rigs: `e_rou_f104` (3), `e_rou_f105` (2),
`e_rou_f106` (2), `e_rou_e102` (6), `e_rou_e108_Missile_open` (2), `e_rou_e501` (1),
and the `e901` boss with 215 tracks per pose. Several of these (`Missile_open`, the
`e901_attack*` poses) are obviously *animation* and harmless for a static pose; the
plain hull rigs `f104`/`f105`/`f106`/`e102` are **not** obviously animation and are
the best hypothesis for a class-specific assembly error. ❔ **HYPOTHESIS — not
verified.** `e106`, the one validated ship, has **no** multikey tracks, which is
exactly how a rule that only works for single-key rigs could have passed unnoticed.
Raw audit output is reproducible with the command above (not checked in; it is
deterministic from the disc).
## 2. First in-mission capture run (Stage 02)
New tool: [`tools/re-capture/ship_capture_session.sh`](../../tools/re-capture/ship_capture_session.sh)
— one blocking session (per the session-lifetime rule): boot → Stage 02 in flight →
N× {screenshot, F10, small yaw}. Each F10 writes its own
`xenia_ship_capture_NN.log` next to the binary.
Run 2026-07-31, 5 presses requested:
- **Boot to in-flight took 24 s** (`skip_intro.sh` skipped the movie at 1 s and 6 s,
title at 11 s, HUD shield bar at 24 s) — much faster than the ~100 s in the notes.
- **3 of 5 F10 presses produced a log** (`_01``_03`, 2964 / 3111 / 3668 draws).
Logs (810 MB each) and the screenshots are at `/sylph-home/re/shipcap/`; not
committed for size. One screenshot is checked in as
[`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png).
- The screenshot confirms the capture frames are real in-mission combat frames
(HUD live, `REMAINING OB 004`, ACROPOLIS + a Destroyer labelled on screen, a
capital-ship hull filling the bottom of the frame).
### Result: no correlation yet ❌
```
correlate_capture xenia_ship_capture_03.log Stage_S02 <id> bdy_01
```
for `f101` (ACROPOLIS), `f105`, `f106`, `e105` reports *"no draw matches any LOD
(culled/off-screen?)"* for essentially every part — only two speculative LOD tries
(`f101_bdy_03` vcount 90 `[l]`, `e105_wep_01` vcount 60 `[l]`) and **zero accepted
matches**.
That is a **negative result, and it is not yet explained**. Facts collected:
- The capture is not empty or degenerate: 3668 draws in `_03`, top shaders
`0xDA51B0745ABF85D2` (1258), `0xE0BAFB4F520FE441` (1091), `0xEEA84C59D7F95371` (770).
None is the `e106` ship-shader hash from the 2026-07-26 capture; the F10 path does
not filter by hash, so this alone is not the cause.
- Large vertex counts *are* present (3024, 2772, 1736, 1612, 1240 …), so capital-ship-
sized geometry is being drawn.
Candidate explanations, **untested**:
1. the Stage-02 capital ships on screen are drawn from LOD/damage variants
(`_d00`, `_m`, `_l`) whose vcounts the correlator's variant list does not cover;
2. the position-validation step rejects otherwise-correct vcount hits (the capture
dumps ≤64 positions — a set-membership test against the wrong variant fails);
3. the ships in view at launch are drawn by a *different* draw path than `e106` in
Stage_S01 (e.g. instanced/batched), so no single draw equals one part.
**First step next session:** take the largest few vcounts in the capture and ask which
decoded part in `Stage_S02.xpr` has that count (invert the match), instead of asking
per-part whether a draw exists. That distinguishes (1)/(2) from (3) immediately.
## 3. The inverted match — the ships were never drawn (2026-08-10) ✅ explained
The inversion was run and it settles the negative result. Two new tools:
```
cargo run --release --example invert_capture -- <capture.log> Stage_S02 [top_n] [--ship f101]
cargo run --release --example vcount_index -- ../sylph_extract/hidden/resource3d <capture.log>
```
`invert_capture` asks, of the capture's own vertex counts, which resource in one stage
container has that count; `vcount_index` asks the same across **all 166 containers**
(5480 resources), so a draw whose geometry lives in `Common.xpr`, a `rou_*` weapon pack
or a `DeltaSaber_*` player-craft pack is identified instead of coming back "unknown".
On `xenia_ship_capture_03.log` (3668 draws, Stage 02):
| capture vcount | draws | what it is |
|---|---|---|
| 10891 | 28 | **`DeltaSaber_T:f001`** — the player's own craft |
| 6000 | 14 | `Stage_S02:n006_02` — backdrop |
| 1096 / 1008 / 841 / 215 / 127 | 14112 | `rou_f001_wep_*` — the player's weapons |
| 417 / 279 / 201 / 167 | 104448 | `Base:j00*`, `ptc_pack:*` — HUD/particles |
| 8 / 4 / 3 / 1 | 317590 | particle quads |
- **Not one capital-ship hull part appears.** Per ship: `f101` **1 of 15** resources had a
drawn vcount (`f101_bdy_03_l`, 90 verts), `e105` 3 of 37, `e106` 3 of 34 — and each of
those hits is a 44225-vertex `_l`/`_b` piece whose count also collides with dozens of
unrelated resources, i.e. probably not even the ship.
- The 3668 draws span **~14 frames** per F10 press and use only **10 distinct vertex
shaders**, and the player's own craft is captured at **full detail with its `c0..c2`
WVP rows** — so the capture path itself is healthy and unfiltered.
- The screenshot ([`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png))
agrees once read carefully: the hull "filling the bottom of the frame" is the **player's
own craft** in the chase view. The nearest contact on the HUD is a wingman's engine trail.
**So hypothesis (3) is dead, and (1)/(2) never applied.** The correlator's message
"no draw matches any LOD (culled/off-screen?)" was literally true: the ships were far
enough away that the renderer drew nothing of them. `correlate` additionally cannot
anchor without the reference part, and `f101_bdy_01` was never drawn at any LOD.
**The variable that was never controlled is RANGE.** New tooling closes that gap:
[`tools/re-capture/approach_capture.py`](../../tools/re-capture/approach_capture.py)
locks onto a capital ship (definition size-radius ≥ 150 = not a fighter), flies at it
with navigator.py's drift compensation and CPA avoidance, firing disabled, and presses
F10 as each range band is crossed (8000 / 6000 / 4500 / 3000 / 2000 / 1400 / 900),
stamping every capture with its distance in `approach-bands.jsonl`. Driver:
[`ship_capture_close.sh`](../../tools/re-capture/ship_capture_close.sh). Besides giving
the correlator a full-detail frame, the stamped bands measure the game's own **LOD
ladder** per part, which the reborn renderer needs anyway.
## 4. Controlled-range capture — three classes verified (2026-08-10) ✅
`ship_capture_close.sh 240` ran one blocking session: boot → Stage 02 in flight →
lock the `f105` cruiser → close on it at full throttle, F10 at each range band.
Six bands fired (7375 / 5937 / 4394 / 2994 / 1944 / 1259 units, stamped in
`approach-bands.jsonl`); **3 of 6 presses produced a log** — the same 3-of-N as the
earlier session, so a press during a previous 8 MB dump is still lost. Logs at
`/sylph-home/re/shipcap-close/` (not committed, ~9 MB each).
The difference from every earlier capture is immediate: `f105_bdy_01` (10926 verts),
`bdy_02`, `bdy_03`, `eng_01`, `sld_01` are all **drawn at full detail**, and the far
log additionally caught the `e105` and `e106` hulls.
### 4a. One log is ~14 frames, and mixing them silently corrupts the result
`WV_ref⁻¹ · WV_p` cancels the camera **only within one frame**. The capture log has no
frame delimiter, so `correlate_capture` was mixing ~14 frames; with the camera closing
at ~760 u/s that is not a small error — two logs of the same cruiser disagreed by
1090 units on `f105_bdy_02`. New `ship_capture::segment_frames` splits the log wherever
a vertex buffer recurs (over-splitting is harmless — a block is still one camera;
under-splitting is what corrupts), and new
[`correlate_frames`](../../crates/sylpheed-formats/examples/correlate_frames.rs)
correlates each block independently and **cross-checks the blocks against each other**.
Two aggregation rules had to be right, and both were wrong first:
- **Only blocks with the requested reference part count.** A block that fell back to
another reference expresses its parts in a different frame — averaging them in
produces a "disagreement" of exactly the distance between the two references.
- **Consensus, not median.** A stage holds several ships of one class; they share
vertex buffers, and a block can hold one instance's full-LOD part beside another's
`_m` copy (different buffers, so nothing splits them). The largest cluster of
mutually-agreeing blocks is the placement; the rest are reported as
`(+N other-instance)` rather than averaged into nonsense.
With that, every part reproduces across independent frames to **≤1 unit** (typical
spread 0.030.2).
### 4b. Static assembly matches the runtime on all three classes ✅
`correlate_frames … --static <Stage_S02.xpr>` diffs `assemble_ship` against the capture,
translation **and rotation**, both re-expressed in the reference part's frame:
| ship | rig | parts compared | worst dT | worst dR |
|---|---|---|---|---|
| `f105` TCAF cruiser | 1 engine, mirrored `sld` pair | 5 | **0.12** | **0.000** |
| `e105` ADAN cruiser | 6 hull bodies, bridge, engine | 7 | **0.05** | 1.711 (`eng_01` only) |
| `e106` ADAN destroyer | 2 nacelles + centre, turret | 8 | **0.43** | 0.098 (`eng`/`wep` only) |
**So the `e106` rules DO generalise.** Translation is exact for every part of every
class — 20 of 21 comparisons under 0.5 units. This is the answer the BACKLOG item asked
for, and it is the opposite of the assumption in it: `assemble_ship` is right, so the
viewer's "capital ships assemble wrong" is the viewer's own transform stack (the
backlog's own "worth ruling out first, cheaply").
Both first-pass exceptions were chased down, and neither survives as an open question:
-**`e105_brg` was genuinely missing — a real assembler bug, now fixed.** Tier 3
matched a part to its `GN_*` hardpoint by trailing index, so an index-less part
(`e105_brg`) compared `"01" == ""` against `GN_Bridge_01` and fell through silently.
With no index to match on, take the lowest-numbered frame of the category. The
runtime is the check: `e105_brg` now assembles at `[0.0, 70.0, -1850.0]` relative to
`e105_bdy_01`, **dT 0.03, dR 0.000** against the capture.
Reach measured by diffing `assemble_ship` part counts over all containers before and
after: **34 (stage, ship) entries gain parts**`e102` +2 (bridge *and* engine),
`e104` +1, `e105` +1, across Stages 0229. Every one of those ships was assembling
without its bridge. `ship_audit` is unchanged (still exactly the `f002_bdy_05`
outlier), so nothing regressed.
- ✅ **The rotation deltas were an artefact of my own aggregation, plus one real
articulation.** The static diff was comparing against a rotation taken from the
first sampled block, which can belong to *another instance* of the class; scoping it
to the position-agreeing cluster drops `e105_eng_01` from dR 1.711 to **0.000** and
both `e106` nacelles to **0.000**. What remains is `e106_wep_02_01` at dR 0.134 — and
that part's rotation varies by **0.182 between blocks that agree on its position**,
i.e. the runtime disagrees with itself more than it disagrees with the assembler.
It is a turret aiming, not an assembly error. `correlate_frames` now prints that
`rotVar` column precisely so "the part moved" cannot be mistaken for "the rotation
is wrong".
Final numbers, three classes, 21 parts: **worst dT 0.43, worst dR 0.000** for every
part that is not articulating.
`include_external` matters and is a caller-side trap: with `false` an `e106` assembles
as **5** parts and with `true` as **11** — the engine cluster, the bridge and the
cross-id `e303_wep_01` turrets live in separate composites (`e_rou_e106_eng`, 3 nodes)
that the primary-composite pass never reaches. The viewer takes it as a parameter
(`iso_loader.rs:4012`); if it is ever passed `false`, ships lose their engines and
bridge — which looks exactly like "assembles wrong".
## Honest summary
- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
(`f002_bdy_05`), reproducible.
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
- ✅ The earlier zero-match was **range**, not a format or correlator bug (§3): the
captures were taken where no capital-ship geometry is drawn at all.
- ✅ With range controlled (§4), **three classes**`f105`, `e105`, `e106` — reproduce
static assembly to **≤0.43 units** in translation, cross-checked across independent
frames. The `e106`-derived rules generalise; the MULTIKEY hypothesis in §1 is *not*
needed to explain anything observed so far (`f105` has 2 multikey tracks and still
matches exactly).
- ✅ One real assembler bug found and fixed by this route: index-less `brg`/`eng`/`sld`
parts never matched their `GN_*` frame, so **34 (stage, ship) entries** assembled
without a bridge (and `e102` also without its engine). Verified against the capture.
- ▶ Next: the viewer itself (`include_external`, node-instance recursion) — the format
layer is now measured, not assumed. A per-ship regression table over the checked-in
captures would keep it that way.

View File

@@ -0,0 +1,468 @@
# Save file (`savedata`) — container ✅ exact, 3 fields named ✅, rest ❔ (2026-08-11)
**Status: ✅ CONFIRMED for the container and the chunk layout** — parsed off the
title's own serializer, not guessed, and verified by a byte-identical round-trip.
**Points, flight time and clear ratio are ✅ named** off the game's own Details
panel; **the rest is still ❔** and none of it is asserted.
Parser + round-trip check: [`tools/re-capture/savegame.py`](../../../tools/re-capture/savegame.py)
(`savegame.py <savedata> --verify` re-serializes the parse and asserts equality).
## Where it lives
```
<xenia content>/<XUID>/535107D4/00000001/game01/savedata 545 B payload
<xenia content>/<XUID>/535107D4/Headers/00000001/game01.header 328 B
```
The **header** is the Xbox content header, not game data: a UTF-16BE display
string (`Game01 07/23/2026 21:08 STAGE02 EASY`), the internal name `game01`, and
the title id `53 51 07 D4`. It is what the save-select screen lists.
`savedata` is the whole save. **545 bytes** — there is no second file, so
everything the game remembers between sessions is in here.
## Container
```
'GDHA' <146-byte header> <zlib stream, 78 DA>
```
The deflated payload is 545 bytes. Two of the GDHA header words are a FILETIME
and a copy of the slot-0 fields; **much of the rest is uninitialised memory**
words like `0x828F3DA8`, `0xBD4B6200`, `0x70D8FD50` are guest virtual addresses
that leaked out of the struct's padding, so the header is not byte-reproducible
and must not be treated as meaningful. The last four bytes of the file are the
zlib stream's own Adler-32, and the same value also appears at header offset
`0x8E`.
## Payload — a chunk stream written by `0x822C00E8`
Every element below is read off the serializer, whose writer primitive is
`0x821885A8(stream, buf, len)`:
| bytes | what | source |
|---|---|---|
| `'GDAA'` | payload magic | `addis 0x4744 / ori 0x4141` at `0x822C00F8` |
| `u32 len`, `char[len]` | **current game phase**, here `GP_BUNK` | `lwz r11,0(save)`; if `< 29`, index the GP_* name table at `0x820A5680` |
| `'GHAD'` + 122 B | the progress block | `0x822BF678`, called with `save+8` |
| `u32 16`, 16 × (`'SHAB'` + 5×u32) | a 16-record result table (**not** the UI's save slots — see Result 3) | `addi r28,r0,16` / stride `addi r29,r29,20` |
| `u32 4`, `"BUNK"`, `'NETA'`, `u32` | trailer | — |
**The in-memory struct is written field-by-field with no packing changes, so a
payload offset is also the offset in the live save object**: the GHAD block is
`save+8`, the slot table is `save+136`, and the serializer's next access after
the table is `lwz r11,456(save)` — exactly `136 + 16*20`. That makes this doc a
map of the runtime object too, which is what a live-RAM read would need.
The phase name is ✅ real: `GP_BUNK` is one of the title's screen ids, and the
.pe carries the neighbouring list `GP_HANGAR`, `GP_READY_ROOM`, `GP_BUNK`,
`GP_MOVIE`, `GP_OPTIONS`, `GP_MISSION_*`. (Note the `SHAB` string that also
appears in the .pe is a **false hit** — it is inside the RTTI name
`.?AVSCRIPT_COMMAND_PUSHABLE@SilpheedSCS@@`. The four-character tags are built
as `lis`/`ori` immediate pairs, so they are not in the string pool at all.)
### The GHAD block (122 bytes, `0x822BF678`)
Ten u32, one u64 (`ld r11,40(r30)`), four u32, a raw 4-byte field and a raw
54-byte blob. 10·4 + 8 + 4·4 + 4 + 54 = **122**, which is exactly what the file
carries — the layout is closed, with nothing unaccounted for.
Field names below come from the **LOAD/SAVE GAME screen's own Details panel**,
photographed against this exact save (see "Naming the fields", further down).
| off | value in this save | reading |
|---|---|---|
| +0 | 0 | ❔ |
| +4 | **324773** | ✅ **flight time, milliseconds** — the screen shows `Flight Time 000:05:24` and 324773 ms = 5 m 24.773 s |
| +8 | 5 | ✅ **clear ratio, percent** — the screen shows `Clear Ratio 5 %`. **It is not a stage counter**: developing one Arsenal weapon stepped it to 6 (see [the develop differential](#the-develop-differential-one-weapon-three-fields)) |
| +12 | 0 | ❔ |
| +16 | 1 | ❔ |
| +20 | 0 | ❔ |
| +24 | **4101** (`0x1005`) | ✅ **Points — the spendable balance.** Named from the Details panel, and **separated from +28** by the develop differential: spending 4000 P moved *only* this field (4101 → 101) and the panel then read `Points 101 P` |
| +28 | **4101** (`0x1005`) | 🟡 **not** the displayed Points — it did **not** move when 4000 P was spent. A lifetime/earned total is the obvious read; unproven until a save is taken after *earning* points |
| +32 | 79 | ❔ |
| +36 | 2 | ❌ **not** difficulty and **not** stage — refuted by probe saves (see below) |
| +40 (u64) | 2014400 | ❔ |
| +48 | 0 | ❔ (`Times Cleared: 0` is on screen, so it is one of the zero fields) |
| +52 | 2 | ❌ see +36 — refuted |
| +56 | 2 | ❌ see +36 — refuted |
| +60 | 0 | ❔ |
| +64 (raw 4) | `09 15 00 00` | ❔ the trailer's u32 is the **same** value |
| +68 (raw 54) | see below | ✅ **per-item Arsenal development state**, one byte per item, `4` = owned, `2` = developable now, `0` = locked ([evidence](#the-develop-differential-one-weapon-three-fields)). Only the `4`s are stored state — `2` is re-derived at load. The index space is **solved**: it is `strings.tbl`'s item order, cut items included ([mapping](../arsenal-develop-economy.md)) |
The 54-byte blob is the interesting one — a fixed-length array of small
enumerated states, **16 of the 54 non-zero** (ten 4s, six 2s):
```
04 02 00 00 00 02 00 00 00 04 02 00 02 00 00 00 00 00 00 00 00 04 04 00 00 00 04
02 00 00 00 02 00 04 00 00 00 00 00 04 00 00 00 00 00 04 04 04 00 00 00 00 00 00
```
The two questions here — *what the values mean* and *which list the index runs
over* — are separate, and were answered separately: the develop differential
below settles the first, and four hand-written probe saves settle the second (the
index space is `strings.tbl`'s item order; see
[the economy note](../arsenal-develop-economy.md)). The `54` length matches no
count established elsewhere, and in particular it is **not** `weapon.tbl`'s
equally-54-long id run — that coincidence cost real time.
### The develop differential: one weapon, three fields
**2026-08-11, in the running game.** The save state loaded from slot 01 (Stage 02,
"At Standby", 4101 P) was taken into READY ROOM → ARSENAL → GUN, where exactly one
item was developed — **Light Machine Gun MG I, 4000 P** — the mount prompt was
declined so that nothing but the development changed, and the result was saved to
the first empty slot (03). Slots 01/02 were not touched.
Diffing the 545-byte payload of slot 03 against slot 02 (the same state saved
*before* the development) moves **exactly three things**:
| field | before | after | what the screen showed |
|---|---|---|---|
| +8 clear ratio | 5 | **6** | slot 03's Details panel: `Clear Ratio 6 %` |
| +24 Points | 4101 | **101** | the Arsenal's `Points` readout, and slot 03's `Points 101 P` |
| +68 blob `[1]`, `[2]` | `2`, `0` | **`4`**, **`2`** | "You obtained Light Machine Gun MG I" and "You can now develop Light Swivel Machine Gun MG2H" |
Everything else — `+28`, `+4` flight time, `+36/+52/+56`, the `SHAB` table, the
trailer — is byte-identical. That is what makes the reading of the blob more than
a guess: **one action produced exactly two blob transitions, and the game
announced exactly two events, in the same order**. `2 → 4` is the item that was
bought; `0 → 2` is the tech-tree successor it unlocked. So the alphabet is a
three-state progression *locked → developable → developed*, and the blob is the
Arsenal's development state, not a bitmask of owned weapons (a bitmask could not
represent the middle state).
**Which item each index is** was chased separately and is **solved** — see
[the Arsenal develop economy](../arsenal-develop-economy.md). The index space is
the item order in `GP_HANGAR_ARSENAL.pak :: eng\strings.tbl`, i.e. the display
order **plus the cut items that only the localisation file lists**, pinned by four
hand-written probe saves (indices 9, 21, 39, 48) and closing exactly at 53. The
retail save's five otherwise-unexplained owned entries are those cut items,
shipped flagged as owned and never rendered.
One more thing this does **not** settle, and should not be written as if it did:
- **Whether the +1 on the clear ratio is per item.** One data point. If it is
per item, the ratio is a *collection* metric and not a stage metric, which
matters because it is one of the three numbers the Details panel shows and it
had been read as mission progress.
Evidence: [`savedata-game03-developed-mg1.bin`](../captures/savedata-game03-developed-mg1.bin)
(compare against [`savedata-game02-samestate.bin`](../captures/savedata-game02-samestate.bin)
with `savegame.py`), and the screenshots
[before](../captures/arsenal-gun-list-predevelop.png) ·
[after](../captures/arsenal-mg1-developed.png) ·
[Details panel](../captures/savegame-details-slot03-developed.png).
### The 16-record `SHAB` table (16 × 20 bytes)
| slot | a | b | c | FILETIME |
|---|---|---|---|---|
| 0 | 2 | 4101 | 324773 | **2026-07-23 20:07:23** |
| 115 | 0 | 0 | 0 | 2006-01-01 00:00:00 |
**The FILETIME reading is ✅ confirmed independently**: the last two u32 of slot 0
decode as a Windows FILETIME to 2026-07-23 20:07:23 UTC, and the content header's
own display string — written by the game, parsed by nobody here — says
`07/23/2026 21:08` (UTC+1). The 15 unused slots all hold 2006-01-01, a plausible
epoch default rather than zero, which is a second check on the same reading.
Fields a/b/c of record 0 are copies of GHAD +36, +24/+28 and +4 — i.e.
difficulty/stage, Points and flight time, the same triple the Details panel
prints. What the table is a table *of* is settled in Result 3 below: not saves.
## Naming the fields — a second save, made in-game (2026-08-11)
The one save on disc could not name anything, so a second one was made. Route,
all with 60 ms d-pad taps, driven by
[`tools/re-capture/nav_probe.sh`](../../../tools/re-capture/nav_probe.sh) (boots
to the READY ROOM, then walks a scripted step list, screenshotting after every
step **and stamping every save file's md5** — so the trail says exactly which
keypress wrote a save):
> READY ROOM → ↓×4 **SYSTEM** → A → *(≈50 s to load under lavapipe)* →
> SYSTEM MENU `BACK / LOAD GAME / SAVE GAME / OPTIONS / BACK TO TITLE` → ↓↓
> **SAVE GAME** → A → slot list, **first empty slot preselected** → A →
> `Save game?` → **cursor starts on YES** → A.
Two traps, both paid for: the SYSTEM screen takes **>15 s** to load and inputs
sent during it are dropped; and the save confirm starts on **YES**, unlike the
load confirm which starts on NO — the first attempt sent the load flow's extra
`↑` and so pressed **NO**, writing nothing (the md5 stamp is what showed this).
A third trap, found on the Arsenal run: **a 60 ms d-pad tap is ignored inside a
modal yes/no dialog** even though it is exactly one step in the list menus. The
`Do you want to develop this weapon?` cursor did not move until the press was
held ~250 ms (`nav_probe.sh`'s own `step()` duration). Always screenshot the
dialog and confirm which side the cursor is on before pressing A — the develop
confirm starts on **NO**, the "would you like to mount it now?" prompt that
follows it starts on **YES**, so a fixed key sequence cannot be trusted across
two dialogs that appear back to back.
### Result 1 — the payload is a pure function of game state ✅
Saving the just-loaded state into empty slot 02 produced `game02/savedata`. Its
**545-byte payload is byte-identical** to `game01`'s — same phase, same GHAD
block, same slot table, same FILETIME inside it. Only the GDHA *header* differs,
and every differing byte is either the container FILETIME at `0x0A` or one of the
guest-pointer words (`0xBC455680``0xBC474020`, `0xBD4B6200``0xBD3B5180`,
`0x70D8FD50``0x70A8FD50`, …). **That empirically confirms the "uninitialised
padding" call**: those words moved between two saves of identical state, by the
deltas you would expect of heap addresses.
So the payload carries **no timestamp, no slot number and no save name** — a
save's identity lives entirely in its content header (`Game02 08/11/2026 06:55
STAGE02 EASY` vs `Game01 07/23/2026 21:08 STAGE02 EASY`; the `__thumbnail.png`s
are identical too). Everything in the 545 bytes is game state, which is also a
second check that the parse leaves nothing unexplained.
### Result 2 — the Details panel names the numbers
Highlighting a slot fills a **Details** panel. Against this save it reads:
> `STAGE 02 — Declaration of War` · `Game Status: At Standby` ·
> `Points 4101 P` · `Times Cleared: 0 Times` ·
> `Difficulty EASY` · `Flight Time 000:05:24` · `Clear Ratio 5 %`
![LOAD GAME details for the state this save holds](../captures/savegame-details-slot02.png)
`4101`, `324773` and `5` are all in the GHAD block, which is what pins **Points**,
**flight time (ms)** and **clear ratio** above. Note `Clear Ratio 5 %` with
`Times Cleared: 0` and one stage finished is consistent with clear ratio =
stages cleared ÷ 20 — the slot list is also 20 entries — but that is ❔.
**What one save still cannot decide:** `Difficulty EASY`, `STAGE 02` and two other
fields all have the value **2**, and `Times Cleared: 0` collides with five zero
fields. Separating them needs a save whose stage or difficulty differs, not
another copy of this one.
### Result 3 — the 16 `SHAB` records are not the UI's save slots
The save UI has **20** slots; the payload has **16** records. Slot 0's FILETIME
stayed at 2026-07-23 in a save written on 2026-08-11, and the 20-slot list is
built from separate `gameNN` files. So `SHAB[]` is **part of the game state**,
not a directory of saves. Record 0 is `(2, 4101, 324773, 2026-07-23 20:07:23)`
difficulty/stage, Points, flight time in ms, and when — i.e. the same summary as
the Details panel, which makes **a per-stage result record** the obvious 🟡 read
(one stage finished → one record filled). Falsifiable the moment a second stage
is cleared: `SHAB[1]` should fill.
## The `SHAB` table is the per-stage record table — record 0 is Stage 01 ✅
**2026-08-11.** `EXTRAS → MISSION SELECT` renders per-stage records, and they
match the table. For Stage 01 the screen shows
> `High Score 4612 P EASY` · `Best Time 05:24.77 EASY`
and `SHAB[0]` is `(a=2, b=4101, c=324773, FILETIME 2026-07-23 20:07:23)`, where
**`c = 324773 ms = 5 m 24.773 s`** — exactly the displayed best time. With
sixteen records and sixteen story stages (see the `.rdata` section above), the
table is **indexed by stage**, and record 0 is Stage 01 — the only stage this
save has cleared.
| field | record 0 | reading |
|---|---|---|
| `a` | 2 | 🟡 the **difficulty of the record** — the screen tags both entries `EASY`, and 2 is the only value present |
| `b` | 4101 | ❔ **not** the displayed high score (4612). It equals GHAD `+24`/`+28`, so it is a points figure, not the score with bonus |
| `c` | **324773** | ✅ **best clear time, ms**`05:24.77` on screen |
| `d,e` | FILETIME | when the record was set |
This also explains a probe that looked like a dead end: filling `SHAB[1]` changed
nothing on the save screen because the table holds **stage records, not
progress** — and Stage 02, where this save sits, has never been cleared, so its
record is legitimately empty. It promotes the earlier 🟡 "per-stage result
record" guess to ✅ for the layout, with the time field named exactly.
Evidence: [`captures/mission-select-stage01-record.png`](../captures/mission-select-stage01-record.png).
The route is `EXTRAS → MISSION SELECT` from the title menu; only cleared stages
are selectable (16 d-pad presses never left Stage 01), and the screen carries its
own **difficulty selector** (`Y`) — that selector is the *next run's* difficulty,
while the `EASY` beside each record is the difficulty the record was set on.
**The title-menu screens read the auto-save (slot 01), not the newest slot.**
Tested: 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 a probe written to a throwaway slot is **invisible** on these
screens.
Two consequences. First, the identification above is unaffected — the match was
against slot 01's own bytes, which is exactly what the screen renders. Second,
**mapping the difficulty enum (`a`) or testing `b` requires editing slot 01**, the
only save carrying real progress. That is deliberately not done here: it is the
user's call, and it is the one edit in this whole line of work that is not
trivially reversible from a throwaway copy. (A full content backup exists at
`/sylph-home/re/content-backup-2026-08-11-preSaveRE`, and slot 01's bytes are
archived, so the call is *recoverable* — it is still not mine to make.)
One more thing this settles: **`High Score 4612` is not stored anywhere.** It is
absent from every file in the content tree — both `.gpd` profile files, all three
saves (compressed and inflated) and `xconfig.settings` — while the best time
`324773` is present in slot 01 alone. So the score shown on that screen is
**computed at display time** from stored fields, which is why `SHAB[0].b = 4101`
does not equal it.
## What this does and does not unlock
It settles the format. It does **not** yet settle which field is stage-unlock
state, so it does not yet answer the question that motivated it — whether stage
progress can be reached without winning missions
([mission outcome](../mission-outcome-stage02.md)).
**Two differentials have been run.** The same-state one named three fields but
could not separate fields that shared a value; the develop one (above) separated
`+24` from `+28` and gave the blob its alphabet. What is left, in order of what
it would settle:
1. **A save at a different difficulty** — start a new game on NORMAL, save,
compare: whichever of +36/+52/+56 moves is the difficulty.
2. **A save after clearing a stage** — settles stage vs. difficulty, `Times
Cleared`, whether `SHAB[1]` fills, and whether the clear ratio steps by 5 %.
3. ~~**A save after developing one Arsenal item**~~ — **done**, see
[the develop differential](#the-develop-differential-one-weapon-three-fields).
Its follow-up is a *second* development in a different weapon category, which
is what maps blob indices to items — but it needs Points, and the balance is
now 101 P, so it waits on a mission payout.
## Writing a save back — the container's derived fields
**A hand-written save loads.** Done 2026-08-11 in the sandbox, into our own
throwaway slot 03 (the user's slots 01/02 were not touched and the original of 03
is archived in `captures/`), to settle a question that otherwise needed story
progress — see [the Arsenal develop economy](../arsenal-develop-economy.md).
`savegame.py` round-trips the *payload*; the container needs three more fields,
all functions of the payload, found by diffing two saves whose payloads differed:
| header off | value |
|---|---|
| `+0x30` u32 | `len(deflate stream) + 10` |
| `+0x8c` u16 | `len(payload)` — 545 in every save seen |
| `+0x8e` u32 | `adler32(payload)` — the zlib trailer, duplicated into the header |
Everything else is copied from the donor. That is safe precisely because of the
earlier same-state result: the words that move between two saves of one state are
the FILETIME and uninitialised guest-pointer padding, so nothing else is
validated. [`tools/re-capture/savegame_edit.py`](../../../tools/re-capture/savegame_edit.py)
implements it and **re-wraps a real save byte-identically**, which is the check
that the three fields above are the only derived ones.
What the title does with an edited save is itself a finding: on load it
**re-derives** which items are *developable* and announces the difference ("You
can now develop …"). So the blob's `4`s are authoritative state and its `2`s are
not — see the economy note.
### What the probes then refuted: the whole GHAD block is **not** stage or difficulty
The three fields that all hold `2` were read as "difficulty or stage, undecidable
from one save" from the day the format was parsed. Writing saves makes it
decidable, and the answer is **neither** — and the sweep did not stop there.
Method — [`boot_menu.sh`](../../../tools/re-capture/boot_menu.sh) boots to the
title menu **without loading anything**, and LOAD GAME's slot list renders each
slot's Details panel (`STAGE`, `Game Status`, `Points`, `Times Cleared`) plus a
row (date, `Difficulty`, flight time, clear ratio). Extra slots can be fabricated
— copy the save directory and its `Headers/…/gameNN.header`, patching the
UTF-16BE display string and the ASCII `gameNN` inside it — so **five probes fit
in one boot**, read-only, nothing loaded.
**Probed, all 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 and 9),
`+40` (u64), `+48`, `+52`, `+56`, `+60`, `+64` (raw) — plus `SHAB[0].a`, plus the
`SHAB` **fill count** in both directions (record 1 filled with a copy of record 0;
record 0 cleared). Sixteen elements.
The panel genuinely re-reads each slot — the control is slot 02, which holds
`Points 4101` and `Clear Ratio 5 %` and displays exactly that while its
neighbours show `101` and `6 %`. A further control: patching a slot's **header**
string to `STAGE09 HARD` changed nothing, and the row's date follows the
**container FILETIME**, so the row is payload-formatted, not header text.
**What is left**, and it is a much narrower set than when this started: the
**phase string** (`GP_BUNK`), the trailer (`"BUNK"` + `0x09150000`), or the blob.
One honest caveat on the negative: **every save on disc is genuinely Stage 02 /
EASY**, so "the field was not found" and "the panel does not vary those two
labels per slot" are not yet separated. The next move is not another probe — it
is to find the code that formats `STAGE %02d` and read which offset it loads,
which the static DB can answer directly.
### The read side, and where the save state lives in memory
The serializer at `0x822C00E8` has a mirror, found by disassembling around it —
and the call graph names the whole read path:
| function | role | pairs with |
|---|---|---|
| `0x822C0380` | **deserializer** — reads `GDAA`, the phase string, then calls the two below | `0x822C00E8` |
| `0x822BF858` | **GHAD reader**, called with `save+8` | `0x822BF678` |
| `0x822BFD60` | **`SHAB` table reader**, called with `save+136`, count 16 | — |
The GHAD reader is a plain byte-for-byte copy — unrolled per field, no clamping
or validation anywhere in it, which is why a hand-written value survives into the
object exactly as written (and why the develop-blob probes worked).
**The save state lives at offset `+304` of a game object.** The deserializer's
only caller sets it up as `lwz r11,4(r30)` → `lwz r11,0(r11)` → `addi r4,r11,304`
(`0x82290594``0x8229059c`). Combined with the layout above, every field has a
fixed absolute offset in that object, which is what a live-RAM read needs:
```
save base = obj + 304 GHAD block = obj + 312
flight time = obj + 316 clear ratio = obj + 320
Points = obj + 336 develop blob = obj + 380
SHAB table = obj + 440 (obj + 304 + 136)
```
**What this did not do, and why:** finding the panel's populate code by searching
for those absolute offsets does not work — the address space is dense enough that
`lwz rX, 336(rY)` has 193 matches, and the windows that contain several of the
right offsets at once are false positives: one is a **vtable thunk table** whose
slots happen to sit at 308340, another is a float-parameter block. Naming the
stage and difficulty fields needs the save-screen's populate path reached from
the screen side (its config record and sprite-key lookup), not from the offsets.
### What the screen itself is built from (static, `.rdata`)
The LOAD/SAVE screen's config **key list is compiled into the executable**, as a
pointer array of key strings — the same shape as the Arsenal's (whose keys sit a
few hundred bytes earlier and match the pak record exactly, which is what
identifies these as config keys rather than loose strings). The save-screen run
starts at **`0x820a0074`**:
```
game, replay, LOADING, GAME, REPLAY, SELECTOR, SAVE_BASES, SAVE_MENUS,
LOAD_BASES, LOAD_MENUS, THUMBNAIL, FONT, EX_FONT, AUTO_SAVED,
AUTO_SAVED_DETAIL, MISSION_SELECT, MISSION_SELECT_DETAIL, CLEARED, EMPTY,
EMPTY_DETAIL, SLOT, VOLUME, STRINGS,
GAME_EASY, GAME_NORMAL, GAME_HARD, GAME_VERY_HARD,
STATE_STAND_BY, STATE_STAGE_CLEAR, STATE_GAME_CLEAR,
LOCATE, …, STAGE01 … STAGE16, CLEAR_TIME_RANKING
```
Three things fall out, none of which needed the emulator:
- **There is a fourth difficulty.** `GAME_VERY_HARD` exists alongside EASY /
NORMAL / HARD, while the stage tables only carry `BonusPoint_EASY/NORMAL/HARD`.
So VERY HARD is 🟡 an unlockable tier the stage bonus table does not price.
- **`Game Status` is a three-valued enum** — `STATE_STAND_BY` (what our save
shows as `At Standby`), `STATE_STAGE_CLEAR`, `STATE_GAME_CLEAR`.
- **The screen can label exactly 16 stages**, `STAGE01`…`STAGE16`. That matches
`weapon.tbl`'s `AUTO_SETTINGS` list — `stage01…16` plus `tutorial01…06` and
`challenge01…06` — so **16 is the story-stage count**. ✅ **The "22 stages"
figure used elsewhere in these notes is now reconciled**: the disc carries 22
stage *containers*, `Stage_S01…S16` **plus `Stage_S24…S29`** — 16 story + 6
extra. Both numbers are right; they count different things, and neither should
be edited to match the other.
The panel's own field names are here too, with their screen positions:
`Points`, `FlightTime` (`%03d:%02d:%02d`), `ClearTimes`, `CompletionRate`,
`RatioOfClear`, `Index`, `Label`, `Date`, `BrokenData`.
**This does not name the stage/difficulty save fields**, and it explains why the
probe sweep could not either: the screen selects a *sprite key by index*, so the
value it indexes with is whatever the loader put in the live object — and the
strings are reached through a config lookup, not an immediate, so there is no
`lis/ori` pair to xref back to the selecting code. Chasing it further means
disassembling the save-screen's populate path, not another grep.
Editing beyond a throwaway slot is still the user's call.

View File

@@ -34,12 +34,27 @@ the elements themselves follow as children:
0x14 u32 entry count
0x20 entry[count], 60 bytes each:
+0 char[28] element name, NUL-padded ("pgp_ttrl_eff10.t32", "pgp_ttrl_btn10.rat")
+28 u32 ×4 flags (0xffffffff / 0xffffffff / 0 / 0xffffffff on every entry seen)
+28 u32 0 on every entry seen
+32 u32 PARENT element index, or 0xffffffff for "none" ← see below
+36 u32 0xffffffff on every entry seen
+40 u32 kind flags 0 for a plain sprite, 0x3002 for a button record,
1 for an element that has a parent
+44 u32 0xffffffff, but 0 on button records
+48 u32 pivot X
+52 u32 pivot Y
+56 u32 0
```
**Corrected 2026-08-11:** this was previously written as "`+28` u32 ×4 flags" — it
is **five** words, and the second of them is not a flag.
**`+32` is a parent element index.** In the in-mission pause bundle,
`pgpeff02a.t32` carries `3` and element 3 is `pgpeff02.t32`; `pgpeff03a.t32`
carries `5` and element 5 is `pgpeff03.t32` — each `…a` variant naming its base
element. Verified on two independent language builds of that screen (4 links, no
out-of-range value anywhere in any bundle), and the tutorial bundle, which has no
`…a` variants, carries `0xffffffff` throughout.
The table lists **both** sprites and `.rat` records — it is the screen's element list.
`pgp_ttrl` declares 11: six `eff*`, `msg`, and four `.rat`s (`title`, `btn10..12`).
@@ -171,12 +186,155 @@ recording them because a static-only reading would have shipped them:
## Next
- **The `eff*` / `deli*` / `msg` placements are still missing** — those sprites have no
`.rat` of their own, and `loop1.rat` turned out to be an animation, not a composition.
So the screen's draw list lives somewhere not yet found (the parent RATC's own header
region, or title code). That is the gap between the rebuild above and a complete screen.
- **Half of this gap is now closed.** The `eff*` / `deli*` / `msg` sprites have no
`.rat` of their own, and `loop1.rat` is an animation, so the question was where the
screen's draw list lives. **It is the bundle's own declaration table** (above): that
table is not the child listing — the children are grouped by type (every `.t32`, then
every `.rat`), while the declaration table names *elements*, in a plausible back-to-front
order, and it is exactly the missing set that appears there. For the in-mission pause
menu it lists 23: `eff11/12/10`, `eff02(+a)`, `eff03(+a)`, `eff01`, `title.rat`,
`btn00/01/04/05.rat`, `msg`, `deli1…4`, `eff30…33`, `loop1.rat`. Note what it does
**not** list: the focused button variants, which are reached through each base record's
`opt ` link — so the table is the screen's element set, not a resource inventory.
- **And the position is there too — the gap is closed.** Immediately after the
declaration table the bundle carries a **placement region, one group per element**:
```
group header: u32 element index (0,1,2… — the correspondence is stated, not inferred)
u32 keyframe count
then `count` keyframes, 40 bytes apart, each holding:
u32 scale X = 100 u32 scale Y = 100
u32 tint = 0xffffffff
i32 X i32 Y ← SIGNED
u32 time u32 flags/terminator
```
**Three things here are easy to get wrong, and were:**
- **X and Y are signed.** Off-screen animation starts are negative — the Arsenal's
weapon-list window begins at **X = 516**. Read as unsigned it is 4 294 966 780,
which a naïve implementation would happily draw four billion pixels to the right.
- **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 for an animated element. The resting position is the
**max-dwell** keyframe — the one with the longest gap to the next time — which
for that window is **(127,155)…(134,155)**, matching where the list panel
actually appears. `screen_layout.rs` reports that, not `final`.
For the tutorial pause bundle this yields 11 groups for 11 elements, and every
group's header index and count match the blocks actually present. The values are
self-evidently right: the three menu buttons sit at X=546, **70 px apart**
(288 / 358 / 428) — static, every keyframe identical, which is why their reading
was unaffected by the first/last mistake above — the title at (540,119), the
message at (451,545), and the `eff*` sprites carry multi-position fly-ins.
**Cross-checked against the records themselves:** `pgp_ttrl_btn10.rat` places its
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.
A screen is therefore fully reconstructible from its bundle alone: **element list
and order** (declaration table) + **placement and animation** (this region) +
sprites, with `opt ` supplying focused states.
- The same method should now unroll the other screens directly; `GP_HANGAR_ARSENAL.pak`
(789 T8aD + 510 RATC) is the big one, and the ARSENAL `DATA SHEET` panel documented in
[weapon-datasheet-runtime.md](../weapon-datasheet-runtime.md) is a ready-made oracle for it.
- Tooling: `crates/sylpheed-formats/examples/ui_screen.rs` (inventory a screen pak, carve a
named RATC child), `sylpheed-cli pak textures` (decode every sprite).
## It generalises: the ARSENAL screen, and how a multi-component screen composes
**2026-08-11.** [`examples/screen_layout.rs`](../../../crates/sylpheed-formats/examples/screen_layout.rs)
dumps a bundle's declaration table and placement region together. It reproduces
the tutorial pause menu exactly, and it reads the ARSENAL the same way
([capture](../captures/arsenal-main-screen-layout.txt)) — 23 elements that match
the running game:
- **Eight buttons** `prbtn1…8.rat` at **X=242**, Y `166, 220, 276, 331, 385, 441,
496, 551` — evenly spaced, and eight is exactly what the screen's own config
declares with `WEAPON_CATEGORIES = 8` (GUN, BEAM, LASER, MPM, ASM, B/R, CANNON,
SPECIAL, the list photographed in the Arsenal).
- **`prexp3.t32` declared seven times** at X=726, 34 px apart (`381 … 585`) — the
DATA SHEET's rows.
- `prexp1.t32` animates `(726,143) → (1286,143)`, sliding off the right edge, with
`prexp1a.t32` **parented to it** (`parent = 13`).
- `prmsg.t32` at (151,645) — the description line along the bottom.
### Two things this adds
- **`kind = 0x4` marks a repeated instance.** `prexp3.t32` appears once with
`kind 0x0` and then six more times with `0x4`, each with its own placement — the
game repeats one row template rather than shipping seven sprites. So the element
*name* is not a key; the declaration index is.
- **A screen is composed of named components.** The pause menu is one bundle, but
`GP_HANGAR_ARSENAL.pak` holds **510** RATC entries because its screens are built
from the `.prt` components its config names (`Menu = prmain_scr.prt`,
`Select_Window = prselect_scr.prt`, `Detail_Window_Known = prselect_win3.prt`, …).
Those resolve **under the config's own `PATH` prefix**: `prmain_scr.prt` is not
present, `eng\prmain_scr.prt` is — the same `<lang>+<member>` convention the
[movie table](../movie-subtitle-link.md) uses. A sub-component reads identically:
`psselect_win1` declares 4 elements, three of them **parented to element 0**,
which is the parent-index field doing real work on an independent pak.
### Validated against the running game, to ±2 px
The layout above is a static parse; this is the check that it predicts what the
game actually draws. Booting to the ARSENAL and detecting the eight teal category
chips by colour gives their row centres, against the eight `prbtn1…8.rat`
placements from `eng\prmain_scr.prt`:
```
placement Y + pivotY(14) + chrome(45) observed centre diff
166 225 224 -1
220 279 278 -1
276 335 334 -1
331 390 388 -2
385 444 444 +0
441 500 498 -2
496 555 554 -1
551 610 608 -2
```
The spacings are the real signature: **54, 56, 55, 54, 56, 55, 55** predicted
against **54, 56, 54, 56, 54, 56, 54** observed — an irregular alternating pattern,
not a round number that could match by luck. The single free parameter is the
**45 px emulator window chrome** (title bar + menu bar), which is not part of the
game; the ±2 px residual is the centroid measurement, since a thresholded chip
centre is not exact.
This confirms three things at once: the **declaration table is the element set**,
the **placement region gives real screen coordinates**, and the **pivot composes
additively** — `pivotY = 14` is half the 28 px chip, so placement is the top-left
and pivot carries the centre offset, exactly as the record layout says.
**Not** confirmed by this: the max-dwell rule for *animated* elements. These eight
buttons are static (every keyframe identical), which is precisely why they make a
clean ruler. Validating the animation rule needs an element captured mid-slide.
Evidence: [`captures/arsenal-layout-validation.png`](../captures/arsenal-layout-validation.png).
## RATC nesting, measured
`INDEX` carried the bundle format as 🟡 "one level deep", which reads like a
parser limitation. It is not — **there is nothing deeper on the disc**:
- **2 859** top-level RATC bundles hold **18 002** children at depth 1 and
**0** at depth 2, with **no** blob failing to parse.
- The children that are themselves RATC (the `.rat` layout records) are **leaf
records**: they carry no child list and instead **reference their siblings by
name** — the sprite they place and, via `opt `, their focused variant.
**3 311** such leaves, every one embedding sibling names, and **10 144 of
10 148** references resolve to a sibling of the same bundle.
That is the same by-name convention used one level up, where a screen's config
names `.prt` components, and one level up again, where the movie table names
`<lang>.pak+<member>`.
**The 4 unresolved references are a real defect on the disc**, not a parse gap:
`pmbase.rat` in `GP_STAGE_CLEAR.pak` (four language builds) places `pmbase.t32`,
and that sprite exists **nowhere** — not as a pak member, not as a child of those
bundles. It is the second such dangling asset found, after
[`SUBTITLE_S12B.tbl`](../movie-subtitle-link.md); a reimplementation should skip
a missing sprite rather than treat it as a decode failure.

View File

@@ -272,3 +272,108 @@ take-off, ~12 minutes in mid-combat, and after GAME OVER. 14 objects, the same
is no need to play it, and no need to survive it.
Stages captured so far: `Ttrl` (BASIC CONTROLS), Stage 02.
## A defaulted unit field is not a global constant — some inherit from a sibling
**Confidence: 🟡 for `Size_Y`, ❔ for the rest. Analysis 2026-08-10, offline, from
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv).**
The coverage limit above (21 of 110 units, growing only with story progress) is
worth attacking from the other side first: *if* a field the disc leaves unset
always took the same runtime value, the 21 captured units would pin that default
for all 110 and no further missions would be needed.
**It does not.** Restricting to the 150 values that are both ✅ CONFIRMED and
come from a field the disc leaves defaulted, only 6 of 24 fields have a single
value across every unit that defaults them (`HP`→10, `MassScore`→0,
`MaximumVelocity`→0, `RadarRange`→0, `DestroyMotionTime`→0, `Size_Z`→0.1). The
other 18 take several distinct values — so the default is computed per unit.
Where from? For each defaulted value, ask which *other* field of the same unit
holds exactly that value. Counting only cases where the value is **non-zero**
(otherwise `0 == 0` inflates every pair) and checking that the two fields are at
**different offsets** (so the match is not the layout solver aliasing them):
| defaulted field | takes the value of | support | independent units |
|---|---|---|---|
| `Size_Y` (`0x034`) | `Size_X` (`0x030`) | 9/9 | **7**, 6 distinct values |
| `Size_Radius` (`0x050`) | `min(Size_X, Size_Z)` | 4/4 | 4, 3 distinct values |
| `FCSRange` (`0x2a4`) | `RadarRange` (`0x2a0`) | 4/4 | 2 |
| `DefencePoint` (`0x2bc`) | `AttackVesselPoint` (`0x2b4`) | 6/6 | 2 |
`Size_Y ← Size_X` is the one to trust: seven unrelated ships (`e105` 600,
`e106` 300, `e108` 80, `e201` 300, `f101` 400, `f105` 700, `f106` 200) each omit
`Size_Y` on disc and each shows its own `Size_X` at runtime. When both fields
*are* on disc they differ freely (14 distinct `Size_Y` values against 13 of
`Size_X`), so this is a default rule, not one value stored twice.
`Size_Radius`'s formula is **not yet separable**: `min(Size_X, Size_Z)` and "the
median of the three axes" fit all four units identically. `UN_e010_ADAN_Attacker_S`
is what rules out the simpler `Size_Radius ← Size_X` (X=100, Y=40, Z=50, radius
**50**). The last two rules rest on two independent units each and are ❔ —
recorded so they can be falsified, not relied on.
**Why it matters for the reimplementation:** filling a missing `Size_Y` with `0`
or with a global constant gives the game's largest hulls a wrong lateral extent
(`f105` 700, `e105` 600, `f101` 400 — all defaulted on disc). Applied across the
disc, the rules recover **65 (unit, field) values in units that have never been
visited**: `Size_Y` in 21 of the 21 units that omit it, `Size_Radius` in 22 of 26,
`FCSRange` in 14 of 56, `DefencePoint` in 8 of 60.
### Cross-check against the weapons: this is NOT an engine-wide mechanism
The obvious worry is that four rules from 21 units are coincidence. The
`Weapon`/`Shell` capture is the control: **complete coverage, 126 records**, with
the same "defaulted on disc" classification. Running the identical sweep there
(confirmed rows, non-zero values, offsets required to differ) finds **no sibling
rule at all** — the single 100 %-agreement candidate (`Shell.Length ←
`Shell.Volume`, 5 records) has one distinct value, i.e. it is really the constant
`Length → 10` coinciding with `Volume = 10`. Weapon defaults vary per record just
as unit defaults do (10 of 14 `Weapon` fields, 16 of 17 `Shell` fields), so the
phenomenon is general; the *sibling* explanation is not.
So `Size_Y ← Size_X` is **specific to the unit schema** (plausibly the size block
defaulting its axes), not a property of IDXD default resolution. Two consequences:
the rule cannot be justified by appeal to a general mechanism, and the two
two-unit hypotheses (`FCSRange`, `DefencePoint`) lose the support they would have
borrowed from one — treat them as **coincidence-not-excluded** until a new stage
tests them.
`Size_Y ← Size_X` itself survives this scrutiny, and was re-checked at the raw
token level rather than through the sub-record merge: `UN_e105_ADAN_Cruiser`,
`UN_f105_TCAF_Cruiser` and `UN_f101_TCAF_Acropolis` each declare `Size_X`,
`Size_Z` and `Size_Radius` and **no `Size_Y` at all**, and each reads back its own
`Size_X` (600 / 700 / 400) at runtime.
**How to falsify:** the rules predict a specific number for units in stages not
yet captured. Load any new stage, snapshot, and compare — one disagreement kills
the rule. Note what is *not* a useful test: Stage 01, the only other reachable
stage, adds just four uncaptured units (`e010`/`e106` variants) whose predictions
are the same numbers their already-captured base variants gave, so it would
re-measure rather than test. A real test needs a stage with unfamiliar classes,
i.e. story progress — which is now the *only* thing story progress is needed for
here.
## Cross-validated against the loader itself (2026-08-13)
This layout was solved by binding disc values to RAM words. It has now been
derived a second time, independently, **from the game's own code**: the loader
`sub_82341A20` builds every key as `addi r4, r30, -N` (`r30 = 0x82088f94`), so the
field *name* for each store is a string in the executable, and pairing each key
with the first store after its accessor call gives the offset without any value
matching. See [`live-unit-definitions.md`](../live-unit-definitions.md).
**The two agree completely where they overlap: 25 shared offsets, 25 agree, 0
disagree.** Each also covers what the other misses — the code-derived table has
**159 fields** (vs 27 confirmed here) and is checked in as
`crates/sylpheed-formats/data/unit_definition_layout.txt` with
`sylpheed_formats::unit_layout` and a no-emulator regression test; this table
still uniquely holds **`ScorePoint` (`+0x08c`)** and **`MassScore` (`+0x094`)**,
which the key-string extraction did not pick up.
⚠️ **One tentative entry here is an artefact and should not be trusted.**
`Slalom_CutoffRatio` at `+0x00c` is recorded with values like `2.8026e-45` and
`1.4013e-45` — those are the **denormal float readings of the integers 2 and 1**.
`+0x00c` is below the first field the loader ever names (`+0x018`), and the same
trap produced a false `YawDragFactor → +0x0c` hit when the code-derived map was
being built. It is an int-typed word, not a ratio.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
# XPR2 colours: channel order ✅ confirmed against the running game
[`INDEX`](INDEX.md) carried XPR2 as 🟡 with **"colours unverified (dynamic
item)"**. The failure mode that matters for a texture decoder is a **swapped red
and blue channel** — it is invisible on greys and on anything you have no ground
truth for, and it silently poisons every asset. This settles that one, and is
explicit about what it does *not* settle.
## Method
Pick an asset whose in-game appearance is unambiguous and **strongly
asymmetric in hue** — the Delta Saber's livery is white/grey panels with
orange-rust accents, so a red↔blue swap would render it cyan. Then compare the
decoded texture against the game rendering the same asset.
- **Decoded:** `hidden/resource3d/DeltaSaber_T.xpr` — 1024×1024, **Dxt1**, 11 mip
levels — exported with `sylpheed-cli texture export`.
- **In-game:** the Delta Saber standing in the ARSENAL scene, captured at
`x 700..830, y 180..480` of the framebuffer so the crop is hull, not UI.
Both images were resampled to 96×96 and every pixel classified; for the
saturated pixels (`maxmin ≥ 50`) the median hue was taken.
| | warm px (`R > B+40`) | cool px (`B > R+40`) | median hue of saturated px |
|---|---|---|---|
| decoded texture | **1073** | **0** | **23.3°** (orange) |
| in-game render | 916 | 124 | **9.3°** (orange-red) |
**Both land in the orange/red family; a red↔blue swap would put the decoded
texture at ≈200° (azure).** The 124 cool pixels in the game crop are the blue UI
panel behind the model, and the 14° offset is the hangar's red key lighting
tinting the lit model — both expected, neither able to move a hue by 180°.
Evidence: [`captures/xpr2-colour-check-deltasaber.png`](captures/xpr2-colour-check-deltasaber.png)
— decoded atlas beside the in-game hull.
## What this establishes, and what it does not
**Channel order is right** for the XPR2 path, on a DXT1 texture with mips.
Separately, `BG_Earth.xpr` decodes as a 1024×1024 **A8R8G8B8** cubemap face and
produces a coherent nebula rather than noise, so the de-tile step is sound on
that path too — though a nebula has no ground truth, so it is corroboration, not
proof.
🟡 **Exact colour fidelity is still open.** A hue-family comparison cannot see a
gamma/sRGB curve error, a premultiplied-alpha mistake, or a small per-channel
scale — all of which preserve hue. Settling those needs a flat, unlit,
known-value surface rather than a lit hero model.
❔ The `~15 %` of T8aD variants noted elsewhere and the XPR2 **cubemap face
ordering** are untouched by this.

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Fly TO a capital ship and dump a draw capture at several ranges.
Why this exists: the 2026-07-31 Stage-02 capture correlated **zero** parts, and
inverting the match (`cargo run --example invert_capture`) showed why — at the
captured frames no capital-ship hull was drawn at all. The only large draw was
the player's own craft (`DeltaSaber_T:f001`, 10891 verts); of `f101`/`e105`/
`e106` only a handful of tiny far-LOD/effect pieces appeared. The ships were
simply too far away. Pressing F10 wherever the craft happens to be is therefore
not a capture strategy.
So: pick a capital ship, fly at it, and press F10 as each distance band is
crossed. That gives (a) frames where the full-detail hull is actually drawn —
what the correlator needs — and (b) as a by-product, the game's own **LOD
ladder**, because each capture is stamped with the range it was taken at.
Firing is disabled (the target is usually a friendly), and navigator.py's
closest-point-of-approach avoidance is inherited unchanged, so closing on a hull
does not end in a collision.
Usage: approach_capture.py <config.json> [seconds] [--target REGEX] [--dry]
Env: SYLPH_CAPTURE_WIN xdotool window id to send F10 to (unset = no capture)
SYLPH_CAPTURE_OUT where to write the band log and screenshots
"""
import json
import math
import os
import re
import subprocess
import sys
import time
from collections import Counter
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigator # noqa: E402
from navigator import Navigator, ang, norm # noqa: E402
from flight_probe import Pad # noqa: E402
# Ranges (guest units) at which to dump a capture, largest first. Chosen to
# straddle the plausible LOD switches: the far-LOD pieces seen in the 2026-07-31
# capture were drawn at whatever range the craft sat at, and the one validated
# capture (e106, Stage_S01) had the ship close.
BANDS = [8000.0, 6000.0, 4500.0, 3000.0, 2000.0, 1400.0, 900.0]
# A capital ship, not a fighter: the definition's own size radius says which.
CAPITAL_RADIUS = 150.0
class Approach(Navigator):
# Never shoot: the approach target is usually the escorted asset, and a
# negative cone makes the inherited fire gate unsatisfiable.
FIRE_CONE = -1.0
HOLD = 700.0 # stop closing inside this; the capture is already made
def __init__(self, W, pad, target_re=None, dry=False, log=sys.stdout,
win=None, out=None):
super().__init__(W, pad, dry=dry, log=log)
self.target_re = re.compile(target_re, re.I) if target_re else None
self.win = win
self.out = out or "/sylph-home/re/shipcap"
self.locked = None # (off, name) — stay on one ship
self.pending = list(BANDS)
self.captures = []
self.throttle = None
# -------------------------------------------------------------- target
def pick(self, me_p, me_v, fwd, ents, me_off):
"""The chosen capital ship — locked once, so the run is one approach."""
cands = [e for e in ents
if e[0] != me_off and "Player" not in e[1] and e[4] >= CAPITAL_RADIUS
and (self.target_re is None or self.target_re.search(e[1]))]
if not cands:
return None
if self.locked is not None:
same = [e for e in cands if e[0] == self.locked]
if same:
e = same[0]
return (e[0], e[1], e[2], e[2] - me_p, float(np.linalg.norm(e[2] - me_p)))
# First lock: the biggest ship that is not absurdly far.
cands.sort(key=lambda e: (-e[4], float(np.linalg.norm(e[2] - me_p))))
e = cands[0]
self.locked = e[0]
print(f"LOCK {e[1]} radius={e[4]:.0f} d={np.linalg.norm(e[2]-me_p):.0f}",
file=self.log, flush=True)
return (e[0], e[1], e[2], e[2] - me_p, float(np.linalg.norm(e[2] - me_p)))
# ------------------------------------------------------------- capture
def capture(self, band, dist, name):
idx = len(self.captures) + 1
shot = f"{self.out}/approach-{idx:02d}.png"
if self.win:
subprocess.run(["screenshot", shot], capture_output=True)
subprocess.run(["xdotool", "key", "--window", self.win, "F10"],
capture_output=True)
rec = {"index": idx, "band": band, "distance": round(dist, 1),
"target": name, "shot": shot, "t": round(time.time(), 3)}
self.captures.append(rec)
print(f"CAPTURE {idx:02d} band={band:.0f} d={dist:.0f} {name}",
file=self.log, flush=True)
with open(f"{self.out}/approach-bands.jsonl", "a") as f:
f.write(json.dumps(rec) + "\n")
# ---------------------------------------------------------------- loop
def step(self, t, dt, prev_vhat):
msg, vhat = super().step(t, dt, prev_vhat)
# Distance to the locked ship drives both the throttle and the captures.
ents = self.W.sample(t)
me = next((e for e in ents if "Player" in e[1]), None)
tgt = next((e for e in ents if e[0] == self.locked), None) if self.locked else None
if me is None or tgt is None:
return msg, vhat
d = float(np.linalg.norm(tgt[2] - me[2]))
# Throttle: RT to close, LT to hold off once we are as near as we want.
want = 1 if d > self.HOLD * 2 else (-1 if d < self.HOLD else 0)
if want != self.throttle and not self.dry:
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
self.throttle = want
while self.pending and d <= self.pending[0]:
band = self.pending.pop(0)
self.capture(band, d, tgt[1])
return f"{msg} | d={d:7.0f} thr={want:+d} left={len(self.pending)}", vhat
def main():
cfg = json.load(open(sys.argv[1]))
secs = float(sys.argv[2]) if len(sys.argv) > 2 and not sys.argv[2].startswith("-") else 240.0
target = None
if "--target" in sys.argv:
target = sys.argv[sys.argv.index("--target") + 1]
W = navigator.World(cfg)
a = Approach(W, Pad(), target_re=target, dry="--dry" in sys.argv,
win=os.environ.get("SYLPH_CAPTURE_WIN"),
out=os.environ.get("SYLPH_CAPTURE_OUT"))
a.run(secs)
print(f"CAPTURES {json.dumps(a.captures)}", flush=True)
if __name__ == "__main__":
main()

46
tools/re-capture/boot_menu.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Boot to the TITLE MENU and stop there — no save is loaded.
#
# nav_probe.sh's boot half deliberately loads a save, which is wrong for any
# experiment that wants to *inspect* save slots rather than enter one: with
# several probe saves on disc the load picks one of them, and a probe value the
# game cannot make sense of turns into a long "NOW PROCESSING" (or a mission
# load, if a d-pad step is dropped and A lands on TAKE OFF).
#
# From the main menu, LOAD GAME is one d-pad step away and its slot list renders
# every slot's Details panel — STAGE, Game Status, Points, Times Cleared — which
# is read straight out of each save's payload. That makes this the cheap harness
# for save-field probes: write N slots, boot once, arrow through them.
#
# Run as ONE BLOCKING FOREGROUND call. Usage: boot_menu.sh [tag]
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
SD="$(cd "$(dirname "$0")" && pwd)"
SHOTS=/sylph-home/re/shots
TAG="${1:-menu}"
mkdir -p "$SHOTS"
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
ensure_display(){
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
nohup bash -c 'Xvfb "$0" -screen 0 1280x720x24 -ac -nolisten tcp \
+extension GLX +extension RANDR >/tmp/xvfb98.log 2>&1' "$DISPLAY" </dev/null >/dev/null 2>&1 &
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
sleep 1
fi
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { echo "DISPLAY UNAVAILABLE"; exit 1; }
}
[ -n "$(alive)" ] && { kill -9 $(alive) 2>/dev/null; sleep 2; }
rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null
ensure_display
cd /sylph-home/re
nohup run-canary --audio --apu=sdl --log_mask=13 \
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
sleep 5
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; }
sleep 14
screenshot "$SHOTS/$TAG-menu.png" >/dev/null 2>&1
echo "AT MAIN MENU (cursor on NEW GAME); LOAD GAME is one d-pad step down"

View File

@@ -19,7 +19,9 @@ alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $
# that actually bought was the opposite: a process nothing owns is a process
# nothing keeps alive, and both were being reaped a couple of minutes in — the
# long-standing "Xvfb and the emulator die on their own every few minutes" note.
# Run this whole script as ONE tracked background task and leave Xvfb, openbox
# MEASURED WRONG 2026-08-10: a harness-tracked BACKGROUND task does not protect
# them either — the display was lost 11 s in, at the turn boundary. Run this
# whole script as ONE BLOCKING FOREGROUND call and leave Xvfb, openbox
# and xenia as its children: they then live exactly as long as the session does.
# `nohup` still shields them from a stray HUP; the exit-status wrapper means a
# death is reported with the server's own account instead of being inferred.

Some files were not shown because too many files have changed in this diff Show More