184 Commits

Author SHA1 Message Date
eac92c3e44 re(flight): withdraw the roll result — the probe was measuring pitch as much as roll
Re-running roll with 5 s settles and in-run clock brackets:

  this run's clock: TIME 00:34.93 -> 00:45.97 = 11.04 s game in 7.98 s wall = 1.383
  min speed: 90.6 deg/wall-s /1.383 -> 65.5 deg/game-s  (AV_Roll_Min 200)
  max speed: 59.1            /1.383 -> 42.7             (AV_Roll_Max 125)

The corrected numbers are within a few per cent of the PITCH run's 67.8 and 40.9 —
two different stick axes cannot produce the same rates, so the probe is not
separating them. Cause: watching a non-forward matrix row sees any rotation that
moves that row, and pitch moves it as much as roll. The correct measure is rotation
ABOUT the forward axis (project the row onto the plane perpendicular to forward and
track that angle).

So "roll shows no speed dependence, unlike pitch" is withdrawn: it rested on 2 s
settles and a row that mixes axes, and the two runs disagree with each other
(144/150 then, 90.6/59.1 now). AV_Roll_{Min,Max} are not confirmed and the axis
question is open.

The clock ratio is now measured three times in three flights: 1.260, 1.311, 1.383 —
a property of the moment, not the machine, so every rate probe must bracket its own
phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 18:12:20 +00:00
8b10c0451f re(flight): the clock factor is universal — angular matches too once corrected in-run
pitch_gametime.py brackets each turn phase with HUD screenshots, so the mission
clock's own advance converts wall seconds to game seconds within the same run:

  this run's clock: TIME 00:33.68 -> 00:44.12 = 10.44 s game in 7.96 s wall = 1.311

  pitch @ min speed  88.9 deg/wall-s  /1.311 -> 67.8 deg/game-s  vs AV_PitchMinus_Min 75
  pitch @ max speed  53.6            /1.311 -> 40.9             vs AV_PitchMinus_Max 40

Both land on the definition (the slow phase 10% low, consistent with including the
AA_* ramp in an 8 s window), so the clock explanation covers angular motion as well:
every stated rate is per GAME second.

The ratio is not a machine constant — 1.260 in the earlier flight, 1.311 here — so it
must be measured in the same run as whatever it corrects. Bonus: the same shots show
the HUD reading 102 at full LT against MinimumVelocity 100.

Also documents the trap that cost three runs: a killed Canary leaves both its shm
image and its last frame on screen, so a dead emulator looks alive and the scans
report "0 moving triples" like a tooling bug. pgrep -x matches zombies, so
speed_law.require_live_emulator() checks the process state letter and refuses to
measure a corpse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:56:16 +00:00
ae5f322c03 re(flight): the linear factor is the CLOCK — 1.260 measured against 1.267
Cleanest linear measurement: neutral throttle (HUD = CruisingVelocity 350), sticks
centred, 20 s of perfectly straight flight (displacement/path = 1.000):

  8 900 world units in 20.1 s -> 443.6 /s -> 1.267x the HUD's 350

And the game's own mission timer across a wall-clock interval:

  TIME 00:08.79 -> 00:46.97 = 38.18 s of game time in 30.29 s wall = 1.260

Same number. So the linear discrepancy is not a unit difference: the mission clock
runs ~1.26x faster than wall time under this emulator, and dividing world
displacement by WALL seconds inflates speed by exactly that. World units and
displayed speed share one unit; the definition velocities are per GAME second.

This supersedes the previous "world-unit vs displayed-speed" reading.

Left open (): settled turn rates measured 74.9/41.1 deg/s in wall time against
AV_PitchMinus_Min/Max 75/40, but the clock argument predicts ~94 for the first.
Either that agreement was luck inside a noisy sample (per-window rates spanned
61-96) or angular integration is frame-based where linear is time-based. The check
is to re-measure pitch and convert wall->game seconds with the clock ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:26:27 +00:00
0cb81b6200 re(flight): the linear discrepancy is a world-unit vs displayed-speed difference
Screenshotting the HUD speed readout at each throttle step, beside the
position-derived measurement of the same moment:

  RT 0.00   HUD 350 (= CruisingVelocity)   position ~447   ratio 1.28
  RT 0.25   HUD 507                        position ~652   ratio 1.29
  RT 0.75   HUD 963                        position ~1141  ratio 1.19

So (a) the HUD speaks the definition's units — exactly CruisingVelocity at neutral,
963 at three-quarters against the 987 the interpolation predicts — confirming the
throttle law in the game's own numbers without any position sampling; and (b) world
displacement runs ~1.2x the displayed speed. Since settled angular rates need no such
factor, this is a unit difference between the position triple and the velocity
fields, not a clock effect: a reimplementation moving entities at MaximumVelocity in
world coordinates will be ~20% slow.

Also fixes speed_law.find_player: a mission holds more than one *_Player object and
at least one never moves, so the finder now samples each candidate twice and keeps
the one that displaces. Locking onto the static one is what produced a run of exact
zeros while the game was visibly flying.

🟡 The ratio is 1.19-1.29 rather than a clean constant and every sample was taken in
a firefight; pinning it wants a quiet map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 17:06:35 +00:00
f6974ff3f0 re(flight): settled turn rates match the definition exactly — withdraw the time-base claim
Re-measured with 5 s of settle per phase and the speed recorded at the moment the
turn starts (flight_law3.py):

  pitch @ 130/s    74.9 deg/s   vs AV_PitchMinus_Min 75
  pitch @ 1821/s   41.1         vs AV_PitchMinus_Max 40
  roll  @ 110/s   129.5         vs AV_Roll_Min 200
  roll  @ 1722/s  149.3         vs AV_Roll_Max 125

Pitch lands on the definition's own numbers with NO scale factor, so the ~1.2x I
attributed to the emulated time base two iterations ago was an artefact of
differentiating during the AA_* acceleration ramp with too little settle. That
explanation is withdrawn: AV_* can be used verbatim.

What remains is only on the linear side — settled speeds still read high and vary
between runs (RT full: 1342 in one flight, 1821 in another, vs MaximumVelocity 1200),
consistent with a craft being shoved around in a firefight. The HUD reads exactly
CruisingVelocity at neutral. A clean linear measurement needs a quiet map; no cause
is claimed until then.

Roll re-measured with proper settles confirms the axis difference: no speed
dependence, both regimes near AV_Roll_Max, where pitch moved 75 -> 40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 16:46:33 +00:00
6ebbbeff65 re(flight): LT mirrors RT, and roll does not depend on speed
One flight, two measurements (flight_law2.py, binding early so the moving-craft scan
can see the player).

LT curve: 436, 379, 289, 209, 126 units/s across LT 0.00 -> 1.00 — a straight ramp,
whose endpoints after the ~1.2 time-base factor are CruisingVelocity 350 and
MinimumVelocity 100. So the law is symmetric:

  RT: target = Cruising + RT * (Maximum - Cruising)
  LT: target = Cruising - LT * (Cruising - Minimum)

Roll (measured on a non-forward matrix row, since roll turns about the forward axis):
~144 deg/s at minimum speed and ~150 at maximum — no speed dependence, where pitch
dropped by a third to a half between the same regimes. After the time-base factor
that is ~121, i.e. AV_Roll_Max 125 in BOTH regimes.

So _Min/_Max does not mean the same thing for every axis: pitch interpolates with
speed, roll appears pinned at Max. A reimplementation applying one rule to all axes
would get low-speed roll wrong by ~60%.

Caveat recorded: the two roll phases were 2 s of settling apart, marginal for a
126 -> 1342 speed change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 16:30:19 +00:00
4dbd4f6cef re(flight): the throttle is analogue — target speed interpolates cruise -> maximum
RT is an analogue trigger, so "held" was one point on a curve. Walking it 0.00 ->
1.00 (throttle_curve.py) gives a straight ramp: 438, 626, 879, 1094, 1342 units/s.
Dividing by the ~1.2 time-base factor, the endpoints land on the definition's own
numbers (365 vs CruisingVelocity 350; 1118 vs MaximumVelocity 1200) and the midpoint
follows, so

  target speed = CruisingVelocity + RT * (MaximumVelocity - CruisingVelocity)

which refines the earlier "selects one of three targets" reading: those three are the
curve's endpoints.

It also refutes the standing afterburner hypothesis that full RT is the burner: the
curve is smooth through full deflection with no step, and the shield does not move.

The LT half is not measured yet — the entity scan needs the craft moving when it
runs, so a mission left idling drops out of it. Bind early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 16:14:47 +00:00
fb1405b13f re(flight): the afterburner is not on A/B/X/LB/LS/RS — bounded negative, with a cheap HUD oracle
The definition describes the burner (AB_ConsumeShield_Begin 50, AB_ConsumeShield 10,
AB_AV_* turn caps well below normal) but names no input, and carries no AB velocity
field.

Three probes, all negative for A, B, X, LB (plus LS/RS on the first):
- ab_probe.py: hold RT for a max-speed baseline, then each candidate — speed stayed
  inside the baseline's own noise band every time.
- ab_state_probe.py: sample a window of the player object during each hold and
  report any float that falls — nothing fell.
- HUD oracle needing no offsets: count green pixels of the SHIELD bar on a freshly
  spawned craft. AB_ConsumeShield_Begin 50 should take a visible bite; the bar read
  156/156/156/157/157 across baseline and all four buttons.

So the burner needs a chord, an input this pad cannot reach, or belongs to another
craft/the AI. Recorded so the obvious buttons are not re-probed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 15:51:32 +00:00
86fcfcc8b0 re(flight): turn rates confirm AV_* are rate caps and _Min/_Max mean at min/max speed
turn_law.py pins the speed regime with a throttle, holds a stick axis and
differentiates the craft's own forward vector over 1-second windows.

  slow + nose down  ~87 deg/s   (AV_PitchMinus_Min 75)
  fast + nose down  ~54         (AV_PitchMinus_Max 40)
  slow + nose up   ~175         (AV_PitchPlus_Min 150)
  fast + nose up   ~136         (AV_PitchPlus_Max 70)

So agility falls with speed (_Min/_Max are at minimum/maximum speed, not rate
bounds) and pitching up is ~2x pitching down, exactly as the field pairs say.

Control mapping measured: LX is roll (forward vector barely moves, 3-5 deg/s), LY is
pitch (+1 = nose down per vgamepad's LY: -1 = up), and the right stick does not steer
at all.

The ~1.2x overshoot seen in the speed law appears again here (1.16-1.35x), and a
unit scale cannot explain both m/s and deg/s — a TIME BASE can: if the guest's
simulated second is shorter than the wall-clock second the probe measures against,
every rate reads high by the same factor. So the definition numbers are
self-consistent and these measurements confirm the shape of the law, not a scale.

Recorded 🟡: no yaw input found (AV_Yaw_* exists but neither stick yaws), which with
roll on LX and MaximumBank_Normal points at a bank-to-turn model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 15:30:00 +00:00
1efc3f567d re(flight): the throttle is a target-speed selector, measured against the definition
speed_law.py locks onto the player entity once and samples its position while
holding each throttle input, differentiating over 1-second windows.

  no throttle  -> ~420   (CruisingVelocity 350)
  RT held      -> ~1 530 (MaximumVelocity 1200)
  LT held      -> ~125   (MinimumVelocity 100)
  release      -> back to cruise, from either direction

So the throttle SELECTS a target speed rather than adding thrust — which is what a
reimplementation would most likely have assumed from Acceleration/Deceleration
alone. Those govern the convergence rate instead: ~440 units/s^2 measured on
release (Deceleration 500) and ~470-560 under RT (Acceleration 600).

Recorded as 🟡: measured world speeds run ~1.2-1.3x the definition numbers in all
three regimes while the HUD shows the definition value exactly (350 at cruise), so
world coordinates are a constant multiple (~1.25) of the definition's velocity unit;
the spread is wider than the constant is precise because the craft manoeuvres while
sampled.

Three traps documented: RT/LT are analogue triggers (the button verb is a silent
no-op and the first run measured an unflown craft), per-sample differentiation
aliases against the guest's update rate (0, 1519, 1985, 0, 2681 for smooth flight),
and the player entity only enters the typed scan ~15 s in while the craft dies within
minutes if nobody flies it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 15:08:11 +00:00
6d0bdf0179 re(units): S08 closes the story stages (68 units), and GAME_CLEAR unlocks nothing
S08 gave the last two story-stage units — UN_be005_ADAN_SpaceFortress and
UN_mn500_ADAN_FloatingMine — plus six S08 asteroid collision meshes: 68 units,
9 393 rows, 7 115 defaulted-on-disc values. Every unit any story mission fields is
now read (689 more disc cross-checks, 0 disagreements).

Probe recorded as a negative: setting Game Status (GHAD +48 and its header mirror
+0x18) to 2 = STATE_GAME_CLEAR leaves both menus unchanged — the title still shows
NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS and EXTRAS still shows only
MISSION SELECT / MOVIE THEATER / BACK. So the EX/challenge rosters are not gated on
that field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 14:39:05 +00:00
0221217968 re(units): six targeted harvests — 60 of 110 units, 6 071 defaulted values
S03, S06, S09, S13, S15, S16 (plus the earlier run): 21 -> 60 units,
963 -> 6 071 defaulted-on-disc values, 8 241 rows. Disc cross-checks agree with
0 disagreements throughout.

- UN_e901_ADAN_Boss and UN_e910_core_ADAN_GeneratorCore are harvested (HP 10 000,
  Size_X 500, Size_Radius 250, MaximumVelocity 450) WITHOUT the boss ever being on
  screen: definitions are instantiated at stage load, so a unit only needs to be in
  the roster. That is far weaker than what the draw-capture work requires.
- The extra stages are not reachable via the save's stage field: stage 27 boots to
  the title and then the emulator exits during the load, as do the other S24..S29
  entries, while story stages 1-16 all load. The field addresses the story campaign
  only; EX/challenge rosters need the Challenge-mode menu path.

Remaining 50 units are almost all EX variants in S24/S25/S27/S28/S29; the only
story-stage stragglers are UN_mn500_ADAN_FloatingMine and
UN_be005_ADAN_SpaceFortress in S08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 14:14:18 +00:00
bf6825e278 re(units): target missions by roster, harvest S09 — 36 units / 3 439 defaulted values
examples/roster_target.rs ranks stages by how many roster units are still
unharvested. The EnumUnit_S<NN> tables are found by hashing candidate TOC paths
(hash::TOC_NAME_SCHEMES) — UnitRoster::stage can only infer a tag when the roster
carries a UN_S<NN>_ prop, which most do not.

It picked S09 (10 missing). Flying it: 26 -> 36 units, 3 345 -> 4 785 rows,
2 351 -> 3 439 defaulted-on-disc values. New: e102_Battleship, e104_Carrier,
e107_AAFrigate, e011_Attacker_B, e008_TurretPlus, be001_TerrafoamingUnit,
e001_Elan_GR{,_Violeta}, f102_LightCarrier_Inv, f106_Destroyer_Inv.

Also settled: the definition objects are mission-independent. Eleven units appear in
more than one snapshot and four are not byte-identical, but compared through the
layout ZERO mapped fields differ — the 12 differing slots are all unmapped (offsets
4/8/16/20 and 0x250/0x268/0x300-0x308/0x330-0x338: object header and sub-object
pointers). So a harvested value is the definition, not a per-mission tweak, and the
earlier UN_f201_TCAF_Tanker flag resolves the same way. Cross-checks over three
snapshots: 1 052 agree, 0 disagree.

Third angle field found the same way (Through_AngleMaximum = 60 degrees in radians),
so the degrees<->radians rule covers any name containing "Angle".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 13:48:12 +00:00
14d5ed54d1 re(units): read definitions with the code-derived layout — defaulted values 1 059 -> 2 351
unit_runtime.py can only place fields the disc values (it scores triples against
disc records): 58 of 153 from one snapshot. data/unit_definition_layout.txt came
from the title's loader instead, so it places all 159 — including the fields no
disc record sets, which is the Route-B target.

tools/re-capture/unit_dump_layout.py reads every field of every live definition
object with that layout and keeps the discipline: a field the disc DOES value is a
check, not a new value. Over two snapshots (19 objects): 700 cross-checks agree,
0 disagree.

  fields placed per object            58 of 153 -> 159
  rows over those 19 units                  609 -> 2 736
  defaulted-on-disc values (whole file)   1 059 -> 2 351

Two traps recorded: the layout table's offsets are DECIMAL while the solver CSV
prints hex (parsing as hex fails the cross-check on everything — which is how it
announced itself), and angle fields can carry a prefix (AB_AA_PitchPlus is still an
angle, so the AV_/AA_ test must match anywhere in the name).

Flagged: UN_f201_TCAF_Tanker's object is not byte-identical between the two
missions — per-mission override or a runtime-mutated field; needs a third snapshot
to separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 13:30:49 +00:00
d61baddaf0 re(units): stage select lifts the per-stage coverage limit — 21 -> 26 units
Unit definitions are instantiated per stage, so the parked "coverage grows by
visiting missions" limit was blocked on progress. With the save's stage field
solved, any story stage can be flown from a hand-edited save and snapshotted.

First harvest (a Night Ravens mission, 027 objectives, different roster):
  units with runtime values   21 -> 26
  csv rows                 1 827 -> 2 143
  defaulted-on-disc values   963 -> 1 059

New: UN_e004_ADAN_ElanPlus_N, UN_e006_ADAN_Vindicator_Margras,
UN_f002_TCAF_DeltaSaber_W, UN_f002_TCAF_DeltaSaber_W_Player (a second player craft)
and UN_mn040_Asteroid_Big (47 defaulted values, Size_Z = 2000).

Two traps recorded with the fix: launch_mission.sh presses A on the PRESELECTED
slot, which is the last-used one (03 here), so a probe in slot 01 is ignored — the
first attempt flew stage 02 again and the solver correctly found 0 new units; patch
every slot or confirm the highlighted slot by screenshot. And back up before
patching: the mid-run backup already held the probe, so slot 01 was restored from
the earlier pristine copy (md5 142b4f43…, verified byte-identical).

Token file for the solver: idxd_tokens <GP_MAIN_GAME_E.pak> Generic — 110 UN_*
records, the disc's full unit count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 13:16:04 +00:00
d1b50dc1e6 re(xbg7): characterise the residual 47 misses — 99.25% of resources decode
examples/why_missed.rs reports the furthest gate per undecoded resource. Disc-wide
that is 47 rows / 43 distinct names, and they are mostly not ship geometry:

  30  e_rou_/_rou_ pose & proxy composites (24-vertex marker boxes, extent 0.010)
   6  .DAT particle composites in ptc_pack
   8  damage/LOD variants (e101_bdy_02_d, e901_wing_05_*_m, ...)
   3  props/other (g005 extent 0.196, _rou_f001_wep_05, e_rou_e005)

By gate: extent 32, coverage 8, winding 7. The extent bucket is almost entirely the
pose-proxy boxes, and lowering that floor was measured and refuted earlier. The one
genuinely interesting residual is e901_wing_05_L/R at winding 0.587/0.570 against the
0.70 floor — the signature of a thin double-sided sheet, unproven without a capture
with the boss on screen (flying stage 16 puts the container in memory but the unit
never appeared).

So: 6 247 / 6 294 resources decode (99.25 %), and every real mesh drawn in three
captured missions decodes at the GPU's own offsets with index runs matching byte for
byte (93/93 and 128/128).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 12:50:19 +00:00
3b35c48fce fix(xbg7): grouped selection prefers the candidate explaining the whole pool; per-sub-mesh decls on by default
With per-sub-mesh declarations enabled, n201_01 decoded as a 2-part fragment 4 bytes
off. Both starts validate for the pivot — 0x32BA718 at pad 2 (earlier in file order,
so first-match took it) and the capture-proven 0x32BA71C at pad 0 — so the pivot
alone cannot separate them; at the early one two of four sub-meshes fall out as
out-of-range.

anchor_grouped_meshes now builds each accepted candidate and keeps the one that
explains the most of the declared pool: it returns immediately when a candidate
explains all n sub-meshes, else keeps the best partial, so it can never decode less
than first-match did. n201_01 lands on all four capture-proven offsets
(0x32BA71C / 0x32BEFF4 / 0x32C416C / 0x32C536C) and its two sibling copies take their
own pools, so the twin collapse is gone.

XBG7_SUBMESH_DECLS is therefore on by default (=0 reverts):

  resources that never decode        85 -> 47
  resources decoding in no container 63 -> 30
  degenerate index runs               1 -> 1   (unchanged)
  cross-container minority decodes   96 -> 96  (unchanged)
  captured index runs, stage-02      93/93     (unchanged)
  captured index runs, stage-05     124/128 -> 128/128

The last line is the point: the buffers the capture could not name are the n201
family, and they now decode and match the GPU's indices byte for byte. Suite green
including twin_pairs_do_not_share_a_buffer, apart from the pre-existing
known-failing cross-container consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 12:27:56 +00:00
4cdbbc2d48 feat(xbg7): read the per-sub-mesh vertex declarations (XBG7_SUBMESH_DECLS, off by default)
desc_dump shows each index marker is followed by its OWN element triples: n201_01
declares strides 24, 24, 24 and 28 (the last sub-mesh has a fourth element), which
matches the runtime capture's stride=28 on that draw and the four distinct vertex
shaders. parse_vertex_decl read the first declaration for the whole pool.

all_vertex_decls reads one per marker; anchor_grouped_meshes uses each sub-mesh's
own stride for the pool walk, the pivot validation and the read. debug_grouped_report
follows the same setting so the diagnostic cannot blame the wrong gate — at n201_01's
capture-proven pool start it now reports "pad 0: ACCEPTED" instead of a NaN position.

With XBG7_SUBMESH_DECLS=1: resources that never decode 85 -> 47, resources decoding
in no container 63 -> 30, capture oracles unchanged (93/93 index runs, 42/42 index
counts), consistency unchanged at 96.

Off by default because selection has not caught up: the three n201_0x copies then
settle on one pool (twin_pairs_do_not_share_a_buffer fails), production still picks
a start 4 bytes before sub-mesh #1 rather than the proven one even though the proven
start validates and is unclaimed, and four newly decoded ptc_pack .dat composites
carry degenerate triangles. Format truth is settled; choosing among candidates is
the remaining work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 11:45:20 +00:00
27577538cb re(xbg7): a capture names a never-decoding family, and the cause is mixed strides in one pool
capture_ib_truth now proposes an identity for each drawn buffer our decoder cannot
place, by matching (vertex, index) counts against declared-but-not-decoded
resources. That identified n201_01/_02/_03 in Stage_S02.xpr — three of the 63
resources that decode in no container — and the capture pins all four sub-meshes:

  #0 vb 0x32BA71C ib 0x32B39FC 4464 idx 777 v stride 24
  #1 vb 0x32BEFF4 ib 0x32B5CDC 4464 idx 869 v stride 24
  #2 vb 0x32C416C ib 0x32B7FBC  576 idx 192 v stride 24
  #3 vb 0x32C536C ib 0x32B843C 4464 idx 869 v stride 28   <- different

The layout matches our assumptions exactly (tight index packing, last buffer flush
against vb0 so pad 0, span 27936 == align4-summed markers, contiguous vertex
buffers, max index == verts-1 everywhere). The defect is that sub-mesh #3 has a
different stride AND its own vertex shader: anchor_grouped_meshes parses one
declaration per resource and applies its stride to every sub-mesh, so it reads #3
out of phase (372 non-finite position components of 2607), and since the pivot is
the largest index count with ties going to the last marker, the pivot IS that
sub-mesh — so the whole resource is declined.

Also adds examples/miss_targets.rs (which container to aim a capture at): 63
resources decode nowhere, 58 of them in exactly one container, clustering as
Stage_S16 21 (e901_wing_05_*), ptc_pack 12, Base 6, then per-stage n2xx groups.
Flying stage 16 did not draw the e901 wings — the container is resident but the
unit must also be on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 10:52:35 +00:00
92d36ec10f re(xbg7): a Stage-05 capture validates the pad fix out of sample, and shows how containers load
Ran ship_capture_close.sh in story stage 05 (reachable now via the save's stage
field) instead of stage 02: 3 logs, ~11 200 draws.

- Out-of-sample check of the pad-scoring fix: 124 of 124 index runs identical to
  the GPU's, 2 769 index elements, 0 differing — on draws the fix was not derived
  from (the stage-02 capture gave 93/93).
- A mission keeps SEVERAL stage containers resident: 65 drawn buffers place in
  Stage_S02.xpr at 0x17FE3FF4 (the shared TCAF/ADAN fleet) and 5 in Stage_S05.xpr
  at its own constant 0x1927B7F4 (the f101 ACROPOLIS, this mission's escort) —
  the first capture-truth rows for a container other than Stage_S02. Both sets
  have exact index counts and exact coverage.
- Why: f105_bdy_01 lives in 13 of the 22 stage containers and not in Stage_S05 at
  all, so the resident set follows the mission's unit roster, not the stage number.

Consequence for the residual misses: a different story stage is not a different
container. Aiming a capture at a specific container is a static question (resource
names per container x the stage's EnumUnit roster).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 10:20:00 +00:00
9fd5dcd37d re(savegame): GHAD +52 is the stage number — a hand-edited save plays Stage 05
Static analysis of the header builder (0x822870b4..0x82287128) shows the GDHA
container header carries a SUMMARY of the progress block, and the screens read
that: hdr+0x14 <- GHAD+52, hdr+0x18 <- GHAD+48, hdr+0x1c <- +24 (Points),
hdr+0x20 <- +4 (flight), hdr+0x24 <- the computed clear ratio, hdr+0x28 <- +12.
savegame_edit.py copies the donor header verbatim, so payload-only probes left a
stale summary — which is why +52 looked refuted.

Patching both (hdr[0x14] = 5, ghad[+52] = 5) makes the title read
"STAGE 05 - Star System Escape", load it, brief the Gallia Asteroid Group, and fly
an asteroid-field mission with a different objective and roster. So +52 is the
stage number, 1-based, and one u32 chooses the mission.

Also corrected in the GHAD table: +48 is the Game Status enum (0 = At Standby,
matching the screen's STATE_STAND_BY/STAGE_CLEAR/GAME_CLEAR list) and +12 is Times
Cleared, both via their header mirrors.

This unlocks runtime capture in containers other than Stage_S02 (the 85 XBG7
misses, box identity, ship-placement generalisation) and per-stage unit
definitions. Slot 01 was backed up and restored byte-identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 09:46:08 +00:00
0cf9220d24 re(savegame): stage-unlock probes are negative, and the Details panel is not a payload oracle
Four probes against slot 01 (the save the title-menu screens read), each backed up
and restored byte-identically:

- SHAB[1..7] filled -> MISSION SELECT still locks Stage02-08, cursor will not
  leave Stage01. The per-stage record table is not the unlock gate; the earlier
  SHAB[1] attempt used a throwaway slot and could not show this.
- GHAD +36/+52/+56 = 5 -> still locked, panel still STAGE 02 / EASY.
- GHAD +16 = 4 -> panel still STAGE 02, so it is not a 0-based stage index.
- live-RAM writes into the loaded GDHA header -> panel unchanged.

The useful part is why the panel cannot answer this: guest RAM at that screen holds
NO payload bytes (neither the 4101/4101/79 triple nor the 54-byte develop blob),
but it does hold the save's GDHA container header with a summary copy of Points,
flight time, clear ratio and a FILETIME. savegame_edit.py copies the donor header
verbatim, so a payload edit leaves that summary stale — "the panel did not change"
therefore cannot separate "not the stage field" from "the panel never reads the
payload". Future stage/difficulty probes must patch the header too, or be judged by
what the game does on load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 09:18:46 +00:00
0776beb6f4 fix(xbg7): degenerate index runs 582 -> 1 (grouped path + prefer a clean candidate)
Two follow-ups to the pad-scoring fix, both driven by the same invariant (a
correctly located index run has no degenerate triangles):

- anchor_grouped_meshes picked its pad by first-match too; scoring the pivot run
  the same way cleared every remaining ptc_pack composite (f102/f104/e107).
- anchor_pool_mesh now prefers a degenerate-free candidate over an earlier dirty
  one. examples/better_home.rs showed the last two resources each had exactly one
  degenerate-free, pool-covering block, sitting later in file order than the
  lookalike we took. First-match order is kept for every clean hit, and a dirty
  block is still used if nothing clean exists, so coverage cannot regress.

  degenerate index runs, disc-wide:      582 -> 11 -> 1
  captured index runs identical:         93/93 (unchanged)
  resources decoded / misses:            6 209 / 85 (unchanged)
  index runs changed / anchors moved:    590 / 10 (_rou_f402_dead x8, e201_bdy_03_m x2)

Cross-container minority decodes 89 -> 96, and that is progress: all seven new
rows are _rou_f402_dead, which now has a majority (32x25x8) for the first time, so
its seven wrong copies are named instead of hidden behind "no majority".

The last dirty run (_rou_f402_dead in Stage_S09) is blocked by distinct assignment
— its clean block is claimed by e_rou_f003_Near, both 24-vertex bounding boxes. A
winding-floor escalation for that case was written, measured to fire for nothing,
and reverted; the reasoning is kept as a comment.

Regression threshold tightened to 1. Suite green with --include-ignored apart from
the pre-existing known-failing cross-container consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 08:52:41 +00:00
33dc12c948 re(xbg7): render the before/after — the index shift shredded capital-ship hulls
ship_render --static on f101 (ACROPOLIS) with and without XBG7_PAD_FIRST_MATCH=1.
Identical triangle count, placements and bounds; the old wiring renders as a mess
of shards, the fixed one as a coherent hull. Kept as the evidence image for the
pad-scoring fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 07:28:40 +00:00
41b59faf5f fix(xbg7): the index run was one element late for 575 sub-meshes
Extending the capture comparison from index COUNTS to index VALUES
(`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging
gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17
differing — every difference a shift by exactly one element, on buffers whose
index data sits at pad 2.

`anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried
first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block
yields [true[1], true[2], …, garbage]: every index in range, the pool covered,
the positions right, the winding often just above 0.70 — so it validated, and
every triangle was mis-wired. Nothing count-based could see it.

The signature is decidable without the capture: a shifted run wires arbitrary
vertices, so triangles come out degenerate. 282 of 283 correctly anchored
Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry
1–2 156. So score every validating pad by (degenerate triangles, then winding)
and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour.

  captured index runs identical:            76/93  ->  93/93  (2 025 elements)
  decoded runs with a degenerate triangle:    579  ->     16  (disc-wide)
  sub-meshes whose index run changed:                    575  of 8 850
  resources decoded / vertex anchors / consistency:  unchanged (6 209 / same vb / 89)

Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.
Suite green with --include-ignored apart from the pre-existing known-failing
cross-container consistency test (the 24-vertex bounding-box class).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 07:26:52 +00:00
6d92e6c114 re(xbg7): the [index][vertex] layout is runtime-verified, and the indices= mystery was batching
The decoder's central unstated assumption — a block's index buffer sits
immediately before its vertex buffer (`vb - idx_count*2 - pad`, pad <= 3) — was
also the prime suspect for the residual anchor misses, since a capture-proven
`e106_eng_02_l` block was rejected outright. Measured it instead of assuming:

- extended the F10 ship capture to log each draw's index buffer (base, count,
  min/max index) and to key its de-dup on the index range, so every draw batch
  is recorded rather than only the first;
- `examples/capture_ib_truth.rs` places each drawn buffer in the container by its
  dumped positions and scores the capture against our decode.

Stage_S02, 42 drawn buffers placed: our idx_count == the sum of the draw's index
batches for 42/42, the batch union covers the vertex pool exactly for 42/42, and
all 30 single-block cases sit at pad <= 3 (20 at pad 0, 10 at pad 2). The other
12 are grouped pools, where one index pool serves the whole group. So the layout
holds, the decoded index count is exact, and eng_02_l died on the connectivity
gate (since replaced by the winding gate) — not on index location. The shipped
exact-coverage rule is independently confirmed.

The recorded "capture indices=21 vs our 246" disagreement was an artefact of the
old de-dup key: 21 was the first of two batches, 21 + 225 = 246. Any conclusion
from a pre-2026-08-13 capture's `indices=` or `vbase - ibase` is about one batch,
not about the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 05:57:44 +00:00
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
claude-re
695351dfc4 docs/re: mark the 'targeting is automatic' section superseded
It concluded no target-select input exists because no single press cycled a
target; select is A pressed twice. Kept with a correction banner rather than
deleted — a probe that never performs the action will 'prove' the action does
not exist, which is the reusable warning.
2026-07-30 20:48:15 +00:00
claude-re
4821ba7fea pilot: target select is A pressed TWICE — from the HUD tutorial, not from probing
HEADS-UP DISPLAY tutorial, verbatim: 'Press A twice to target the enemy closest
to the center of the screen.' A double tap, which is exactly why every button
sweep in flight-controls-runtime.md found nothing and why I concluded targeting
was automatic — each sweep tapped once. It also explains the missiles:
GuidanceType 5 guides to the GAME's selection and the loop had never made one,
so 98 launches guided to nothing.

Wired in: double-tap A when the committed contact is already within 14 deg of
the nose, so the game's choice and ours are the same object. One run: 8 kills
from 66 missiles (12% per missile) against the previous 9 from 101 (8.9%). The
absolute count is inside run variance and the efficiency gain is one sample, so
neither is claimed as decisive — it needs repeat runs.

Also documents that expository tutorials self-advance while interactive ones
stall (BASIC CONTROLS waits forever on 'Go to the box'), and that captions need
cropping across many frames because they type out.
2026-07-30 20:47:54 +00:00
claude-re
58f421d896 re-capture: read the ADVANCED CONTROLS tutorial; its moves regress the pilot, so they ship off
tutorial_capture.sh plays a tutorial and photographs what it teaches. ADVANCED
CONTROLS states three mechanics the key-config screen only named:
  B + LS            Side Roll / 180 Degree Turn / Level Off
  B + A together    face the target (snap turn)
  LT + RT together  'sets your fighter's speed to that of the target ... works
                    well when you are trying to get behind an enemy. Once
                    behind an enemy, this also helps you attack them.'

Wired both usable ones in and measured, one run each, everything else equal:
  commitment only ............ 101 missiles, 364 fire frames,  9 kills
  + match(4500) + snap-face ....  9 missiles,  28 fire frames,  0 kills
  + match(1200) + snap-face ...  57 missiles, 225 fire frames,  2 kills

So both are a net regression as applied, and both now default to OFF. Matching a
target's speed while still 5 km behind means never closing (the pilot sat at
272 u/s all run) — the tutorial scopes it to being already in the saddle. The
B+A snap turn reorients mid-pursuit and destroys the dwell commitment buys.

The code and thresholds stay so a future session can re-enable and A/B them over
SEVERAL runs; one run per config is inside this stage's spawn variance.
2026-07-30 19:50:25 +00:00
claude-re
9f41fe08e9 pilot: target commitment takes kills from 2 to 9; and the target pointer does not exist
The loop re-scored every contact every tick, so the nose chased whichever
fighter scored best that instant and aim error wandered 10-40 deg through a
pass. A missile lock is time-on-target, so constant switching is the one thing
guaranteed to prevent a kill. Commit to a contact until it dies, passes 6000,
sits >90 deg off the nose for 2.5 s, or 14 s elapse.

Same guns, same ballistics, same escort weighting, same missile cadence:
kills 0000 (five gun-only runs) -> 0002 (missiles) -> 0009 (commitment), with
101 missiles vs 98, and the largest hostile-population fall of any run
(134->97). Own hull untouched. The ACROPOLIS still ended at 76.6%, so this is
lethality, not the mission outcome.

Also records a negative result so it is not re-attempted: the selected target is
NOT a raw entity pointer. Three searches came up empty — a +-0x1400 window of
the player object, a full-RAM sweep of every entity-pointer word tapped through
each button (only thread-stack slots churn, which is frame noise), and a delta
tally over all 150 entities of the kind that found the definition pointer at
+0x130. The selection must be a handle, an index, or in a subsystem outside the
entity object.
2026-07-30 19:19:23 +00:00
claude-re
95ac545b2b docs/re: the game's own in-flight action list, off the disc
Decoded dat/GP_OPTIONS.pak (po_keys_btn* sprites): the OPTIONS key-config screen
lists every bindable in-flight action. Two of them change the plan.

'Change Target' exists — so target selection IS an input, and the earlier probe
that swept LB/X/B/A/LS/RS found nothing only because it watched the ammo
counters, which those actions do not touch.

'Padlock Mode Toggle' is a view/aim lock onto the selected target: the aim-dwell
problem solved by a game mechanic rather than by tuning a PD controller, and
presumably how a human holds a contact long enough to lock a missile.

Confirms our measured bindings (Use Nose Weapon = RB, Use Main Weapon = Y,
Accelerate/Decelerate = RT/LT, Radar Map Toggle = d-pad) and adds Special Move,
Maneuver, Resupply and Change Main Weapon (which would reach ASMissile, Power
5000). Also records that CONTROL SETTINGS carries a Control Type preset plus
yaw/pitch/roll sensitivity, so the mapping is not fixed and any stick
calibration is only valid for the save's current profile.

Tutorial menu (GP_TUTORIAL.pak) names the six lessons: BASIC CONTROLS,
HEADS-UP DISPLAY, RADAR, SUPPLY AND SPECIAL MOVES, RADIO ORDERS, ADVANCED
CONTROLS.
2026-07-30 18:54:16 +00:00
claude-re
ab8f5307ff docs/re: in-flight control mapping, and why lethality is an aim-dwell problem
Probed rather than assumed: hold each pad input and photograph the HUD ammo
counters. RB = nose gun (NOSE BM 6000->5956 in 4 s, ~11 rounds/s, HEAT rises),
Y = main mount (MAIN MPM 300->299), d-pad = tactical map overlay, and
LB/X/B/A/LS/RS move neither counter.

No target-cycle input exists. The green TARGET marker is already up with nothing
pressed, so the game selects for us and a guided missile's lock is a
time-on-target mechanic, not a button. That closes the lethality question: it is
not target choice (escort weighting), not ballistics (now from the confirmed
Shell records), and not the mapping — the steering loop simply never holds the
nose on one contact long enough to lock. Aim dwell is the next lever.

Also records two things the reimplementation needs: two weapons with separate
ammo pools and HUD counters, and a HEAT bar that fills while the gun fires
(cap and cool-down not yet measured).
2026-07-30 18:49:16 +00:00
claude-re
76f463b611 pilot: RB fires, Y is the main mount, and guided missiles produce the first kills
fire_probe.sh holds each pad input in flight and photographs the HUD ammo
counters. RB moves NOSE BM 06000 -> 05956 in 4 s (~11 rounds/s, HEAT rises);
Y moves MAIN MPM 00300 -> 00299; nothing else moves either counter. So the
control mapping is measured rather than assumed, and 'we never shoot' is dead:
we shoot and miss.

The disc data says to stop shooting: Shell_TCAF_DeltaSaber_Missile_P is Power
200, GuidanceType 5 (guided), MaximumRange 5000, versus the nose gun's Power 15
unguided — one missile is worth ~14 gun hits on a 500 HP fighter and it steers
itself. Launching them (press Y, release a tick later, >=2 s apart) produced
YOU KILLED: WARPLANES 0002 — the first non-zero kill counter of the series,
against 0000 in all five gun-only runs, with hostiles down 134 -> 104.

Still only 2 kills per 98 missiles (~2%). Likely cause: the game expects a lock
before launch and an unlocked missile is wasted. Reading the lock state out of
RAM is the next step.
2026-07-30 18:40:39 +00:00
claude-re
3f6efadf9e pilot: real ballistics from the solved Shell records — and the metric that says it did not help
Shell_TCAF_DeltaSaber_*_P: Velocity 8000, LifeTime 0.5 s, MaximumRange 4000
(self-consistent: 8000 x 0.5 = 4000), all confirmed. Two things were wrong:
lead computed flight time as d / OUR speed (400-2000 u/s, so every shot was led
4-16x too far), and FIRE_RANGE was 5000 — past where the shells expire.

Both fixed. But the HUD's own kill counters read 0000/0000 at the end of EVERY
run including the nearest-fighter baseline, so the pilot kills nothing in any
configuration and 'fraction of frames firing' was never measuring lethality.
No improvement is claimed.

One clean negative result kept: gating on the target's angular half-size alone
(2.7 deg at 2584 units) is far tighter than the steering loop can hold the nose
— firing collapsed to 1 frame in 2639. Angular size is a floor on the firing
cone, never a cap.

Next: the HUD carries a live ammo count, so holding fire and watching it settles
'we never shoot' vs 'we shoot and miss' in a single run.
2026-07-30 18:16:26 +00:00
claude-re
402985adbf pilot: escort-weighted targeting (DEFEND), plus the capital-ship keep-out it needed
While the asset is losing hull, target what is pressing IT — ranked by distance
to the asset minus credit for closing on it — instead of what is nearest to us.
Trigger and ranking both read the live hull (pos+0x154), so nothing is inferred.
DEFEND engaged 1.9 s after the asset's first hit and held 54% of a 330 s run.

It did NOT measurably save the asset: over the window two runs share, the
policies are equal to within noise (t=239: 23218 vs 23038). Two reasons, both
recorded rather than papered over: the runs are not comparable past that window
(spawn timing differs and the hostile count GREW 134->166 in one, fell 147->118
in the other), and the real bottleneck is lethality — the guns are on for 12% of
combat frames because the target is outside the 9 deg cone the rest of the time.

Also corrects a single-run claim in the previous commit: the asset is NOT
reliably safe for the first ~170 s. A second run had first damage at t=70 s. The
stage does not replay identically; only 'the loss is slow' survives.

Fixes a fatal bug the new mode exposed: DEFEND flies at the asset, which sits
inside the friendly formation, and the first escort run went hull 1500 -> DEAD in
one tick at 2026 units/s, 0.6 s from a friendly destroyer that avoidance thought
it would clear by 365 units — the ship's radius is 2000. Keep-out applied only to
hostile turrets. Every entity above BIG_RADIUS now gets its own radius + 800 of
physical keep-out with braking inside it, whatever its faction.
2026-07-30 17:49:38 +00:00
claude-re
c277e42c92 docs/re: the hull anchor is class-wide — the escort objective is scoreable live
own_state.py found hull = position + 0x154 for the PLAYER. Stage 02 is an
escort and is lost when the ACROPOLIS sinks, so scoring it needs someone
else's hull. Measured over 240 s of Stage 02: at t=0 pos+0x154 equals each
entity's own definition HP across 7 classes and 5 distinct HP values (turret
100, fighter 500, destroyer 10000, cruiser 30000, Acropolis 25000). Nothing
read above its HP; the five that read slightly below were already under fire
when the player launched. It falls with damage (780 events), goes negative at
death, and the object then leaves the heap.

UN_f101_TCAF_Acropolis: HP 25000, radius 1400, measured 25000 -> 23038 over
240 s with the attack starting only at t~170 s (~600 HP/min) — so the earlier
GAME OVER was not a fast loss, it was an undefended one.

Also: REMAINING OB reads 012 while 118 ADAN entities are alive, so it counts
objectives, not hostiles; its address is still unknown.
2026-07-30 17:21:05 +00:00
claude-re
8ee3bfbcef re-capture: stop detaching the display and the emulator
ensure_display() and the run-canary launch used 'setsid nohup', to make them
outlive the shell that started them. That is what has been killing every long
run: a setsid'd process belongs to no supervised tree, and both Xvfb and xenia
were reaped a couple of minutes in — the 'they die on their own every few
minutes' note in the project memory. Measured: a bare Xvfb with no emulator
running exited 0 (a clean shutdown, not a crash and not the OOM killer) at the
exact moment a turn ended, and a session whose display was setsid'd from inside
a tracked task died the same way 12 s after launch.

Run launch_mission.sh as one tracked background task and keep Xvfb, openbox and
xenia as its children. Adds an exit-status wrapper so a future death reports the
server's own exit code (128+N for signal N) instead of being inferred, and
clears a stale X lock before starting.
2026-07-30 17:12:00 +00:00
claude-re
91fb022caf re-capture: a dead display must not look like a playing movie
skip_intro.sh compared two screenshots to detect the intro movie. When the X
server died 3 min into a run, `screenshot` failed silently and left both PNGs
at their previous contents — two stale files, whose RMSE is a constant non-zero
number, i.e. exactly the signature of a changing screen. The loop then reported
"movie -> skip A" every 5 s for the whole 600 s timeout with no emulator and no
display alive, and the session wasted 10 minutes before saying BOOT FAILED.

Both waiters now verify, every iteration, that the screenshot was actually
written, that the display answers xdpyinfo, and that a non-zombie xenia_canary
exists — with distinct exit codes (3 display, 4 emulator, 5 capture) so the
session log names the cause instead of timing out.

Also adds mission_state.py + escort_session.sh: read pos+0x154 against each
definition's HP for EVERY entity, to test whether the hull anchor is a property
of the entity class rather than of the player object (the escort question).
2026-07-30 17:06:08 +00:00
claude-re
93d6534efe docs/re: open a backlog, first item = capital ships assemble wrong in the viewer
The runtime-capture write-up declares static ship assembly exact, but its test
covers exactly one ship (e106). Records what is suspect (engine-cluster rig,
the X-reflect twin heuristic, cross-id turrets), and that the F10 capture is
already the oracle to settle it on a second class.
2026-07-30 17:01:32 +00:00
41c34dc3ca re: surviving is not winning -- Stage 02 is an escort
The second 240 s flight ended on GAME OVER with our hull untouched at
1500/1500. Nothing shot us down; the ACROPOLIS was sunk while the
pilot pursued an attacker two kilometres away. "Nearest hostile
fighter" is the wrong objective function for this stage.

The fix is available with what is already solved: the protected ship's
hull is readable with the same anchor as ours (position+0x154, maximum
= its own definition HP), so target priority can be "closing on the
asset" and the escort can be scored live.

Also records that a frozen log tail is what mission-end looks like
from outside the emulator, not a wedge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:43:42 +00:00
2e9903d0fc re: the autopilot now survives, and kills
Three measurements, then a pilot built on them.

* Hull is position+0x154. Found by anchoring on a field the definition
  already had solved (HP = 1500) rather than scanning for a value that
  falls: an undamaged craft must contain its own definition's number.
  Confirmed by the trace across a death -- 30/60/90 per hit, negative
  at 0, GAME OVER on screen.
* RT accelerates, LT brakes, and the throttle is a persistent setting
  (488 -> 1510 -> 174 units/s, measured as displacement per second of
  the craft's own position, so no speed field was needed). This
  overturns the earlier "RT is not the throttle", which came from
  assuming the control and hunting for a field.
* Shield is probably position+0x430 (== definition MaxValue 400), not
  yet confirmed live -- nothing had damaged it.

pilot.py is a state machine on damage (ENGAGE / EVADE / RETIRE) that
treats turrets as keep-out zones instead of targets. It flew Stage 02
for 300 s with the hull untouched at 1500/1500 and took the first
confirmed kill (WARPLANES 0001); every run the day before was dead
inside 35 s.

Two bugs the live run exposed and this fixes: gating the guns on the
commanded direction keeps them cold whenever avoidance is steering
(gate on the target instead), and an orbit-plus-brake rule made it
circle one attacker for 40 s outside its own firing cone.

Also: boot to in-flight is now ~100 s unattended, because
wait_flight.sh waits for the HUD's own shield bar instead of a fixed
75 s sleep that lavapipe does not honour; entities2.py picks the
attitude block by matching the measured flight path (taking the first
orthonormal block gave a bone/camera frame); and the entity-heap scan
is numpy instead of a per-word Python loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:38:00 +00:00
4623387f6c re: the memory-driven autopilot now flies, tracks and shoots
Three findings turned the previous dead end into a working loop, each checked
against something independent rather than assumed:

  * a live entity's definition pointer sits at position + 0x130, so one heap
    scan types every craft in the scene -- which is what separates ~20 real
    combatants from ~30000 moving particles. The result is coherent: wingmen,
    enemy turrets and attackers, friendly capital ships, one Player.
  * orientation is a 3x3 at position - 0x70 stored with a 16-BYTE ROW STRIDE
    (a 4x4 whose translation row is the position). The earlier search for nine
    contiguous floats could not find this by construction, which is why the
    first pass wrongly concluded there was no transform. Confirmed by its row 2
    matching measured direction of travel at cos = +1.000.
  * RB is the fire button, established by consequence: of RB/LB/A/B/X/Y/RT/LT
    it is the only one that makes the nose-ammo counter in RAM fall.

Control is PD on the aiming error with the derivative from body angular
velocity, and target selection weighted by off-boresight angle -- pure
nearest-first kept picking targets 90 deg off the nose, whose bearing rate then
outran the turn rate and held the craft outside its firing cone at a steady 27
deg pitch error.

Observed: distance to target closing monotonically, yaw error driven from -8 deg
to ~0, fire=1 once inside the cone, ammo counter falling.

Not solved: survival. There is no evasion, no shield/armour awareness and no
throttle control, so it flies a straight pursuit into defended space and is
shot down; every long run has ended in GAME OVER. Mission completion needs
those, plus objective-aware target priority.
2026-07-29 19:32:37 +00:00
72365b217a re: memory-driven autopilot -- infrastructure, and an honest account of what is not solved
Reads the live world out of guest RAM and drives the pad from it. Working:
loop-rate memory reads, whole-RAM float scanning with numpy (1270 orthonormal
3x3 blocks in 6.2 s), entity enumeration by unit type (116 live instances in
Stage 02), pad control written straight into the vgamepad FIFO (the CLI spawns
a process per command and its tap/hold sleep inside the server, so neither is
usable in a control loop), unattended mission entry, and the Hangar loadout --
the "Recommended" control is AUTO SELECT, which at 5 % progress is a no-op
because only two weapons are developed and both are already mounted.

Not working, and the reason the craft is not yet flown: the class 0x820af030
is NOT the live entity. It has one object per spawned thing and carries the
unit-ID string, which is why it looked like the entity list, but every one of
its 384 words is constant across a 29 s in-flight capture. No transform lives
in it or one pointer hop from it. Input correlation (hard left yaw vs hard
right, looking for a turn axis that reverses) does find self-like objects at
cos = -0.99, but they cluster in what looks like a camera volume rather than
the craft, and with no definition pointer near them the trick of learning one
entity's layout and applying it to the rest has nothing to anchor on -- so the
33418 moving triples in a firefight cannot be split into enemies, friendlies
and bullets, and there is nothing to aim at.

Two dead ends are recorded so they are not repeated: RT is not the throttle
(the two-state speed scan therefore found nothing), and comparing orientation
matrices 2 s apart is outside the small-angle regime, which is what produced
"angular velocities" of 30000.

Also corrects the claim in unit-struct-runtime.md that 0x820af030 holds live
state. The definition class 0x820af844 and every value derived from it are
unaffected.

autopilot2.py (a PD controller using body angular velocity from consecutive
rotation matrices) is committed but has never had a valid config to run
against, and is marked as untested.
2026-07-29 19:04:33 +00:00
22d2518847 re: don't let zombie emulators fool the liveness check
Container PID 1 is `sleep infinity` and never reaps, so every emulator this
script kills stays as a <defunct> entry that `pgrep -x` still matches -- after
a few captures the script always believed one was running. Same trap as the
Xvfb check. Ask ps for the process state and skip anything in Z.
2026-07-29 18:24:18 +00:00
1ee2d5e406 re: widen unit coverage to all six tutorials, and prove the fields are run-invariant
Captures the five remaining tutorials (grab_tutorial.sh, one cold boot each)
and re-solves over seven snapshots from seven separate emulator runs. Coverage
18 -> 21 units, confirmed fields 22 -> 27 (Size_Y, MassScore, MinimumVelocity,
AV_PitchPlus_Min, AV_PitchMinus_Min join the zero-contradiction set).

The multi-run union exposed something the single-run check could not: the same
unit's object is NOT byte-identical between runs. unit_runtime.py --crosscheck
pins down why -- exactly 15 words differ, 13 of them holding guest heap
pointers, and none of them is a solved or interpolated field offset. So every
value reported is run-invariant, which is a stronger statement than the
within-run identity check that came before it.

The two non-pointer stragglers are a real caveat, now documented rather than
smoothed over: +0x2c8 reads 8000.0 for UN_f001_TCAF_DeltaSaber_T_Ttrl in two
tutorials and 10000.0 in a third, with a 0/1 flag at +0x2d0. A couple of words
in the object are set per stage, so it is mostly but not entirely the parsed
table. Unidentified, marked NEEDS-HUMAN.

Coverage honesty: all six tutorials together add only 3 units the missions do
not already have -- they reuse one training box, one drone and the player
craft. Further coverage needs story progress, not more tutorials.

Two navigation facts encoded in grab_tutorial.sh, both learned by breaking
them: the main menu is not input-ready for ~10 s after the title tap and early
d-pad presses are dropped (which sends the A to NEW GAME); and NEW GAME is not
a shortcut to Stage 01 -- it gates on DIFFICULTY then plays the prologue. A
NEW GAME excursion to the READY ROOM leaves game01/savedata byte-identical,
verified by diff against a backup.
2026-07-29 18:22:47 +00:00
2196ac112d re: unit definitions are parsed at stage load, not on wave spawn
Counted the definition objects at three points in Stage 02 -- just after
take-off, ~12 min into combat, and after GAME OVER: 14 objects, same 14 IDs
each time. Capturing a stage therefore costs one load and one snapshot; the
mission does not have to be played or survived.
2026-07-29 17:31:45 +00:00
2fa4c33d1a re: solve the runtime Unit (craft/vessel) struct from live guest memory
Craft stats were the last parked Route-B target: the Hangar exposes only a
weight class, and in menus the flight-model object is not instantiated at all.
It is instantiated in-mission, so this reads it there.

The definition class is discovered rather than assumed (unit_discover.py):
scanning for pointers to the disc ID strings and tallying the word behind each
pointer site picks out vtable 0x820af844 -- one object per unit ID, name-record
pointer at +0x04, string at +0x10, exactly the Weapon shape. The neighbouring
class 0x820af030 is the spawned entity, not the definition; the two are told
apart by one-object-per-ID and by 14/14 objects being byte-identical across two
snapshots 12 minutes and one firefight apart.

22 fields bind with zero contradictions on >=3 distinct values. Beyond that,
the Maneuver sub-record turns out to be laid out in schema declaration order,
4 bytes per field, base 0x9c with a two-slot gap after AA_Roll_Min -- 29
independently solved anchors fit two exact bases with no conflicts. That rule
pins five fields NO unit table ever values (PitchDragFactor, RollDragFactor,
DragFactorThreshold, ArterBurner_Acc, DecPitchFactor); PitchDrag == RollDrag ==
the disc's YawDrag on 18/18 units corroborates the interpolation.

Angles are float32 radians at runtime and degrees on disc.

Coverage is 18 of 110 units: unlike weapons, unit definitions are instantiated
per stage, so it grows by visiting missions. The AI-behaviour tail of Maneuver
is explicitly NOT resolved and marked tentative in the CSV.

Captured from Xenia Canary (BASIC CONTROLS tutorial + Stage 02 from save 01).
2026-07-29 17:27:53 +00:00
6a41525c41 re: generalize the two capture tools beyond weapons
idxd_tokens took the sub-record type list as an argument already but still
hard-filtered entries to those declaring a `Weapon_*` id, so it could not be
pointed at another schema. Filter on the requested type names instead -- it now
splits `EnumUnit` (Generic/Maneuver/Shield/Explosion/Frame/Turret/...) too.

gmem: honour $GMEM_FILE. `cp --sparse=always /dev/shm/xenia_memory_* snap.bin`
takes ~2 s and reads identically, which matters because a running Canary pegs
every core under lavapipe and makes repeated live reads stall unpredictably.

Groundwork for the craft (UNIT) stats. Not a finding yet: in menus only the
player craft's *name* is resident -- the flight-model object is not instantiated
until a mission loads, so that needs an in-mission snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 06:05:13 +00:00
9daac6f1f0 re: read the defaulted weapon stats out of live guest memory
Canary backs the whole guest address space with one shared-memory file, so the
game's RAM is readable from the host while it runs -- no debugger, no emulator
patch. That turns the parked Route-B question (fields the IDXD omits because
they sit at their default) from "unreachable" into a table lookup.

gmem.py maps guest VAs into /dev/shm/xenia_memory_* via Xenia's fixed table and
searches only the allocated extents, so a full-RAM scan is ~0.2 s.

weapon_runtime.py finds the parsed objects by scanning for their vtable, then
*solves* the struct layout instead of guessing it: every (field, offset,
encoding) triple is scored against the disc records, and a binding is accepted
only with zero contradictions -- and marked confirmed only when >=10 records
agree on >=3 distinct values, because a field whose samples are all one number
matches any offset holding that constant.

Result: Weapon (0xc0) and Shell (0x200) mapped, 4393 values the disc does not
carry, for all 126 weapons at 5 % save progress. Cross-checks hold -- the
Arsenal DATA SHEET read 4 for wep_05/wep_60 Max. Lock Ons and the memory read
agrees; the in-flight HUD's 06000/00300 match LoadingCount; all 252 objects are
byte-identical across a screen change, so this is definition data, not state.

Two findings fell out: `IsCharging = Yes` switches LoadingCount and
TriggerShotCount to float32 (it partitions the 8 float-encoded records exactly),
and two .tbl entries declare Shell_TCAF_Ship_AAGun with conflicting Power -- the
runtime keeps the one that omits it.

Where the defaulted values *come from* (the IDXD's undecoded binary node region
vs code defaults) is not settled here; they vary per weapon, so they are not one
constructor constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 05:29:34 +00:00
e3f3d55d29 re: ui layout generalizes — the title main menu rebuilds too
Second screen, independent of the pause work: GP_TITLE.pak's ptbtn01..05.rat give
X=542 for all five items and Y=162/242/322/402/482, an 80px pitch where the pause
menu used 70. Measured against the main-menu screenshot the sprite tops sit at a
constant +46px for all five, which is exactly the 45px of Xenia window chrome plus
one — so the record's Y is the sprite's top edge in the guest framebuffer, to the
pixel, on a screen the format was not derived from.

GP_TITLE also splits by sub-screen the way the pause pak splits by context:
ptbtn00 (PRESS A BUTTON), ptbtn01..05 (main menu), ptbtn11..13 (EXTRAS submenu),
pgloading_* (loading screen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:44:29 +00:00
9a86a09f8d re: ui — the RATC bundle header is the screen's element declaration table
u32 count at 0x14, then fixed 60-byte entries of [name | 4 flag words | pivotX |
pivotY | 0]. The table lists both .t32 sprites and .rat records, so it is the
screen's element list.

Verified: for all 7 .t32 entries in the tutorial pause bundle the declared pivot
is exactly half the decoded texture's dimensions, 7/7 with no mismatch. That also
settles what the pair at 0x50/0x54 of a .rat record is — a .rat simply opens with
one of these declarations.

The language split is visible inside one bundle: the English build's .t32
declarations carry correct English pivots while its .rat declarations carry
Japanese-derived ones, so the sprite table is regenerated per language and the
layout layer is inherited from the Japanese master.

Adds tools/re-capture/ratc_decls.py (parse the table, check pivots against the
decoded textures).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:40:57 +00:00
aaa4ea60b3 re: ui .rat — pivot field, language-independence, PRMD, and a tightened claim
Follow-up decoding on the layout record:

- 0x50/0x54 is the sprite PIVOT: exactly half the texture size on 4 of 5 plain
  sprites. It is a rotation/scale centre, not a draw offset — X/Y remains the
  top-left, which is what actually reproduces the screenshot.
- The records are byte-identical across the English and Japanese bundles: layout
  is authored once and only the sprites are swapped. That explains pgpbtn01's
  pivot implying a 226px texture that neither shipped language has.
- loop1.rat is NOT the screen composition — it is a looping sprite animation
  (3-name table + ~30 keyframes at one position). The eff*/deli*/msg draw list is
  therefore still unfound, and that gap is now stated plainly.
- The 380-byte top-level entries are PRMD primitives: a colour plus four explicit
  corner coordinates for a full-screen quad — the pause dim overlay. The format
  is tag-driven ('opt ', 'PRMD', 'end '), not a fixed struct.

Also demotes one piece of evidence. The screenshot is of the TUTORIAL pause menu,
so only pgp_ttrl_* can be checked against it; the in-mission records mapping onto
the same screenshot under a constant offset works only because both builds share
the 70px pitch and proves nothing. In-mission coordinates are now marked
unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:38:04 +00:00
621b9dd6e9 re: crack the UI layout record — the retail UI rebuilds from the disc
Each UI sprite <name>.t32 ships with a <name>.rat companion, and that companion
is the layout record: big-endian u32, a 1280x720 design space at 0x18, and a
placement block of scaleX/scaleY/tint/X/Y. Animated elements repeat the block
with a trailing time field (keyframes); an 'opt ' tag carries a length-prefixed
link to the focused-state record.

Verified in that order, records first: across the four pause buttons exactly one
field varies, stepping 268/338/408/478 at a constant 70px pitch with X fixed at
226; the three items visible in a screenshot of the running game measure
346.0/415.5/485.5, mapping onto those numbers under one constant offset to
within 0.5px. The tutorial build's records are outright framebuffer coordinates,
and compositing its sprites there reproduces the screenshot pixel-accurately.

Also records two mistakes the A/B caught, since a static-only reading would have
shipped both: texture width does not identify a language (Japanese fits English
widths), and the in-mission and tutorial pause menus are separate sprite sets
rather than one set re-packed into slots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:05:21 +00:00
07eff85819 re: in-flight HUD — independent confirmation of the ammo mapping
The tutorials need no save and are ~1 min from a cold boot, so they are the
cheapest route into a live flight scene. The HUD prints the equipped weapons as
NOSE BM 06000 / MAIN MPM 00300 — matching wep_01's LoadingCount 6000 and wep_02's
300, with the weapons identified from the HANGAR loadout rather than from the
ammo number. That makes it a genuinely independent confirmation of
Ammo Capacity == LoadingCount, from a different renderer in a different mode.

Also inventories the HUD elements (heat gauges, shield/armor pools, target armor
gauge, per-weapon RANGE markers) and notes that the RANGE gauge draws each
weapon's MaximumRange on a scale — a route to a real number where the Arsenal
panel only gives a letter class.

Adds autopilot.py (waypoint-arrow chaser). It detects reliably but oscillates;
recorded as such rather than as a working tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:50:46 +00:00
c3b5f3f401 re: state the real (non-circular) evidence for Ammo Capacity == LoadingCount
The 'N/N exact matches' framing was circular: each UI entry was identified BY its
ammo value. Replaced with what actually carries the claim — tab-uniqueness of the
forced match, the independent Max. Lock Ons agreement on two of them, and one
entry (STILETTO BG I) identified from the HANGAR loadout rather than from ammo.
Also downgrades LIGHT MACHINE GUN MG I to PROBABLE: three guns share ammo 3000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:09:03 +00:00
ba33c533da re: runtime DATA SHEET reads the live weapon record (Route B, first capture)
Drove the retail game headless (Canary + lavapipe, vgamepad) to the Ready Room
Arsenal and read the Gallery-Mode DATA SHEET for every weapon the 5%-progress
save reveals.

Two UI->field mappings are CONFIRMED against weapons whose values ARE on disc:
  Ammo Capacity  == LoadingCount      (9/9 exact matches)
  Max. Lock Ons  == TriggerShotCount  (FALCON 9AM 12 == wep_02; BUZZARD 22 == wep_04)

That makes the panel an oracle for fields the disc leaves defaulted, and it is
shown for undeveloped weapons too, so no points need spending. Recovered
TriggerShotCount for wep_05 and wep_60 (both 4, on-disc absent), and bracketed
the defaulted Power / MaximumRange via the Range/Damage letter classes.

Ruled out first: the defaults are not in hidden/DefTables.pak (no WEAPON-schema
objects there) nor in the localized GP_MAIN_GAME_* duplicates.

Also adds the two analysis examples that produce the shopping list of defaulted
fields, the screenshot harness used to drive the menus, and the evidence PNGs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:01:25 +00:00
MechaCat02
c067761e56 docs: handoff — static ship placement exact, capture-verified (2026-07-26)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:54:55 +02:00
MechaCat02
ea32e77e45 ship: engine placement CONFIRMED by 43-instance capture; exhaust markers added
capture_verify example: clusters every part's ship-relative transform across
the new multi-snapshot, multi-instance F10 captures (5 snapshots, 43 e106
instances, all angles). Verdict: the dominant clusters match the static
assembly to ~1 unit on EVERY part — including BOTH engine nacelles at
(+-131, -133, -131) — so the "engines inside the hull" appearance is the
game's own placement: the engine geometry sits recessed in the aft hull, and
the visible "thrusters" in-game are exhaust FX drawn at the GN_Jet/GN_SJet
frames (Z ~ -570, past the stern).

To close that perception gap the viewer now draws simple exhaust cones at the
game's own jet frames (ship::exhaust_frames; part of the external-parts
toggle). The jet frames flip Z, so the cone apex is authored at +Z and lands
trailing aft — verified in the offline render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:44:12 +02:00
MechaCat02
31f2637e6c viewer,ship: external parts ON by default + Ships catalog = composite ships only
Fixes the two things the user saw in the Ships browser:
- "Can't find the thrusters": show_external defaulted OFF, hiding the engine
  rig / bridge / turrets entirely. Externals are EXACT since the node-TRS fix,
  so the toggle now defaults ON (relabelled; off = bare hull bodies).
- "Some parts very far away": families without a composite scene graph (fighter
  morph sets like f002 whose detached parts are authored far from the origin,
  shared turret/prop part families) fell into the stack-at-origin fallback and
  rendered as piles with stray pieces. ShipModel now carries has_composite and
  the catalog lists only real assembled ships.

Offline audit across ALL stages (ship_audit example): the only far-away
outliers were composite-less f002 morph parts (now filtered); every capital
ship assembles coherently (e108 places its 7 turret instances, f105 its
mirrored shield generators). All primary composites are single-key tracks —
multikey exists only in animation composites (e901 attacks, missile-open),
which assemble_ship does not select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:15:34 +02:00
MechaCat02
737b61242e ship: STATIC assembly is now EXACT — node-TRS off-by-one cracked via the capture
The runtime capture served its true purpose: as the answer key that exposed the
static encoding. The XBG7 node joint table (node+0x44) holds NINE keyframe
channels [TX TY TZ RY RX RZ SX SY SZ] behind EIGHT pointer slots shifted one
track forward: channel k+1 = f64@(ptr[k]+8), and channel 0 -- TX, which no
pointer names -- sits at ptr[0]-0x48 (tracks are 0x50-byte records, value at
+8). The old 8-slot read took TY as X, RY as Y (usually 0 -- why both hulls
stacked on the centreline) and never saw TX at all (the +-264 hull offsets).
Euler order is Ry(ch3)*Rx(ch4)*Rz(ch5) with angles applied directly, pinned by
the captured engine nacelle Rx(-15)*Rz(30) decomposition. mesh.rs gains
read_trs9/node_rotation; scene_world_nodes and node_transforms both fixed
(saber fin overrides unaffected).

assemble_ship rewritten fully static and exact:
- every composite-node instance placed (alias duplicates collapsed, real
  instances kept) including cross-id turret mounts (rou_e303_wep_01_root x2)
- the e_rou_<id>_eng cluster rig mounts at GN_Engine_01 (two mirrored nacelles
  +-131 with -+30-degree cant + centre engine) -- world = frame o rig_local
- brg/sld at their exact GN frames (frames were always exact)
- shared-geometry twin pairs at +-TX: X-reflect the instance whose offset
  opposes the geometry's dominant side (viewer reverses winding on det<0)
- squeezed node names resolve (wep02 -> wep_02_01)

Ground-truth gate: ship::tests::static_assembly_matches_runtime_capture asserts
static == the baked e106 capture for all 8 parts (T<1.0, R<0.02) plus both
nacelles, both turrets, and the starboard-hull mirror. Viewer now uses pure
static assembly; ship_capture + the baked table remain as the verification
oracle. Renders show a coherent, bilaterally-symmetric destroyer.

81 formats-lib + all disc tests green; viewer builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:00:49 +02:00
MechaCat02
015e49ac8a ship: diagnostics — baked-table renderer + evidence the placement IS static
ship_render example: assemble a ship from the baked capture table (or --static)
and write orthographic top/side PPM renders + per-part world bounds — the
offline eye for placement bugs.

Findings recorded (docs to follow with the fix):
- e106 baked eng_01 is CROSS-INSTANCE contamination: the F10 capture de-dups by
  vertex-buffer address alone, so for a part drawn by several fleet ships only
  the FIRST instance's transform survives; eng_01's 30-degree rotation and
  below-hull position belong to a different destroyer. Fleet formation made it
  reproduce across captures, defeating the cross-validation.
- The static composite DOES carry the exact placement: BE-f64 +-264.0 (the
  lateral hull pair offset the static assembler loses) and -164.044 (the bridge
  Z we captured as -164.037) sit in e_rou_e106's node joint tables. The static
  decode is incomplete, not the data — full static assembly is achievable, with
  captures demoted to a verification oracle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:39:34 +02:00
MechaCat02
d9442a2106 ship_capture: bake e106 COMPLETE from the 2026-07-26 F10 capture — all 8 parts
The second F10 capture (ship at _m LOD distance) closes e106 entirely: all 8
parts placed including the bridge both earlier captures missed, cross-validating
the doc's 7 known translations to 0.1 units and adding brg_01 at the exact
centreline (0, 153.3, -164.0).

Matching hardened by what the real capture taught us:
- LOD-aware: each part tries every variant vcount (base/_m/_l/_d) — a distant
  ship draws its LOD copies, same local frame.
- Position-validated, SET-based, against the UNION of a part's variants: buffer
  order != decode order, and one draw's vcount equalled the _m count while its
  buffer held the FULL 1633-vert geometry. A coincidental vcount (foreign
  51-vert mesh vs the bridge) is rejected by geometry.
- Runtime mirror handled: twins share one file geometry; the engine uploads the
  starboard copy X-reflected. Mirror-validated draws bake diag(-1,1,1) and the
  viewer reverses triangle winding for det<0 so front faces stay outward.
- Near-axis rotation entries snapped to exact 0/+-1 for a clean table; eng_01
  keeps its genuine 30-degree nacelle rotation.

data/ship_placements.txt now ships e106 (viewer renders via the captured table);
embedded_e106_is_complete locks the baked data. part_pos + capture_match helper
examples added. 10 ship_capture tests; 80 formats-lib tests green; viewer builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:26:23 +02:00
MechaCat02
4efc0278b1 ship_capture: ingest the draw-logger format + quantify the static gaps
The correlator now accepts BOTH capture formats: the F10 ship-capture snapshot
and the draw-logger format (mission_draws.log / xenia_re_draws.log) via
parse_drawlog — group the ship shader's draws by vertex-buffer base, vcount =
size_words/stride_words, keep the first c0..c2 WVP. So a capital ship seen in a
normal instrumented run bakes without a dedicated F10 pass. correlate_capture
auto-detects the format. Shared normalize_wvp between both parsers.

Also add examples/ship_dump.rs (offline static-placement inspector) and record
the measured static-assembler gaps for e106 in the doc: bdy_01/bdy_02 overlap
(runtime port/starboard mirror at X=+-264), eng_02 and wep_02_01 never placed.
These confirm exact placement is runtime-only — nothing more to squeeze
statically; the capture-driven table is the only path. The draw logs present on
this box are the player fighter, so no capital-ship table can be baked offline.

7 ship_capture tests (adds draw-log parse + correlate); 77 formats-lib green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:57:08 +02:00
MechaCat02
c6ca5ce9e7 ship: bake pipeline — correlator as a tested module + viewer prefers captured table
Promote the F10 ship-capture correlator from an example into a reusable, unit-
tested library module (ship_capture): parse_capture (log -> per-draw rigid
WorldView from the c0..c2 WVP constants), correlate (match parts to draws by
vertex count, express each in the reference part's frame), and a checked-in
placement-table format (serialize_table/parse_table, embedded via
embedded_placement from data/ship_placements.txt).

build_ship_model now prefers a ship's captured placement over the static
assemble_ship when a table entry exists (empty table -> unchanged fallback).
correlate_capture example refactored onto the module + gains --emit to print a
table block. Doc updated: bake plumbing done; remaining is running real F10
captures to populate the table (needs the emulator).

5 new ship_capture tests (parse/normalize/correlate/table round-trip); 76
formats-lib tests + viewer build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:48:00 +02:00
MechaCat02
a68405216f re: runtime-capture pipeline for exact capital-ship part placement
Static external-part placement (ship::assemble_ship) is only approximate — a GN_*
frame is the mount pivot, and the real offset lives in a runtime detail rig. So
capture ground truth from Canary instead.

Finding: the guest vertex buffer holds LOCAL coords (byte-identical to the .xpr),
so capital-ship parts are placed entirely in the vertex shader. The per-part
transform is in the VS float constants — c0..c2 are the WorldViewProjection rows;
normalising each by its norm (= projection x/y/z scales, sy/sx = 16:9) yields the
rigid WorldView (rows orthonormal, det +1). Expressing every part relative to a
reference part cancels the camera: rel_p = WV_ref⁻¹·WV_p, a pure ship-space rigid
transform.

- examples/correlate_capture.rs: parse xenia_ship_capture.log, decode the ship's
  parts, match by vertex count, recover each part's ship-relative placement.
- docs/re/ship-placement-runtime-capture.md: full method + the capture protocol
  (Canary branch capture-ship-placement, F10 hotkey) + validated e106 result.

Validated on e106 (ADAN Destroyer, Stage_S01): all 7 drawn parts matched by
vertex count; bdy_01/bdy_02 recovered as a PORT/STARBOARD pair (X = ∓264) that
static had overlapping; engines correctly aft; extent 872×670×2181. Only brg_01
(bridge) missing — culled at that camera angle, needs a bridge-facing capture.

HANDOFF: next = re-capture bridge, bake a per-ship placement table the viewer
prefers over assemble_ship, then capture the cruiser / ACROPOLIS / TCAF ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:50:26 +02:00
MechaCat02
79c78cc7e1 ship: apply scale + gate approximate external parts behind a toggle
Two changes after user feedback that externals sit near-but-wrong (shield inset
into the hull, thruster floating close):

- Scale: the joint table's slots 5–7 are per-axis scale (a GN_ShieldG frame ships
  at 0.5, GN_Jet FX at 2.4–6.5) and were being ignored, rendering scaled parts at
  the wrong size. ScenePart now carries `s` and applies R·(S·v)+T.
- External placement is fundamentally approximate: a GN_* frame is the mount
  PIVOT (f105's two GN_ShieldG frames are both on the centreline), while the
  part's outboard/rotational offset lives in a detail sub-rig / runtime code this
  static pass can't recover. So assemble_ship gains `include_external`: the hull
  tier (rou_ nodes) is exact and always drawn; the external tier (bridge / shield
  / engine at GN_ frames) is opt-in via a "Show external parts" checkbox in the
  Ships browser, off by default so the clean, correct hull is the default view.

The module doc is corrected to the scene-graph mechanism (the old "draw parts
untransformed" claim was wrong).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:11:47 +02:00
MechaCat02
8c8e2dbeb4 ship: use only the primary composite — fixes parts floating mid-ship
The sub-composites (`e_rou_<id>_eng`, `_wep_*`) are LOCAL detail rigs: their
nodes are posed in their own frame, not ship-space, so folding them in placed
the engines at ~mid-ship (Z≈99) instead of aft — the "floating in air" parts.

The PRIMARY composite (`e_rou_<id>`, the one with the most nodes) already carries
the full ship-space layout: every hull body AND every `GN_*` hardpoint frame
(GN_Bridge at the stern, GN_Engine aft, GN_ShieldG amidships…). Assemble from
that one composite only; engines/bridge/shield now resolve to their real aft/mid
frames. Robust to the odd single-composite case (e108's `e_rou_e108_Missile_open`
is still picked as its own primary).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:52:20 +02:00
MechaCat02
a8b5691d1c ship: assemble capital ships from the composite scene graph (real placement)
The first cut drew every part at the origin, overlapping into a mess — the parts'
vertex buffers are authored around the origin, NOT pre-placed. The real world
placement lives in the composite scene-graph resource `e_rou_<id>`, exactly like
node_transforms poses the DeltaSaber's fins within one resource — but here the
nodes reference SEPARATE geometry resources.

Two tiers, matching how the game splits a ship (as the user described):
- Hull — `rou_<id>_*` scene nodes name the hull body resources and carry their
  world transform (bodies that together form the hull, not individually
  destructible). Composed parent∘child down the first-child/next-sibling tree.
- External hardpoints — `GN_*` frame nodes are the Vessel mounts (`GN_Bridge_01`,
  `GN_ShieldG_01`, `GN_Engine_01`, matching the IDXD recipe Frame names). Each
  bridge / shield-generator / engine part is placed at its frame by category+idx —
  the individually-destructible parts.

- mesh: `scene_world_nodes` — walk a composite, emit every `rou_`/`GN_` node with
  its composed world transform (same record layout + TRS convention as
  node_transforms, cross-resource). ScenePart{resource,m,t}+apply.
- ship: `assemble_ship(bytes, id)` — resolve hull nodes to the ship's own parts
  (shared cross-id turrets excluded — they need the per-mount recipe), match
  external parts to GN frames, fall back to a raw draw when a prop has no
  composite. is_base_part now also drops `_dNN`/`_lNN`/`_mNN` LOD copies.
- viewer: build_ship_model runs assemble_ship, decodes only referenced resources,
  bakes each part's world pose into its vertices (one posed copy per placement),
  then assembles via the shared prepare path. RequestShipRender carries the id.

Verified headless: parts now spread into coherent single-ship extents
(e108 185×174×683, f106 703×479×2079, f101 carrier 560×539×1712) instead of
piling at the origin. Turret placement (shared eNNN gun models on many GN_*Gun*
frames) still pending the per-mount recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:44:25 +02:00
MechaCat02
720a5fec27 viewer: Ships browser (View ▸ Ships) — assemble & view whole capital ships
Adds a Ships window that scans the stage containers, reconstructs each capital
ship from its XBG7 part family (via sylpheed_formats::ship), and renders the
whole assembled model in the 3D view on click.

- iso_loader: ShipBrowser resource + ShipRow (id, faction, name, parts, stage,
  and linked Vessel stats: HP/size/turrets/bridges/shield gens), RequestShipCatalog
  + RequestShipRender events, ShipCatalogLoaded message. build_ship_catalog scans
  Stage_S01..S30, groups resources into ships, joins Vessel recipes by id, and
  sorts capital ships (Vessel-matched) first. handle_ship_render_request decodes
  only the ship's parts (models_named) and assembles them off-thread, reusing the
  existing XprPrepared → apply_prepared_xpr render + camera-focus path.
- prepare_models gains an `assemble` mode (prepare_ship): parts share ONE recentre
  so each keeps its ship-local offset (bridge fore, engines aft) instead of the
  per-model recentre / thumbnail grid used for a stage browse.
- ui: draw_ships_ui (own system, keeps draw_viewer_ui under the 16-param limit) —
  faction filter chips, name/id search, collapsible per-ship stat cards with an
  "Assemble & view in 3D" button; colour-coded by faction. View ▸ Ships menu item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:16:15 +02:00
MechaCat02
cacb576ecd formats: ship module — reconstruct capital ships from XBG7 part families
Project Sylpheed never stores a capital ship as a single mesh. Each ship is
modelled, then split into destructible parts (hull segments, bridge, engines,
integral turrets), each shipped as its own XBG7 resource inside a
hidden/resource3d/Stage_SNN.xpr container. The split lets the game hide/replace
a part when the player destroys it. Crucially each part keeps its ship-local
position, so reconstructing the whole ship is: draw every base part of one id
together, untransformed.

New `ship` module: Faction (from the e/f/n id prefix — ADAN/TCAF/Neutral),
ShipModel, ship_id_of, is_base_part (skips _l/_m/_d LODs, _bNN/_dead/_break
destruction geometry, e_rou_ scene nodes), and ships_in_container to group a
stage's resources into whole ships.

Supporting:
- mesh::xbg7_resource_names — list a container's XBG7 resource names (directory
  walk only, no geometry decode).
- Xbg7Model::models_named — decode only the named resources (assemble a ship
  from its ~8 parts instead of the whole ~300-resource stage).
- game_data::Vessel gains `model` (the rou_<id> hull stem) to link stats to the
  3D part family.

Tests: id/part classification + a disc-gated reconstruction of the ADAN e108
frigate from Stage_S02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:16:04 +02:00
MechaCat02
df724b6bcb viewer: Game Data browser (View ▸ Game Data)
Wire the decoded game_data + localization tables into the viewer as a
browsable window. View ▸ Game Data opens a floating panel with category
tabs — Weapons, Craft, Capital Ships, Characters, Missions, Arsenal,
Flights — decoded off-thread from GP_MAIN_GAME_E + GP_HANGAR_ARSENAL.

- Weapons/Craft/Ships: sortable stat grids (power/velocity/range;
  HP/cruise/accel/radar/turrets; HP/length/turrets/bridges/shield-gen),
  with a live name filter.
- Characters: name (localized) + faction (colour-coded) + portrait count.
- Missions: collapsible per stage — location, resolved objectives, fail
  conditions, and the enemy roster with HP.
- Arsenal: the player's nose/arm weapon options in four columns.
- Flights: distinct wingman line-ups.

Its own egui system (keeps draw_viewer_ui under Bevy's 16-param limit);
a RequestGameData event from the menu opens it and lazily decodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:44:40 +02:00
MechaCat02
d41b065813 game_data: Arsenal loader — player weapon options per hardpoint
Decode the player weapon arsenal from the UNITS tables
(GP_HANGAR_ARSENAL.pak): the selectable weapons per Delta Saber
hardpoint. Each `STANDARD_<hp>` header (Nose / Arm1-3) lists its options
up to the next header — extracted by shape, unioned + deduped across
configs.

The arsenal reads with the game's own names: NOSE guns (Stiletto,
Rapier, Broad Sword, Machine Cannon), ARM missiles/bombs (Falcon, Eagle,
White Shark, Piranha, Tomahawk Rail Gun, Maelstrom Bomb). Example
`arsenal` prints them. +1 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:30:13 +02:00
MechaCat02
f2991b61ab game_data: load_weapons catches variant-schema weapons
The player's Delta Saber missile / special weapons (Weapon_DSaber_P_wep_*)
live in variant schemas (439b1f29, 28295901), not the main WEAPON schema,
so the schema-only filter missed 10 of them. Match all `Weapon`-shaped
records by table name instead (excluding the EnumWeapon name lists),
deduped by id: 121 → 131 weapons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:26:35 +02:00
MechaCat02
f5182f9649 game_data: PilotRoster loader — player squadron flight assignments
Decode the UNITS/ZUNITS player-unit tables (GP_HANGAR_ARSENAL.pak) into
flight assignments: which pilot flies each callsign (Rhino1→Katana,
Bird1→Sandra, …), plus the player craft. Assignments are `Callsign<N>-
Pilot` tokens, extracted by shape.

138 configs; the distinct line-ups trace the campaign's story — Raymond
leads Rhino flight early (Rhino1=Raymond, Rhino2=Katana), then Katana
takes the lead (Rhino1=Katana, Rhino2=Ellen, Rhino3=Gene, Rhino4=Yoji).
Example `flights` prints the line-ups. +1 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:14:22 +02:00
MechaCat02
551291f869 game_data: UnitRoster loader — per-mission battle compositions
Decode the EnumUnit tables (schema 35b8dc67) into mission rosters: the
distinct combatant `UN_*` ids for one battle, stage props (asteroids,
collision meshes) dropped. The tables aren't tagged with their stage on
disc, so `stage` is inferred from any `UN_S<NN>_…` prop id in the roster
and left None otherwise (honest — ~8 of 31 rosters self-identify).

Joined against load_units / load_vessels this gives each battle's
combatants with stats, e.g. S01 = ADAN Turret (100 HP), Attacker
(500 HP), Destroyer (10000 HP). Example `battle` prints them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:11:32 +02:00
MechaCat02
6e704291be game_data: DemoMessage loader — the dialogue backbone
Decode the message tables (schema b412e6d8) into dialogue lines: each
`Message_NNN` sub-record → a DemoMessage with the speaker, portrait
(face), voice-clip token, and caption page text-keys. Fields are found
by prefix (Character*/Face*/VOICE_*/id-prefixed pages), so the
positional / first-record-defines-schema layout doesn't matter.

10,263 lines parse; ~815 name an explicit speaker (the rest are system /
continuation lines — left unattributed rather than guessed). Paired with
localization::TextIndex the attributed ones read straight out — e.g.
KATANA [VOICE_RHIN_000] "Ellen, get back into formation!". The
voice-clip token ties each line to its audio bank, closing the loop with
the cutscene-voice work. +1 test; example `dialogue` prints
speaker + clip + resolved text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:59:14 +02:00
MechaCat02
2930770060 game_data: Squadron loader — mission flight groups
Decode the per-stage Enumerate_Squadrons tables (UnitGroup_S<NN>.tbl)
into flight groups: formation shape, AI behaviour, side, and the member
craft. Definitions are delimited by the FormationID key; members are the
first `count` UN_ craft after the header (anything past them belongs to
the next squadron or the stage roster — the naive "all UN_ in span"
over-collected). e.g. the player's Rhino flight = a 2-craft TCAF
squadron of Delta Sabers flying Formation_2_Rhino. Schema hash varies
per stage so we match on the table name. Squadron id is positional and
left None when the id list and definitions don't line up (honest, not
guessed). +1 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:54:48 +02:00
MechaCat02
d73601fe45 localization: resolve the game's UTF-16 text keys to display strings
Project Sylpheed stores its UI/story text in IXUD entries as UTF-16LE,
interleaved `text, key` (a prose string immediately followed by the
identifier that names it). TextIndex::build(pak) scans a language pak's
IXUD entries and indexes every pair — 8457 English keys from
GP_MAIN_GAME_E: objectives, hints, lose conditions, character names,
and cutscene dialogue.

This makes the whole game read in English: the 16-mission campaign
(Repel the surprise attack → Shoot down Margras → the Prometheus Driver
finale), each mission's briefing, and the named cast (Katana, Raymond,
Ellen, Crichton, …) all resolve. API: get / objectives / lose_conditions
/ hints / character_name. 3 tests.

Examples: campaign / dossier print the readable campaign + cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:49:42 +02:00
MechaCat02
99f2ef8871 game_data: typed loaders for the combat / character / mission tables
Decode Project Sylpheed's IDXD data model into plain, cloneable structs
(from GP_MAIN_GAME_E.pak; the D/F/I/J/S paks are localized dups):

- Weapon (121), CraftUnit (89, with the AI flight model in .fields),
  Vessel (23, structural component counts), PlayerConfig (24) — the
  full combat balance.
- Character (68 — faction + portrait set), Stage (29 missions:
  location, phases, and the per-stage resource tables it wires up).
- Generic Record / load_records(schema) reads any of the 105 IDXD
  schemas without a bespoke struct.

Each blob = one entity; token[0] names the table, fields are
value-before-key. Named Option<> fields for the common stats + a full
`fields` map; defaulted fields stay None (they're code-resident, not on
disc — honest blanks, never guessed). 8 tests against the real disc.

Examples: iso_map / deep_map census the ISO's formats and IDXD schemas;
dm_extract / dm_rows / dm_roster / mission_map dump the decoded data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:49:21 +02:00
MechaCat02
161359e151 Fix hokyu voice: resolve by subtitle demo-id, not ship category
The 13 manifest-unbound hokyu (resupply) cutscenes reuse 5 recordings
(VOICE_D_450..454). The category guess (ship LS/DS × source carrier/
tanker) was wrong for some. The real selector is the cutscene's subtitle
demo-id: 600→450, 601→451, 602→452, 603→453, 604→454 — derived from the
5 BOUND hokyu (each carries both a demo-id and a VOICETRACK), so it
self-validates. Fixes hokyu_LS_s11A/s15A (→451, were wrongly 450) and
hokyu_LS_s24A/s27A (→silent, no voice cue). User-confirmed all correct.

hokyu_voice_token() builds the demo→token map from the bound hokyu and
looks up the target movie's demo-id; replaces the category fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:48:56 +02:00
MechaCat02
053aa81953 Cutscene voice: resolve movies via continuous-stream cue regions
Cracked how the game maps a movie to its voice track and reimplemented it,
fixing wrong/short voices for RT and hokyu cutscenes.

## Mechanism (RE'd from a Canary file-I/O trace, user-confirmed by ear)

The movie voices are ONE continuous XMA stream, physically chunked into
`<lang>\Movie\VOICE_*.slb` (and `\etc\VOICE_D_*.slb`) sound.pak TOC entries
whose boundaries do NOT align with the cutscene cues. A single cue routinely
spans two `.slb` chunks, so a `.slb` does not necessarily hold the track its
name claims (they are off-by-one vs the cues). Each cue ends at an inline
trailer descriptor `(sound_id:u32be, 0x11, …)` whose id repeats at +0x800.

Resolution chain (all derivable on-disc, no guest-code RE):
  1. movie -> cue token         (ADVERTISE_MOVIE manifest VOICETRACK)
  2. cue token -> sound-id       (master sound registry, tables.pak
                                  a2c8c185=eng / b04238e0=jpn, schema 0x13cb84ba)
  3. sound-id -> [start,end)      scan the stream for trailer(id) and its
                                  predecessor; cue audio = the bytes between,
                                  read continuously across chunk boundaries.

Validated: decoded voice length matches each movie within ~0.4s, including
cross-boundary cues (ADV/RT01A/RT01B/RT01C/S00A/S01A + the 5 bound hokyu).

## Changes

- sylpheed-formats/src/movie_voice.rs (new): registry_voice_ids(),
  find_descriptor(), find_descriptor_before() (gap-tolerant predecessor).
  Unit-tested.
- viewer iso_loader.rs: resolve_movie_voice_region() + decode_voice_region()
  (continuous read via existing read_segment_range + to_xma_riffs). Voice/
  audio requests now region-first; a `\Movie\` token that fails region stays
  SILENT (never plays the wrong off-by-one chunk). Removed the old
  movie_is_demo_based suppress hack.
- Anchor tries subdirs Movie/etc/Voice so hokyu `\etc\VOICE_D_*` resolve too.
- Examples resolve_all/validate_cues/hokyu_final document + validate the pipeline.

## Status

- RT / S / ADV cutscene voices: CORRECT (user-confirmed).
- 5 manifest-bound hokyu (VOICE_D_450-454): CORRECT (user-confirmed).

## OPEN (handoff)

13 of 18 hokyu movies have NO manifest VOICETRACK; the game reuses the 5
recordings across stages via a code rule. `hokyu_fallback_token()` currently
guesses by category (ship LS/DS x source carrier-A/tanker-H: LS/A->450,
LS/H->453, DS/A->452, DS/H->454). USER REPORTS THIS IS SOMETIMES WRONG.
- LS/carrier has two takes (450 from s02A, 451 from s09A); the early-vs-late
  split is unknown — unbound LS/A currently all default to 450.
- Definitive fix = Canary `--phase_a_fileio_only` file.read trace of an unbound
  hokyu (e.g. hokyu_LS_s03A) to see which VOICE_D the game actually streams,
  then encode the real rule. Same method that cracked the RT mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:53:55 +02:00
MechaCat02
ed5c2f24c3 feat(formats): decode the movie manifest's full mission/phase structure
The movie manifest (tables.pak, schema 0x067025b9) is the game's authoritative mission -> phase -> cutscene table. Previously we only scraped VOICE_/SUBTITLE_/.wmv strings by prefix; now we decode the real two-array (slot-keys, then value-records) structure.

MovieEntry gains slot, kind (System/Intro/Phase/PhaseEnd/Supply), mission, phase, subtitle, and telop (the previously-ignored .prt on-screen text overlay). Valueless slots (stages with no resupply video) are recovered without decoding the binary node region, via the fully-derivable MS<NN><X> -> S<NN><X> anchors.

Backward compatible: movie/voice_token and resolve_voice_entry/voice_token/is_manifest are unchanged. Validated end-to-end on the real disc: 101 entries (104 slots - 3 gaps), typed System=6/Intro=27/Phase=32/PhaseEnd=18/Supply=18. examples/manifest_map.rs dumps the full map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:49:19 +02:00
MechaCat02
eee1607e1b feat(formats,viewer): standalone voice player covers all line categories
list_voice_clips now enumerates every spoken-line category so the standalone audio player covers them all — bound movie voices (\Movie\), in-mission radio (\Voice\, \etc\), and mission briefings (\Briefing\, whose BR<NN>_<MM> names lack "VOICE"); music (BGM_*) stays excluded.

Viewer voice-library fixes: invalidate the library when a new game source loads, and don't latch an empty result as "loaded" (sounds.tbl always has thousands of clips, so empty = a failed read) so re-opening the menu retries.

Voice decode reverted to the first sub-wave (base behaviour): a naive multi-sub-wave concat produced the wrong track for RT movies. The per-sub-wave splitter (to_xma_riffs) is kept + documented for the eventual real fix, which needs ground-truth playback instrumentation to resolve segments-vs-takes-vs-timecode placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:49:09 +02:00
273 changed files with 57832 additions and 255 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ Thumbs.db
# Trunk build output
dist/
__pycache__/

View File

@@ -0,0 +1,26 @@
# Capital-ship part placements — runtime-captured ground truth (see
# docs/re/ship-placement-runtime-capture.md and src/ship_capture.rs).
#
# Populate with:
# SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit
# then paste the emitted `ship …` block(s) below. The viewer prefers a ship's
# entry here over the static assembler when present.
#
# Per placement line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>
#
ship e106 ref=e106_bdy_04
e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667
# 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
e106_eng_01 0.8659965 -0.50002277 0 0.4829627 0.83651507 0.25885975 -0.12941553 -0.22417325 0.96592265 131.2386 -133.06625 -132.06075
e106_eng_02 1 0 0 0 1 0 0 0 1 0.0013684053 -49.088192 -139.47827
e106_wep_02_01 1 0 0 0 1 0 0 0 1 0.000040663195 49.126797 1009.06744

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,9 @@
use sylpheed_formats::{game_data as gd, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let a=gd::load_arsenal(&pak);
for (hp,list) in [("NOSE",&a.nose),("ARM1",&a.arm1),("ARM2",&a.arm2),("ARM3",&a.arm3)]{
println!("{hp} ({}): {:?}", list.len(), list.iter().take(8).collect::<Vec<_>>());
}
}

View File

@@ -0,0 +1,19 @@
use sylpheed_formats::{game_data as gd, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let units=gd::load_units(&pak); let vessels=gd::load_vessels(&pak);
let hp=|id:&str|->String{
units.iter().find(|u|u.id.as_deref()==Some(id)).and_then(|u|u.hp).map(|h|format!("{h:.0}hp"))
.or_else(||vessels.iter().find(|v|v.id.as_deref()==Some(id)).and_then(|v|v.hp).map(|h|format!("{h:.0}HP")))
.unwrap_or("·".into())
};
let rosters=gd::load_unit_rosters(&pak);
for r in rosters.iter().filter(|r|r.stage.is_some()).take(4){
println!("\n{}{} combatants:", r.stage.as_deref().unwrap(), r.units.len());
for u in r.units.iter().filter(|u|u.contains("ADAN")).take(6){
let short=u.trim_start_matches("UN_").split('_').skip(1).collect::<Vec<_>>().join("_");
println!(" {short:32} {}", hp(u));
}
}
}

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,123 @@
//! Does a resource have a CLEANER home in its container than the one we picked?
//!
//! After the pad-scoring fix, only two resources on the disc still decode to an
//! index run with degenerate triangles — `e201_bdy_03_m` (2 containers) and
//! `_rou_f402_dead` (9). Degeneracy says the run does not fit the pool, so either
//! the vertex block is wrong or the candidate list never offered the right one.
//! This walks every candidate vertex-run start for the resource's declaration and
//! scores each `(start, pad)` the way the anchor now does — degenerate triangles
//! first, then winding, plus coverage — so the answer is one of:
//! * a strictly cleaner candidate exists (the selection is at fault),
//! * several are equally clean (genuinely ambiguous), or
//! * nothing is clean (the block is not in the candidate list at all).
//!
//! Usage: better_home <container.xpr> <resource-name>
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts, Xbg7Model};
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let name = &a[2];
let Some((markers, stride)) = debug_resource_params(&bytes, name) else {
eprintln!("no such XBG7 resource: {name}");
std::process::exit(1);
};
let (vc, ic) = markers[0];
println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len());
// Where did the decoder put it?
let ours = Xbg7Model::stage_models(&bytes)
.into_iter()
.find(|m| m.name == *name)
.and_then(|m| m.meshes.first().and_then(|s| s.vbuf_offset));
println!("our anchor: {ours:?}");
let starts = debug_vertex_run_starts(&bytes, stride);
println!("{} candidate vertex-run starts for stride {stride}", starts.len());
// Score every (start, pad): degenerate triangles, winding against the stored
// normals, and whether the run covers the pool exactly.
let mut rows: Vec<(usize, f32, usize, usize, usize, bool)> = Vec::new(); // degen, wind, start, pad, max_idx, covered
for &vb in &starts {
for pad in 0..=3usize {
if vb < ic * 2 + pad {
continue;
}
let ib = vb - ic * 2 - pad;
if ib + ic * 2 > bytes.len() || vb + vc * stride > bytes.len() {
continue;
}
let idx: Vec<u32> = (0..ic).map(|k| be16(&bytes, ib + k * 2)).collect();
let max_idx = *idx.iter().max().unwrap_or(&0) as usize;
if max_idx >= vc {
continue; // out of range — not a candidate at all
}
let pos: Vec<[f32; 3]> = (0..vc)
.map(|v| {
let at = vb + v * stride;
[
f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()),
f32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()),
f32::from_be_bytes(bytes[at + 8..at + 12].try_into().unwrap()),
]
})
.collect();
if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) {
continue;
}
let mut degen = 0usize;
let (mut agree, mut counted) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize);
if x == y || y == z || x == z {
degen += 1;
continue;
}
// Winding needs normals; use the geometric centroid normal as a
// stand-in so this stays declaration-agnostic: a consistent mesh
// has all faces pointing away from the centroid on a convex-ish
// hull. Weak, so degeneracy leads the sort.
let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]];
let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let cx: [f32; 3] = {
let mut c = [0.0f32; 3];
for p in &pos {
for k in 0..3 {
c[k] += p[k] / pos.len() as f32;
}
}
c
};
let out = [pos[x][0] - cx[0], pos[x][1] - cx[1], pos[x][2] - cx[2]];
counted += 1;
if f[0] * out[0] + f[1] * out[1] + f[2] * out[2] > 0.0 {
agree += 1;
}
}
let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc));
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1)));
println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len());
for (d, w, vb, pad, mx, cov) in rows.iter().take(12) {
let mark = if Some(*vb) == ours { " <-- ours" } else { "" };
println!(
" vb 0x{vb:07X} pad {pad} degen {d:>4} wind {w:.3} max_idx {mx}/{} {}{mark}",
vc - 1,
if *cov { "covers" } else { "SHORT" }
);
}
let clean = rows.iter().filter(|r| r.0 == 0 && r.5).count();
println!("\n{clean} candidates are degenerate-free AND cover the pool exactly");
}

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,24 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text=TextIndex::build(&pak);
let mut stages=game_data::load_stages(&pak);
stages.retain(|s|s.id.starts_with('S') && s.id.len()==3 && s.id[1..].parse::<u32>().map(|n|n<=16).unwrap_or(false));
stages.sort_by(|a,b|a.id.cmp(&b.id));
println!("═══ CAMPAIGN (S01S16) ═══");
for s in &stages{
let obj=text.objectives(&s.id,1);
println!("\n{} · {}", s.id, s.location.as_deref().unwrap_or("?"));
for o in obj.iter().take(2){ println!("{o}"); }
}
// roster with real names
let mut chars=game_data::load_characters(&pak);
chars.retain(|c|c.faction.as_deref()==Some("TCAF") && c.unique==Some(true) && c.faces.len()>=4);
println!("\n═══ PRINCIPAL CAST (TCAF, named) ═══");
for c in &chars{
let id=c.id.as_deref().unwrap_or("");
let name=text.character_name(id.trim_start_matches("Character")).or_else(||c.name_key.as_deref().and_then(|k|text.get(k))).unwrap_or("?");
println!(" {name:12} ({} portraits) [{}]", c.faces.len(), id.trim_start_matches("Character"));
}
}

View File

@@ -0,0 +1,222 @@
//! Where does a drawn block's INDEX buffer really live?
//!
//! Our XBG7 anchor scan only ever *assumes* the layout `[index buffer][vertex
//! buffer]` with a pad of at most 3 bytes between them (`anchor_pool_mesh`:
//! `ib = vb - idx_count*2 - pad`). Nothing on disc states it, and it is the gate
//! that rejected the capture-proven `e106_eng_02_l` block
//! (docs/re/captures/…): the block's index data was not where the decoder
//! looked. The F10 ship capture was extended on 2026-08-13 to log each draw's
//! index-buffer base, count and min/max index value, so the assumption is now
//! directly checkable:
//!
//! * `vbase - ibase` is the real gap in guest memory, and a stage container is
//! uploaded contiguously (see `shared_vbase_check`), so the same difference
//! holds in the file;
//! * `max index vs vcount` says whether a draw covers its whole vertex pool —
//! the "buffer not covered" miss class is a *sub-range draw* if it does not.
//!
//! Usage:
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, 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();
if a.len() < 3 {
eprintln!("usage: capture_ib_truth <container.xpr> <capture.log>...");
std::process::exit(2);
}
let bytes = std::fs::read(&a[1]).expect("container");
// One entry per (log, vbase): the capture already de-dups per placement, and
// a buffer drawn at several transforms has the same index buffer each time.
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");
for d in parse_capture(&text) {
// One entry per (log, vbase, index range): the engine issues SEVERAL
// draws over one vertex buffer, each with its own index sub-range, and
// it is their UNION that describes the block. (Captures taken before
// 2026-08-13 de-dup by (vbase, transform) and so hold only the first
// batch — such a log reads as a mysteriously short draw.)
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.is_some() && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
draws.push(d);
}
}
}
eprintln!("{} drawn buffers with an index buffer", draws.len());
// ── Place the drawn buffers in the file: POSITION is f32×3 big-endian at
// vertex offset 0, so the dumped positions are a literal byte pattern.
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, usize> = HashMap::new();
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new(); // (delta, file offset, draw)
for d in &draws {
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
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 {
let delta = d.vbase as i64 - off as i64;
*deltas.entry(delta).or_default() += 1;
hits.push((delta, off as usize, d));
break;
}
}
}
}
}
}
}
let Some((&base_delta, &n)) = deltas.iter().max_by_key(|(_, n)| **n) else {
eprintln!("no draw could be placed in this container");
std::process::exit(1);
};
println!("container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)");
// ── Our decoder's view of the same container.
let models = Xbg7Model::stage_models(&bytes);
let mut by_off: HashMap<usize, Vec<(String, usize, usize)>> = HashMap::new();
for m in &models {
for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset {
by_off
.entry(off)
.or_default()
.push((m.name.clone(), sm.positions.len(), sm.indices.len()));
}
}
}
println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.len());
// Declared-but-not-decoded resources, indexed by their first marker's
// (vertex, index) counts. A drawn buffer our decoder cannot name is the one
// thing a capture can give the residual misses: ground truth for where the
// block actually is. Matching on counts is enough to propose an identity —
// then `debug_try_anchor` at that offset says which gate rejects it.
let decoded_names: std::collections::HashSet<String> =
models.iter().map(|m| m.name.clone()).collect();
let mut undecoded_by_counts: HashMap<(usize, usize), Vec<String>> = HashMap::new();
for n in xbg7_resource_names(&bytes) {
if decoded_names.contains(&n) {
continue;
}
if let Some((markers, _)) = debug_resource_params(&bytes, &n) {
if let Some(&(v, i)) = markers.first() {
undecoded_by_counts.entry((v, i)).or_default().push(n);
}
}
}
// ── The report: one row per drawn BUFFER, aggregating its index batches.
let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
HashMap::new();
for (delta, voff, d) in &hits {
if *delta != base_delta {
continue;
}
let e = per_buf.entry(d.vbase).or_insert((*voff, Vec::new(), d.vcount));
let ib = d.ib.unwrap();
if !e.1.contains(&ib) {
e.1.push(ib);
}
}
let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize);
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = (0usize, 0usize, 0usize, 0usize);
let mut rows: Vec<(usize, String)> = Vec::new();
for (_, (voff, ibs, vcount)) in per_buf.iter() {
let batches = ibs.len();
let total: u32 = ibs.iter().map(|i| i.icount).sum();
let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta;
let hi = ibs.iter().map(|i| i.ibase + i.icount * 2).max().unwrap() as i64 - base_delta;
let umax = ibs.iter().map(|i| i.imax).max().unwrap();
let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer
let names = by_off.get(voff);
let dec_idx = names
.and_then(|v| v.iter().find(|(_, p, _)| *p as u32 == *vcount).map(|(_, _, i)| *i as u32));
// The decoder's assumption, scored: it expects the whole index buffer at
// `vb - 2*idx_count - pad`, pad ≤ 3.
let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo);
match dec_pad {
Some(0) => pad0 += 1,
Some(p) if (1..=3).contains(&p) => pad_small += 1,
Some(_) => pad_off += 1,
None => unnamed += 1,
}
if umax + 1 == *vcount {
cover_exact += 1;
} else {
cover_short += 1;
}
match dec_idx {
Some(i) if i == total => idx_equal += 1,
Some(_) => idx_partial += 1,
None => {}
}
rows.push((
*voff,
format!(
"vb 0x{:07X} v={:<6} batches {:<3} idx {:<6} span {:<7} gap {:<8} decpad {:<7} cover {:<10} {}",
voff,
vcount,
batches,
total,
hi - lo,
gap,
dec_pad.map(|p| p.to_string()).unwrap_or_else(|| "?".into()),
if umax + 1 == *vcount { "exact".to_string() } else { format!("{}/{}", umax, vcount - 1) },
names
.map(|v| v
.iter()
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
.collect::<Vec<_>>()
.join(" "))
.unwrap_or_else(|| {
// Nothing of ours sits here — is it a resource that never
// decodes? Propose it by (vertex, index) counts.
undecoded_by_counts
.get(&(*vcount as usize, total as usize))
.map(|v| format!("MISSED? {}", v.join(" ")))
.unwrap_or_else(|| "-".into())
})
),
));
}
rows.sort();
for (_, r) in &rows {
println!("{r}");
}
println!("\nplaced {} drawn buffers in this container", rows.len());
println!(
"DECODER layout assumption — whole index buffer at vb - 2*idx_count - pad: pad 0 {pad0} · pad 1..3 {pad_small} · elsewhere {pad_off} · not decoded here {unnamed}"
);
println!(
"index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}"
);
println!("vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}");
}

View File

@@ -0,0 +1,175 @@
//! Do our decoded triangle indices equal the ones the GPU actually read?
//!
//! `capture_ib_truth` established *where* a block's index buffer lives and that
//! its length matches ours. This asks the stronger question: are the index VALUES
//! the same, in the same order? The capture prints the first 24 indices of every
//! draw batch verbatim (`idx: …`), and a batch's `ibase` locates it inside the
//! block's index buffer — so for each drawn buffer we can line the captured run
//! up against `GameMesh::indices` at the right offset and compare element by
//! element. That tests the whole index path at once: the anchor, the u16
//! big-endian read, the triangle-list interpretation (a strip would disagree
//! immediately), and the sub-mesh carve.
//!
//! Usage:
//! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship_capture::{parse_capture, 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();
if a.len() < 3 {
eprintln!("usage: capture_index_bytes <container.xpr> <capture.log>...");
std::process::exit(2);
}
let bytes = std::fs::read(&a[1]).expect("container");
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");
for d in parse_capture(&text) {
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.map_or(false, |i| i.head_len > 0) && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
draws.push(d);
}
}
}
eprintln!("{} draw batches with an index head", draws.len());
// Place each drawn buffer in the file by its dumped positions (see
// capture_ib_truth for the method) and keep the modal load constant.
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, usize> = HashMap::new();
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new();
for d in &draws {
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
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() += 1;
hits.push((d.vbase as i64 - off as i64, off as usize, d));
break;
}
}
}
}
}
}
}
let Some((&base_delta, _)) = deltas.iter().max_by_key(|(_, n)| **n) else {
eprintln!("no draw could be placed in this container");
std::process::exit(1);
};
println!("load constant 0x{base_delta:X}");
// Our decode, indexed by vertex offset. A model may hold several sub-meshes;
// compare against the one whose vertex count matches the draw.
let models = Xbg7Model::stage_models(&bytes);
let mut by_off: HashMap<usize, Vec<(String, usize, Vec<u32>)>> = HashMap::new();
for m in &models {
for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset {
by_off
.entry(off)
.or_default()
.push((m.name.clone(), sm.positions.len(), sm.indices.clone()));
}
}
}
// Where does each buffer's index data START? Take it from the capture, not
// from an assumed pad: `capture_ib_truth` established that a buffer's batches
// tile its index buffer exactly (sum of counts == our idx_count, span ==
// 2*count), so the lowest `ibase` over a buffer's batches IS the block's
// index start. Assuming `vb - 2*len` instead is wrong for the buffers that
// sit at pad 2 and shifts the whole comparison by one element.
let mut ib_start: HashMap<u32, i64> = HashMap::new();
for (delta, _, d) in &hits {
if *delta != base_delta {
continue;
}
let ibase = d.ib.unwrap().ibase as i64;
ib_start.entry(d.vbase).and_modify(|e| *e = (*e).min(ibase)).or_insert(ibase);
}
let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize);
let mut bad: Vec<String> = Vec::new();
let mut checked_elems = 0usize;
let mut done: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for (delta, voff, d) in &hits {
if *delta != base_delta {
continue;
}
let ib = d.ib.unwrap();
if !done.insert((d.vbase, ib.ibase)) {
continue;
}
let Some(cands) = by_off.get(voff) else {
unmatched += 1;
continue;
};
let Some((name, _, ours)) = cands.iter().find(|(_, p, _)| *p as u32 == d.vcount) else {
unmatched += 1;
continue;
};
// Element 0 of our index list is the block's index start, as observed.
let ours_start = ib_start[&d.vbase] - base_delta;
let batch_off = ib.ibase as i64 - base_delta - ours_start;
if batch_off < 0 || batch_off % 2 != 0 {
nooverlap += 1;
continue;
}
let first = (batch_off / 2) as usize;
let n = (ib.head_len as usize).min(ours.len().saturating_sub(first));
if n == 0 {
nooverlap += 1;
continue;
}
let mism = (0..n).find(|&k| ours[first + k] != ib.head[k]);
checked_elems += n;
match mism {
None => agree += 1,
Some(k) => {
disagree += 1;
if bad.len() < 8 {
bad.push(format!(
"{name} vb 0x{:07X} batch@{first}: ours {:?} != captured {:?} (first differs at {k})",
voff,
&ours[first..first + n.min(12)],
&ib.head[..n.min(12)]
));
}
}
}
}
println!(
"index runs compared: {agree} identical · {disagree} differing · {unmatched} no decoded resource · {nooverlap} batch outside our buffer"
);
println!("{checked_elems} index elements checked against the GPU");
for b in &bad {
println!(" MISMATCH {b}");
}
}

View File

@@ -0,0 +1,43 @@
//! Identify which stage + ship a capture log came from: match the capture's
//! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr.
//! cargo run --release --example capture_match -- <capture.log> <resource3d dir>
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{BTreeMap, HashSet};
fn main() {
let a: Vec<String> = std::env::args().collect();
let text = std::fs::read_to_string(&a[1]).unwrap();
let mut draws = parse_capture(&text);
if draws.is_empty() { draws = parse_drawlog(&text); }
let caps: HashSet<u32> = draws.iter().map(|d| d.vcount).filter(|v| *v >= 100).collect();
eprintln!("{} draws, {} distinct vcounts>=100", draws.len(), caps.len());
let mut entries: Vec<_> = std::fs::read_dir(&a[2]).unwrap().filter_map(|e| e.ok())
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n.starts_with("Stage_") && n.ends_with(".xpr") })
.map(|e| e.path()).collect();
entries.sort();
for path in entries {
let bytes = std::fs::read(&path).unwrap();
let names = xbg7_resource_names(&bytes);
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
// resource -> vcount; group hits by ship-id prefix
let mut hits: BTreeMap<String, Vec<(String, u32)>> = BTreeMap::new();
for m in &models {
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
if vc >= 100 && caps.contains(&(vc as u32)) {
let id = m.name.get(..4).unwrap_or("?").to_string();
hits.entry(id).or_default().push((m.name.clone(), vc as u32));
}
}
let total: usize = hits.values().map(|v| v.len()).sum();
if total >= 3 {
println!("== {} : {} matching resources ==", path.file_name().unwrap().to_string_lossy(), total);
for (id, v) in &hits {
let s: Vec<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect();
println!(" {id}: {}", s.join(" "));
}
}
}
}

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,192 @@
//! Verify static ship assembly against multi-snapshot, multi-instance F10
//! captures: cluster every part's ship-relative transform across ALL ships in
//! ALL snapshots, and compare the dominant clusters with `assemble_ship`.
//!
//! cargo run --release --example capture_verify -- <stage.xpr> <ship_id> <log> [log…]
//!
//! Every draw is position-validated against the ship's parts (any LOD, set
//! based). Each `bdy_04`(-LOD) draw acts as a ship reference; every validated
//! part draw within ship range contributes `rel = WV_ref⁻¹ ∘ WV_part`. The true
//! per-part placements appear as tight clusters repeated once PER SHIP per
//! snapshot; cross-ship pairings scatter and stay below the cluster threshold.
use std::collections::HashSet;
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
type M3 = [[f64; 3]; 3];
fn transpose(m: &M3) -> M3 {
[
[m[0][0], m[1][0], m[2][0]],
[m[0][1], m[1][1], m[2][1]],
[m[0][2], m[1][2], m[2][2]],
]
}
fn mmul(a: &M3, b: &M3) -> M3 {
let mut o = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
o
}
fn mv(m: &M3, v: [f64; 3]) -> [f64; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let id = &a[2];
// Part position sets (base + LODs share the local frame) + per-variant vcounts.
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);
struct Part {
base: String,
vcounts: Vec<u32>,
pos: Vec<[f32; 3]>, // union of variants
}
let mut parts: Vec<Part> = Vec::new();
for p in &base_parts {
let mut vcounts = Vec::new();
let mut pos = Vec::new();
for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] {
if let Some(m) = models.iter().find(|m| m.name == cand) {
let mp: Vec<[f32; 3]> =
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect();
vcounts.push(mp.len() as u32);
pos.extend(mp);
}
}
parts.push(Part { base: p.clone(), vcounts, pos });
}
// Validate a draw against a part (direct or X-mirrored), like ship_capture.
let validate = |d: &CapturedDraw, p: &Part| -> Option<bool> {
if !p.vcounts.contains(&d.vcount) || d.pos.is_empty() {
return None;
}
let near = |a: &[f32; 3], b: &[f32; 3]| {
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
};
let (mut direct, mut mirror) = (0usize, 0usize);
for q in &d.pos {
if p.pos.iter().any(|v| near(q, v)) {
direct += 1;
}
let qm = [-q[0], q[1], q[2]];
if p.pos.iter().any(|v| near(&qm, v)) {
mirror += 1;
}
}
let need = d.pos.len().div_ceil(2);
if direct >= need && direct >= mirror {
Some(false)
} else if mirror >= need {
Some(true)
} else {
None
}
};
// Collect rel samples per part across all logs.
let mut samples: std::collections::BTreeMap<String, Vec<[f64; 3]>> = Default::default();
for log in &a[3..] {
let text = std::fs::read_to_string(log).unwrap();
let draws = parse_capture(&text);
// label validated draws
let mut labeled: Vec<(usize, usize, bool)> = Vec::new(); // (draw, part, mirrored)
for (di, d) in draws.iter().enumerate() {
for (pi, p) in parts.iter().enumerate() {
if let Some(mir) = validate(d, p) {
labeled.push((di, pi, mir));
break;
}
}
}
let refs: Vec<usize> = labeled
.iter()
.filter(|(_, pi, _)| parts[*pi].base.ends_with("bdy_04"))
.map(|(di, ..)| *di)
.collect();
for &ri in &refs {
let rf = &draws[ri];
let rt = transpose(&rf.r);
for &(di, pi, _mir) in &labeled {
let d = &draws[di];
let dt = [d.t[0] - rf.t[0], d.t[1] - rf.t[1], d.t[2] - rf.t[2]];
let rel = mv(&rt, dt);
if rel.iter().map(|v| v * v).sum::<f64>().sqrt() < 2600.0 {
// also require the rel rotation be finite-sane
let _rm = mmul(&rt, &d.r);
samples.entry(parts[pi].base.clone()).or_default().push(rel);
}
}
}
eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len());
}
// Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples.
println!("\n== dominant ship-relative placements (clusters ≥3 samples) ==");
for (part, mut v) in samples {
v.sort_by(|x, y| x[0].partial_cmp(&y[0]).unwrap());
let mut clusters: Vec<([f64; 3], usize)> = Vec::new();
for s in &v {
match clusters.iter_mut().find(|(c, _)| {
(c[0] - s[0]).abs() < 8.0 && (c[1] - s[1]).abs() < 8.0 && (c[2] - s[2]).abs() < 8.0
}) {
Some((c, n)) => {
for k in 0..3 {
c[k] = (c[k] * *n as f64 + s[k]) / (*n as f64 + 1.0);
}
*n += 1;
}
None => clusters.push((*s, 1)),
}
}
clusters.sort_by(|x, y| y.1.cmp(&x.1));
let tops: Vec<String> = clusters
.iter()
.filter(|(_, n)| *n >= 3)
.take(6)
.map(|(c, n)| format!("[{:7.1} {:7.1} {:7.1}]×{n}", c[0], c[1], c[2]))
.collect();
println!(" {part:20} {}", tops.join(" "));
}
// Static assembly rel bdy_04 for comparison.
println!("\n== static assemble_ship rel bdy_04 ==");
let placed = assemble_ship(&bytes, id, true);
if let Some(r4) = placed.iter().find(|p| p.resource == format!("{id}_bdy_04")) {
for p in &placed {
println!(
" {:20} [{:7.1} {:7.1} {:7.1}]",
p.resource,
p.t[0] - r4.t[0],
p.t[1] - r4.t[1],
p.t[2] - r4.t[2]
);
}
}
}

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

@@ -0,0 +1,136 @@
//! Recover exact capital-ship part placement from a Canary capture log
//! and (optionally) emit a checked-in placement-table block.
//!
//! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example
//! is the CLI wrapper: it reads the log, decodes the ship's parts from the disc to
//! get their vertex counts + leading positions (the match keys), correlates, and
//! prints the result. With `--emit` it prints the `ship …` block to paste into
//! `crates/sylpheed-formats/data/ship_placements.txt`.
//!
//! Matching is **LOD-aware**: a ship on screen at distance is drawn with its
//! `_m`/`_l` LOD copies, which live in the same local frame as the base part — so
//! each base part tries its own vcount first, then its LOD variants', and the
//! recovered transform is recorded under the base name. Same-vcount twins
//! (mirrored port/starboard hulls) are routed by the draw's position dump.
//!
//! 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
//! Deception (USA, Europe) (En,Ja).iso" \
//! cargo run --release --example correlate_capture -- \
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit
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, serialize_table, PartKey,
};
use sylpheed_formats::xiso::open_iso;
use std::collections::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 emit = args.iter().any(|a| a == "--emit");
if positional.len() < 3 {
eprintln!(
"usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]"
);
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_04");
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
// Accept either the F10 ship-capture format or the draw-logger format;
// auto-detect by trying the F10 parser first.
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);
eprintln!("parsed {} draws (draw-logger format)", draws.len());
} else {
eprintln!("parsed {} draws (F10 capture format)", draws.len());
}
// Decode the ship's base parts AND their LOD copies (vcount + leading
// positions are the match keys; LODs share the base part's local frame).
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();
// Candidate resources per base part: itself + `_m`/`_l`/`_d` LOD copies.
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
for p in &base_parts {
for suf in ["_m", "_l", "_d"] {
let cand = format!("{p}{suf}");
if names.contains(&cand) {
want.insert(cand);
}
}
}
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())
};
// One PartKey per (part, variant-vcount present in the capture) — correlate
// takes the first that validates. Validation uses the UNION of the part's
// variant position sets: a draw's `vcount` can match one LOD while its
// buffer holds another variant's geometry (seen on the real e106: the
// 558-vcount draw carried the FULL 1633-vert bdy_04 buffer), and every
// variant is the same part in the same local frame.
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();
let mut any = false;
for cand in &variants {
if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32;
if draws.iter().any(|d| d.vcount == vcount) {
let lod = if cand == part { "full" } else { cand.rsplit('_').next().unwrap_or("?") };
eprintln!(" {part:20} try vcount={vcount:6} [{lod}]");
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
any = true;
}
}
}
if !any {
eprintln!(" {part:20} — no draw matches any LOD (culled/off-screen?)");
}
}
let Some(ship) = correlate(id, &draws, &keys, ref_sub) else {
eprintln!("no parts matched a captured draw");
return;
};
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
for p in &ship.parts {
eprintln!(" {:18} T=[{:9.1}{:9.1}{:9.1}]", p.part, p.t[0], p.t[1], p.t[2]);
}
for part in &base_parts {
if !ship.parts.iter().any(|p| &p.part == part) {
eprintln!(" {part:18} — NOT placed (culled, or its only vcount hit failed position validation)");
}
}
if emit {
print!("{}", serialize_table(std::slice::from_ref(&ship)));
}
}

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,59 @@
use sylpheed_formats::{ratc, idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
// 1. RATC child-type census across the big RATC paks
let mut childtypes:BTreeMap<String,u64>=BTreeMap::new();
let mut ratc_unparsed=0u64;
for pk in ["GP_READY_ROOM","GP_MOVIE_THEATER","GP_DIALOG","GP_DEBRIEFING_PILOTLOG","GP_TITLE","GP_BUNK"]{
let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue};
if !ratc::is_ratc(&b){continue;}
match ratc::parse(&b){
Some(kids)=> for k in kids{
let ext=k.name.rsplit('.').next().unwrap_or("?").to_lowercase();
*childtypes.entry(ext).or_default()+=1;
},
None=>ratc_unparsed+=1,
}
}
}
println!("=== RATC child-type census (big RATC paks) ===");
let mut cv:Vec<_>=childtypes.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
for (e,c) in &cv{ println!(" .{e:8} ×{c}"); }
println!(" (RATC bundles that failed to parse: {ratc_unparsed})");
// 2. The 00000002 mystery format (GP_MAIN_GAME_E)
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue};
if b.len()>=4 && b[0..4]==[0,0,0,2]{
let hx:String=b[..48.min(b.len())].iter().map(|x|format!("{x:02x}")).collect::<Vec<_>>().join(" ");
let asc:String=b[..64.min(b.len())].iter().map(|&x|if(0x20..0x7f).contains(&x){x as char}else{'.'}).collect();
println!("\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",b.len());
break;
}
}
// 3. Sample the top undecoded IDXD schemas: first tokens (guess semantics)
println!("\n=== top undecoded IDXD schemas — sample tokens (semantic hints) ===");
let targets:[u32;6]=[0xb412e6d8,0x026379ab,0x43faa517,0x3c5b0549,0x6ab4825a,0x0426e81d];
for want in targets{
let mut shown=false;
for e in arc.entries(){
if shown{break;}
let Ok(b)=arc.read(e) else{continue};
if b.len()<12 || &b[0..4]!=b"IDXD"{continue;}
let s=u32::from_be_bytes([b[8],b[9],b[10],b[11]]);
if s!=want{continue;}
if let Ok(o)=IdxdObject::parse(&b){
let t=o.tokens();
let sample:Vec<String>=t.iter().take(14).map(|s|{let s=s.chars().take(18).collect::<String>();s}).collect();
println!(" {want:08x}: {sample:?}");
shown=true;
}
}
if !shown{ println!(" {want:08x}: (parse failed / not found)"); }
}
}

View File

@@ -0,0 +1,50 @@
//! Scratch analysis: for one IDXD key, list every object that DECLARES it and
//! whether it carries a value on disc or is left at the title-code default.
//!
//! Run: cargo run -p sylpheed-formats --example default_owners -- <KEY> [KEY...]
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn is_number(s: &str) -> bool {
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
}
fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
}
fn is_value(s: &str) -> bool {
is_number(s) || !is_key(s)
}
fn main() {
let root = std::env::var("SYLPHEED_DISC")
.unwrap_or_else(|_| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
let wanted: Vec<String> = std::env::args().skip(1).collect();
let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let toks = obj.tokens().to_vec();
let mut hits: Vec<String> = vec![];
for (i, t) in toks.iter().enumerate() {
if !wanted.iter().any(|w| w == t) {
continue;
}
let valued = i > 0 && is_value(&toks[i - 1]);
hits.push(if valued {
format!("{t}={}", toks[i - 1])
} else {
format!("{t}=<DEFAULT>")
});
}
if !hits.is_empty() {
println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
}
}
}

View File

@@ -0,0 +1,135 @@
//! Scratch analysis: which IDXD fields are DECLARED but left at their default on
//! disc, per schema. Those defaults live in title code, so they can only be read
//! from the running game — this prints the shopping list for that dynamic capture.
//!
//! Run: cargo run -p sylpheed-formats --example defaulted_fields -- <disc-root>
use std::collections::{BTreeMap, BTreeSet};
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn is_number(s: &str) -> bool {
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
}
/// Same shape-test the parser uses: an identifier-looking token that is not a
/// value literal is a field-name key.
fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
}
fn is_value(s: &str) -> bool {
is_number(s) || !is_key(s)
}
fn main() {
let root = std::env::args()
.nth(1)
.unwrap_or_else(|| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
let paks: Vec<String> = std::env::args().skip(2).collect();
let paks = if paks.is_empty() {
vec![
"dat/GP_MAIN_GAME_E.pak".to_string(),
"dat/GP_HANGAR_ARSENAL.pak".to_string(),
]
} else {
paks
};
for pak_rel in &paks {
let path = std::path::Path::new(&root).join(pak_rel);
let Ok(arc) = PakArchive::open(&path) else {
eprintln!("-- skip {pak_rel} (open failed)");
continue;
};
println!("\n================ {pak_rel} ================");
// schema -> key -> (n_set, n_defaulted, distinct values, example owners)
type Stat = (usize, usize, BTreeSet<String>, Vec<String>);
let mut per_schema: BTreeMap<u32, (usize, BTreeMap<String, Stat>)> = BTreeMap::new();
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else {
continue;
};
let ident = obj.identity();
let toks = obj.tokens().to_vec();
let entry = per_schema.entry(obj.schema_hash).or_default();
entry.0 += 1;
// Track which keys this object declares, and whether each is valued.
let mut seen_here: BTreeMap<String, Option<String>> = BTreeMap::new();
for (i, t) in toks.iter().enumerate() {
if !is_key(t) {
continue;
}
let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
let valued = prev.map(|p| is_value(p)).unwrap_or(false);
let v = if valued { Some(toks[i - 1].clone()) } else { None };
seen_here.entry(t.clone()).or_insert(v);
}
for (k, v) in seen_here {
let s = entry.1.entry(k).or_default();
match v {
Some(val) => {
s.0 += 1;
if s.2.len() < 12 {
s.2.insert(val);
}
}
None => {
s.1 += 1;
if s.3.len() < 6 {
s.3.push(ident.clone());
}
}
}
}
}
for (schema, (n_obj, keys)) in per_schema {
if n_obj < 2 {
continue;
}
let name = match schema {
0x0426_e81d => "PLAYER",
0x6ab4_825a => "WEAPON",
0x43fa_a517 => "UNIT",
0x3c5b_0549 => "VESSEL",
0xbd86_d41c => "CHARACTER",
0x3c9a_e32e => "STAGE",
0xb412_e6d8 => "MESSAGE",
_ => "?",
};
let defaulted: Vec<_> = keys
.iter()
.filter(|(_, s)| s.1 > 0)
.collect();
if defaulted.is_empty() {
continue;
}
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
println!(
"{:<30} {:>5} {:>5} {}",
"KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
);
for (k, (n_set, n_def, vals, owners)) in defaulted {
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
println!(
"{:<30} {:>5} {:>5} {} | {}",
k,
n_set,
n_def,
vv.join(","),
owners.join(",")
);
}
}
}
}

View File

@@ -0,0 +1,71 @@
//! Dump an XBG7 resource's descriptor around each index marker, to see whether a
//! grouped pool carries a declaration PER sub-mesh (the 2026-08-13 mixed-stride
//! finding says it must: n201's four sub-meshes use strides 24/24/24/28 and four
//! different vertex shaders).
//! Usage: desc_dump <container.xpr> <resource>
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let want = &a[2];
// Walk the XPR2 directory by hand (the crate's reader is private).
// 16-byte directory entries at 0x10: type tag, data offset, descriptor size,
// name offset — all relative to 0x10 (see texture::Xpr2ResourceEntry).
let num = be32(&bytes, 12) as usize;
for i in 0..num {
let e = &bytes[0x10 + i * 16..0x10 + i * 16 + 16];
let tag = &e[0..4];
let data_off = be32(e, 4) as usize + 0x10;
let desc_size = be32(e, 8) as usize;
let name_off = be32(e, 12) as usize + 0x10;
if tag != b"XBG7" {
continue;
}
let name = {
let s = name_off.min(bytes.len());
let end = bytes[s..].iter().position(|&c| c == 0).unwrap_or(0) + s;
String::from_utf8_lossy(&bytes[s..end]).to_string()
};
if name != *want {
continue;
}
let desc = &bytes[data_off..(data_off + desc_size).min(bytes.len())];
println!("{name}: descriptor at 0x{data_off:X}, {} bytes", desc.len());
// markers: a == c*2, c%3==0, vertex count 32 bytes earlier
let mut rel = 0usize;
while rel + 8 <= desc.len() {
let (x, c) = (be32(desc, rel), be32(desc, rel + 4));
if c >= 3 && c % 3 == 0 && c < 400_000 && x == c * 2 && rel >= 32 {
let vc = be32(desc, rel - 32);
if (3..=200_000).contains(&vc) {
println!("\n marker @0x{rel:X}: {vc} verts / {c} indices — declaration triples after it:");
let mut r = rel + 8;
let mut stride = 0u32;
for _ in 0..12 {
if r + 12 > desc.len() {
break;
}
let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 {
println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}");
break;
}
println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF);
stride = stride.max(o + 4);
r += 12;
}
println!(" (min stride from element offsets: {stride})");
rel += 8;
continue;
}
}
rel += 4;
}
return;
}
eprintln!("resource not found");
}

View File

@@ -0,0 +1,13 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text=TextIndex::build(&pak);
let msgs=game_data::load_demo_messages(&pak);
println!("{} dialogue lines total\n", msgs.len());
for m in msgs.iter().filter(|m|m.character.is_some()&&!m.page_keys.is_empty()).take(8){
let who=m.character.as_deref().unwrap_or("?").trim_start_matches("Character");
let line:String=m.page_keys.iter().filter_map(|k|text.get(k)).collect::<Vec<_>>().join(" ");
println!(" {who:10} [{}] “{}", m.voice_clip.as_deref().unwrap_or("-"), line.chars().take(64).collect::<String>());
}
}

View File

@@ -0,0 +1,29 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let tables:[(u32,&str);4]=[(0x0426e81d,"Player / physics+scoring"),(0x6ab4825a,"Weapon"),(0x43faa517,"Unit / craft"),(0x3c5b0549,"Vessel / capital ship")];
for (want,label) in tables{
// collect all records of this schema
let mut recs:Vec<IdxdObject>=vec![];
for e in arc.entries(){ let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; if o.schema_hash==want{recs.push(o);} }
// field-union (explicit-valued keys), with occurrence count
let mut cols:BTreeMap<String,u32>=BTreeMap::new();
for o in &recs{ for (k,_) in o.resolved_fields(){ *cols.entry(k.into()).or_default()+=1; } }
println!("\n╔══ {label} [{want:08x}] {} records ══", recs.len());
let mut cv:Vec<_>=cols.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
let colstr:String=cv.iter().take(28).map(|(k,c)|format!("{k}({c})")).collect::<Vec<_>>().join(" ");
println!("║ numeric/enum fields: {colstr}");
// dump 2 sample records: identity + explicit fields
for o in recs.iter().take(2){
let id=o.get_raw("ID").unwrap_or("?");
let name=o.get_raw("Name").unwrap_or("");
print!("║ • {id}");
if !name.is_empty(){print!(" «{name}»");}
println!();
let fields:Vec<String>=o.resolved_fields().iter().map(|(k,v)|format!("{k}={v}")).collect();
for chunk in fields.chunks(5){ println!("{}", chunk.join(" ")); }
}
}
}

View File

@@ -0,0 +1,11 @@
use sylpheed_formats::{game_data, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let chars=game_data::load_characters(&pak);
let mut byfac:BTreeMap<String,Vec<String>>=Default::default();
for c in &chars{ byfac.entry(c.faction.clone().unwrap_or("·".into())).or_default().push(format!("{}({})",c.id.clone().unwrap_or_default().trim_start_matches("Character"),c.faces.len())); }
println!("{} characters across {} factions:",chars.len(),byfac.len());
for (f,mut v) in byfac{ v.sort(); println!(" [{f}] {}", v.join(" ")); }
}

View File

@@ -0,0 +1,14 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn short(s:&str)->String{ s.trim_start_matches("Weapon_").trim_start_matches("UN_").trim_start_matches("UnitName_UN_").into() }
fn g<'a>(o:&'a IdxdObject,k:&str)->String{ o.get_f32(k).map(|v|{if v==v.trunc(){format!("{}",v as i64)}else{format!("{v}")}}).or_else(||o.get_raw(k).filter(|x|x.chars().next().map(|c|c.is_ascii_digit()).unwrap_or(false)).map(|s|s.to_string())).unwrap_or("·".into()) }
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let recs=|want:u32|->Vec<IdxdObject>{ arc.entries().iter().filter_map(|e|arc.read(e).ok()).filter_map(|b|IdxdObject::parse(&b).ok()).filter(|o|o.schema_hash==want).collect() };
println!("### WEAPONS (name | target | load | int | power | vel | range | trig)");
for o in recs(0x6ab4825a).iter().take(16){ println!("{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), o.get_raw("TargetType").unwrap_or("·"), g(o,"LoadingCount"),g(o,"Interval"),g(o,"Power"),g(o,"Velocity"),g(o,"MaximumRange"),g(o,"TriggerShotCount")); }
println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)");
for o in recs(0x43faa517).iter().take(16){ println!("{:<38}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"CruisingVelocity"),g(o,"Acceleration"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"ScorePoint")); }
println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)");
for o in recs(0x3c5b0549).iter(){ println!("{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"Size_X"),g(o,"Size_Z"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"BridgeCount"),g(o,"HatchCount"),g(o,"ShieldGeneratorCount"),g(o,"ThrusterCount")); }
}

View File

@@ -0,0 +1,15 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text=TextIndex::build(&pak);
for n in 1..=16u32{
let sid=format!("S{n:02}");
// primary objective across phases (first that resolves)
let obj:Vec<String>=(1..=3).flat_map(|p|text.objectives(&sid,p)).map(|s|s.to_string()).collect();
let lose:Vec<String>=(1..=3).flat_map(|p|text.lose_conditions(&sid,p)).map(|s|s.to_string()).collect();
let full_obj=obj.join(" ");
let full_lose=lose.into_iter().take(2).collect::<Vec<_>>().join(" ");
println!("{sid}\t{full_obj}\t{full_lose}");
}
}

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,17 @@
use sylpheed_formats::{game_data as gd, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let rosters=gd::load_pilot_rosters(&pak);
// distinct rosters by their pilot set
let mut seen=std::collections::BTreeSet::new(); let mut shown=0;
println!("{} pilot-roster configs; distinct line-ups:", rosters.len());
for r in &rosters{
let key:String=r.pilots.iter().map(|(c,p)|format!("{c}:{p}")).collect::<Vec<_>>().join(",");
if seen.insert(key) && shown<8 {
shown+=1;
let flt:Vec<String>=r.pilots.iter().map(|(c,p)|format!("{c}={p}")).collect();
println!(" {}", flt.join(" "));
}
}
}

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,2 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
for l in sylpheed_formats::mesh::debug_grouped_report(&b,&a[2],a[3].parse().unwrap()){println!("{l}")}}

View File

@@ -0,0 +1,43 @@
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_subtitle as ms, movie_voice, slb, PakArchive};
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn ct(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let man=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
let lpak=PakArchive::open(format!("{disc}/dat/movie/eng.pak")).unwrap();
let reg=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|ct(b,b"eng\\Movie\\VOICE_ADV.slb"))).unwrap();
let ids=movie_voice::registry_voice_ids(&reg);
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let ents=PakArchive::parse_toc(&stoc).unwrap();
// demo->token from bound hokyu
let hok:Vec<_>=movie_manifest::parse(&man).into_iter().filter(|m|m.movie.starts_with("hokyu_")).collect();
let resolve=|movie:&str|->Option<String>{
movie_manifest::voice_token(&man,movie).or_else(||{
let want=ms::track_voice_cues(&lpak,movie).first().map(|&(d,_)|d)?;
hok.iter().find_map(|e|{let t=e.voice_token.clone().filter(|_|e.movie.starts_with("hokyu_"))?; ms::track_voice_cues(&lpak,&e.movie).iter().any(|&(d,_)|d==want).then_some(t)})
})
};
for e in &hok{
let mv=&e.movie;
let bound=e.voice_token.is_some();
let tok=resolve(mv);
let demo=ms::track_voice_cues(&lpak,mv).first().map(|&(d,_)|d);
let mut d="".to_string();
if let Some(tok)=&tok{
if let Some(&id)=ids.get(tok){
if let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|dir|{let h=name_hash(&format!("eng\\{dir}\\{tok}.slb"));ents.binary_search_by_key(&h,|x|x.name_hash).ok().map(|i|ents[i].offset as u64)}){
let ws=(anchor.saturating_sub(2*1024*1024))&!3; let win=rg(&disc,ws,8*1024*1024);
if let Some(el)=movie_voice::find_descriptor(&win,id){let end=ws+el as u64;
let start=movie_voice::find_descriptor(&win,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&win,el)).map(|o|ws+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
let region=rg(&disc,start,(end-start)as usize); let mut rf=slb::to_xma_riffs(&region); if rf.is_empty(){rf=slb::to_xma_riff_best(&region).into_iter().collect();}
if let Some(r)=rf.first(){let xp=format!("/tmp/hd_{mv}.xma.wav");let wp=format!("/tmp/hd_{mv}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
}
}
}
}
println!("{mv:16} {:8} demo={:?} -> {:11} voice={d}s", if bound{"BOUND"}else{"unbound"}, demo, tok.unwrap_or("(silent)".into()));
}
}

View File

@@ -0,0 +1,36 @@
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, slb, PakArchive};
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn contains(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
fn hokyu_fallback(m:&str)->Option<String>{if !m.starts_with("hokyu_"){return None}let ls=m.contains("_LS_");let ds=m.contains("_DS_");let a=m.ends_with('A');let h=m.ends_with('H');Some(match(ls,ds,a,h){(true,_,true,_)=>"VOICE_D_450",(true,_,_,true)=>"VOICE_D_453",(_,true,true,_)=>"VOICE_D_452",(_,true,_,true)=>"VOICE_D_454",_=>return None}.into())}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
let code="eng";
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
let ids=movie_voice::registry_voice_ids(&registry);
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let entries=PakArchive::parse_toc(&stoc).unwrap();
let hokyu:Vec<String>=movie_manifest::parse(&manifest).into_iter().filter(|m|m.movie.starts_with("hokyu_")).map(|m|m.movie).collect();
for movie in &hokyu{
let bound=movie_manifest::voice_token(&manifest,movie);
let token=bound.clone().or_else(||hokyu_fallback(movie));
let Some(token)=token else{println!("{movie:16} NO TOKEN"); continue};
let src = if bound.is_some(){"bound"}else{"fallback"};
let Some(&id)=ids.get(&token) else{println!("{movie:16} {token} not in registry");continue};
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else{println!("{movie:16} no anchor");continue};
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
let window=rg(&disc,win_start,8*1024*1024);
let Some(el)=movie_voice::find_descriptor(&window,id) else{println!("{movie:16} desc not found");continue};
let end=win_start+el as u64;
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&window,el)).map(|o|win_start+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
let region=rg(&disc,start,(end-start)as usize);
let mut riffs=slb::to_xma_riffs(&region); if riffs.is_empty(){riffs=slb::to_xma_riff_best(&region).into_iter().collect();}
let mut d="?".into();
if let Some(r)=riffs.first(){let xp=format!("/tmp/hf_{movie}.xma.wav");let wp=format!("/tmp/hf_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
println!("{movie:16} {src:8} {token:12} id={id} voice={d}s movie={mv}s");
}
}

View File

@@ -0,0 +1,108 @@
//! RE diagnostic: emit every `weapon\Weapon_*.tbl` in a pak as machine-readable
//! records, split at the sub-object boundaries the schema declares.
//!
//! An IDXD `.tbl` for a weapon holds `count` sub-records — a `Weapon` and its
//! `Shell` — flattened into one string pool. `sylpheed-cli pak dump` shows them
//! merged, which is ambiguous (both declare `ID`/`Name`). The title's schema
//! name pool gives the split point: the pool contains the literal type names
//! (`Weapon`, `Shell`), and each sub-record's fields follow its type name.
//!
//! Here we split on a type-name token appearing as a *key* position, so each
//! record is emitted with its own ID and field set:
//!
//! ```text
//! REC <tbl-hash> <index> <TypeName> <ID>
//! F <key> <value>
//! ```
//!
//! Run: cargo run --release -p sylpheed-formats --example idxd_tokens -- <pak> [types...]
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn is_number(s: &str) -> bool {
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
}
/// `Yes`/`No`-style scalar literals are values, not field names, even though
/// they are identifier-shaped.
fn is_enum_value(s: &str) -> bool {
matches!(s, "Yes" | "No" | "YES" | "NO" | "On" | "Off" | "ON" | "OFF")
}
fn is_key_like(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
&& !is_enum_value(s)
}
fn main() {
let mut args = std::env::args().skip(1);
let pak_path = args.next().expect("usage: idxd_tokens <pak> [TypeName...]");
let types: Vec<String> = {
let v: Vec<String> = args.collect();
if v.is_empty() {
vec!["Weapon".into(), "Shell".into()]
} else {
v
}
};
let pak = PakArchive::open(&pak_path).expect("open pak");
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let toks = obj.tokens();
// Only entries that actually declare one of the requested sub-record
// types (the pool-start heuristic can prefix one stray byte, so match a
// short suffix rather than equality -- same rule as the splitter below).
if !toks
.iter()
.any(|t| types.iter().any(|ty| t == ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2)))
{
continue;
}
if std::env::var("SYLPH_RAW").is_ok() {
println!("RAW {:08x}", entry.name_hash);
for t in toks {
println!("T {t}");
}
}
// Sub-record boundaries: a bare type-name token. The first one is the
// schema's own declaration (`EnumWeapon`-style header lives in title
// code, not here), so we take every occurrence in order.
let mut bounds: Vec<(usize, &str)> = Vec::new();
for (i, t) in toks.iter().enumerate() {
// The pool-start heuristic can glue one stray binary byte onto the
// first token ("YWeapon"), so match a short suffix, not equality.
if let Some(ty) = types
.iter()
.find(|ty| t == *ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2))
{
bounds.push((i, ty.as_str()));
}
}
for (n, &(start, ty)) in bounds.iter().enumerate() {
let end = bounds.get(n + 1).map(|b| b.0).unwrap_or(toks.len());
let slice = &toks[start..end];
// The record's own ID value is the token straight after its type
// name (`Shell` -> `Shell_DSaber_P_wep_01_Beam`). The `ID`/`Name`
// *keys* are interned once in the pool, so only the first record
// shows them -- position-of-key lookup would miss the rest.
let id = slice.get(1).map(|s| s.as_str()).unwrap_or("?");
println!("REC {:08x} {n} {ty} {id}", entry.name_hash);
for i in 1..slice.len() {
let (k, v) = (slice[i].as_str(), slice[i - 1].as_str());
if is_key_like(k) && (!is_key_like(v) || is_number(v)) {
println!("F {k} {v}");
}
}
}
}
}

View File

@@ -0,0 +1,32 @@
//! Dump a fingerprint of every decoded index run, so two decoder settings can be
//! diffed exactly. Usage: index_hash_dump <resource3d_dir>
use sylpheed_formats::mesh::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();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let w = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::stage_models(&bytes) {
for (k, sm) in m.meshes.iter().enumerate() {
let mut h = 1469598103934665603u64;
for i in &sm.indices {
h = (h ^ *i as u64).wrapping_mul(1099511628211);
}
println!(
"{w} {} {k} vb={:?} n={} h={h:016x}",
m.name,
sm.vbuf_offset,
sm.indices.len()
);
}
}
}
}

View File

@@ -0,0 +1,134 @@
//! Is our index list one element late? A capture-free check of the `pad` choice.
//!
//! `anchor_pool_mesh` tries `ib = vb idx_count*2 pad` for `pad` in `0..=3`,
//! **pad 0 first and with the looser winding gate** (`XBG7_PAD0_CONSISTENCY`,
//! 0.70) than the pad ≥ 1 path (0.85). The 2026-08-13 index-byte comparison
//! against the runtime showed 17 draw batches whose captured indices equal our
//! decoded list shifted by exactly one element — all of them on buffers whose
//! real index data sits at pad 2. A one-element shift re-wires every triangle,
//! and it is invisible to every count-based metric (the count, the coverage and
//! the positions are all still right).
//!
//! This reproduces that finding from the container alone: for each resource it
//! reads the index run at pad 0 and at pad 2 and scores both on two properties a
//! correct triangle list has — **no degenerate triangles** (two equal indices)
//! and consistent winding against the stored normals.
//!
//! Usage: index_pad_check <container.xpr> [name-substring]
use sylpheed_formats::mesh::Xbg7Model;
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
/// Degenerate triangles (a repeated index) and the fraction of triangles whose
/// face normal agrees with their vertices' stored normals.
fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize) {
let mut degen = 0usize;
let mut agree = 0usize;
let mut counted = 0usize;
for t in idx.chunks_exact(3) {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c {
degen += 1;
continue;
}
if a >= pos.len() || b >= pos.len() || c >= pos.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
if nrm.len() <= a {
continue;
}
let n = nrm[a];
let dot = f[0] * n[0] + f[1] * n[1] + f[2] * n[2];
counted += 1;
if dot > 0.0 {
agree += 1;
}
}
let frac = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
(degen, frac.max(1.0 - frac), counted)
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let filter = a.get(2).cloned().unwrap_or_default();
let models = Xbg7Model::stage_models(&bytes);
println!(
"{:<22} {:>6} {:>6} {:<26} {:<26} verdict",
"resource", "verts", "idx", "pad 0 (what we decode)", "pad 2 (2 bytes earlier)"
);
let (mut better_p2, mut better_p0, mut tie) = (0usize, 0usize, 0usize);
let mut hist_ok: Vec<(usize, String, usize)> = Vec::new();
for m in &models {
if !filter.is_empty() && !m.name.contains(&filter) {
continue;
}
if m.meshes.len() != 1 {
continue; // single-block path only; grouped pools use another formula
}
let sm = &m.meshes[0];
let Some(vb) = sm.vbuf_offset else { continue };
let n = sm.indices.len();
if n < 6 || sm.normals.is_empty() || vb < n * 2 + 2 {
continue;
}
let read_at = |start: usize| -> Vec<u32> { (0..n).map(|k| be16(&bytes, start + k * 2)).collect() };
let l0 = read_at(vb - n * 2);
let l2 = read_at(vb - n * 2 - 2);
// Sanity: l0 must be what the decoder emitted, or the assumption that we
// took pad 0 is wrong for this resource and the comparison is meaningless.
let took_pad0 = l0 == sm.indices;
if !took_pad0 {
continue;
}
let (d0, c0, _) = score(&l0, &sm.positions, &sm.normals);
let (d2, c2, _) = score(&l2, &sm.positions, &sm.normals);
let max_i0 = l0.iter().max().copied().unwrap_or(0);
let max_i2 = l2.iter().max().copied().unwrap_or(0);
let v = sm.positions.len() as u32;
// A shifted list typically also overruns the pool by one vertex or leaves
// the last vertex unreferenced, so carry the coverage as evidence too.
let verdict = if d2 < d0 && c2 >= c0 {
better_p2 += 1;
"pad 2 is the real one"
} else if d0 < d2 || c0 > c2 {
better_p0 += 1;
"pad 0 fine"
} else {
tie += 1;
"indistinguishable"
};
if verdict == "pad 0 fine" {
hist_ok.push((d0, m.name.clone(), n));
}
if verdict != "pad 0 fine" || !filter.is_empty() {
println!(
"{:<22} {:>6} {:>6} degen {:>5} wind {:.3} max {:<5} degen {:>5} wind {:.3} max {:<5} {}",
m.name, v, n, d0, c0, max_i0, d2, c2, max_i2, verdict
);
}
}
println!("\npad 2 wins: {better_p2} · pad 0 wins: {better_p0} · indistinguishable: {tie}");
// Is "no degenerate triangle" a safe invariant for a correctly anchored
// block? Distribution over the resources where pad 0 is the better choice.
let zero = hist_ok.iter().filter(|(d, _, _)| *d == 0).count();
println!(
"of the {} pad-0-correct resources, {zero} have ZERO degenerate triangles",
hist_ok.len()
);
let mut worst: Vec<_> = hist_ok.iter().filter(|(d, _, _)| *d > 0).collect();
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
for (d, n, idx) in worst.iter().take(10) {
println!(" {n}: {d} degenerate of {} triangles", idx / 3);
}
}

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,50 @@
use sylpheed_formats::PakArchive;
use std::collections::BTreeMap;
fn be32(b:&[u8],o:usize)->u32{ if o+4<=b.len(){u32::from_be_bytes([b[o],b[o+1],b[o+2],b[o+3]])}else{0} }
fn magic(b:&[u8])->String{
if b.len()<4 {return "(<4B)".into();}
let m=&b[0..4];
if m.iter().all(|&c|(0x20..0x7f).contains(&c)){ String::from_utf8_lossy(m).into() }
else if m==b"\x89PNG"{"PNG".into()} else if m==[0,1,0,0]{"ttf".into()}
else { format!("{:02x}{:02x}{:02x}{:02x}",m[0],m[1],m[2],m[3]) }
}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
// Known/understood IDXD schemas (semantic parsers we have)
let known_schema:BTreeMap<u32,&str>=[(0x067025b9,"ADVERTISE_MOVIE (movie_manifest)"),(0x13cb84ba,"sound registry / sounds.tbl")].into();
let mut fmt_count:BTreeMap<String,(u64,u64)>=BTreeMap::new(); // fmt -> (count, bytes)
let mut schema_census:BTreeMap<u32,(u64,u64,String)>=BTreeMap::new(); // schema -> (count,bytes,sample pak)
let mut unknown_magics:BTreeMap<String,(u64,Vec<String>)>=BTreeMap::new();
let paks:Vec<String>={let mut v=vec![]; for e in std::fs::read_dir(format!("{disc}/dat")).unwrap(){let p=e.unwrap().path(); if p.extension().map(|x|x=="pak").unwrap_or(false){v.push(p.file_stem().unwrap().to_string_lossy().into());}} v.sort(); v};
println!("pak entries decompressed formats");
for pk in &paks{
let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
let mut per:BTreeMap<String,u64>=BTreeMap::new();
let mut tot=0u64; let n=arc.entries().len();
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue};
tot+=b.len() as u64;
let mut f=magic(&b);
if f=="IDXD"{ let s=be32(&b,8); schema_census.entry(s).or_insert((0,0,pk.clone())).0+=1; schema_census.get_mut(&s).unwrap().1+=b.len() as u64; f=format!("IDXD:{s:08x}"); }
else if !["XPR2","IXUD","LSTA","RATC","RIFF","XBG7","OTTO","PNG","ttf","DDS ","T8AD"].contains(&f.as_str()){
let ent=unknown_magics.entry(f.clone()).or_insert((0,vec![])); ent.0+=1; if ent.1.len()<3 && !ent.1.contains(pk){ent.1.push(pk.clone());}
}
*per.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f.clone()}).or_default()+=1;
let g=fmt_count.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f}).or_insert((0,0)); g.0+=1; g.1+=b.len() as u64;
}
let mut fs:Vec<_>=per.into_iter().collect(); fs.sort_by_key(|x|std::cmp::Reverse(x.1));
let top:String=fs.iter().take(4).map(|(k,v)|format!("{k}×{v}")).collect::<Vec<_>>().join(" ");
println!("{pk:26} {n:>7} {:>10}KB {top}", tot/1024);
}
println!("\n=== FORMAT TOTALS (across all paks) ===");
let mut fv:Vec<_>=fmt_count.into_iter().collect(); fv.sort_by_key(|x|std::cmp::Reverse(x.1.1));
for (f,(c,b)) in &fv{ println!(" {f:12} {c:>6} entries {:>8}KB", b/1024); }
println!("\n=== IDXD SCHEMA CENSUS ({} distinct schemas) ===", schema_census.len());
let mut sv:Vec<_>=schema_census.into_iter().collect(); sv.sort_by_key(|x|std::cmp::Reverse(x.1.1));
for (s,(c,b,pk)) in &sv{
let tag=known_schema.get(s).copied().unwrap_or("??? UNDECODED");
println!(" {s:08x} {c:>5} ent {:>7}KB e.g. {pk:22} {tag}", b/1024);
}
println!("\n=== UNKNOWN / UNCLASSIFIED MAGICS ===");
for (m,(c,pks)) in &unknown_magics{ println!(" {m:12} ×{c:<5} in {pks:?}"); }
}

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,46 @@
//! Validation: dump the parsed mission/phase cutscene map from the real manifest.
use sylpheed_formats::movie_manifest::{self, MovieKind};
use sylpheed_formats::pak::PakArchive;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC");
let arc = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let man = arc
.entries()
.iter()
.find_map(|e| arc.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let entries = movie_manifest::parse(&man);
let mut counts = [0usize; 5];
for e in &entries {
counts[match e.kind {
MovieKind::System => 0,
MovieKind::Intro => 1,
MovieKind::Phase => 2,
MovieKind::PhaseEnd => 3,
MovieKind::Supply => 4,
}] += 1;
let m = e.mission.map(|x| x.to_string()).unwrap_or_default();
let p = e.phase.map(|x| x.to_string()).unwrap_or_default();
println!(
"{:<22} {:<9} m={:<2} p={:<1} {:<16} {:<14} tel={:<14} sub={}",
e.slot,
format!("{:?}", e.kind),
m,
p,
e.movie,
e.voice_token.as_deref().unwrap_or("-"),
e.telop.as_deref().unwrap_or("-"),
e.subtitle.as_deref().unwrap_or("-"),
);
}
eprintln!(
"TOTAL {} entries — System={} Intro={} Phase={} PhaseEnd={} Supply={}",
entries.len(),
counts[0],
counts[1],
counts[2],
counts[3],
counts[4]
);
}

View File

@@ -0,0 +1,86 @@
//! Which container should the next runtime capture aim at?
//!
//! The 2026-08-13 stage-05 capture showed that a mission keeps several containers
//! resident and that its OWN `Stage_SNN.xpr` is among them (the f101 ACROPOLIS was
//! drawn from `Stage_S05.xpr`), so a capture can be aimed by choosing the mission.
//! This ranks the targets: for every XBG7 resource on the disc it records the
//! containers where it decodes and the ones where it does not, then reports
//!
//! * resources that decode **nowhere** — the real gaps, where a capture is the
//! only ground truth left;
//! * per container, how many of those it holds (fly that mission);
//! * resources that miss in one container but decode in another — a defect worth
//! fixing, but the geometry is already recoverable, so a capture is not needed.
//!
//! Usage: miss_targets <resource3d_dir>
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, BTreeSet};
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();
// resource -> (containers where it decodes, containers where it misses)
let mut ok: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut miss: BTreeMap<String, BTreeSet<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();
let declared: BTreeSet<String> = xbg7_resource_names(&bytes).into_iter().collect();
if declared.is_empty() {
continue;
}
let decoded: BTreeSet<String> =
Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
for n in &declared {
if decoded.contains(n) {
ok.entry(n.clone()).or_default().insert(where_.clone());
} else {
miss.entry(n.clone()).or_default().insert(where_.clone());
}
}
}
let never: BTreeMap<&String, &BTreeSet<String>> =
miss.iter().filter(|(n, _)| !ok.contains_key(*n)).collect();
let recoverable: Vec<&String> = miss.keys().filter(|n| ok.contains_key(*n)).collect();
println!("resources that decode NOWHERE: {}", never.len());
println!("resources that miss somewhere but decode elsewhere: {}\n", recoverable.len());
// Rank containers by how many never-decoding resources they hold.
let mut per_container: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, wheres) in &never {
for w in wheres.iter() {
per_container.entry(w).or_default().push(n);
}
}
let mut ranked: Vec<_> = per_container.iter().collect();
ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
println!("container never-decoding resources it holds");
for (w, v) in ranked.iter().take(14) {
let sample: Vec<&str> = v.iter().take(6).map(|s| s.as_str()).collect();
println!("{:<26} {:>3} {}", w, v.len(), sample.join(", "));
}
// Exclusives: a never-decoding resource that only ONE container holds is only
// reachable through whatever loads that container.
let excl: Vec<(&&String, &&BTreeSet<String>)> =
never.iter().filter(|(_, w)| w.len() == 1).collect();
println!("\nof the never-decoding, {} live in exactly one container", excl.len());
let mut by_one: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, w) in &excl {
by_one.entry(w.iter().next().unwrap()).or_default().push(n);
}
let mut b: Vec<_> = by_one.iter().collect();
b.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
for (w, v) in b.iter().take(10) {
println!(" {:<24} {:>3} {}", w, v.len(), v.iter().take(5).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
}
}

View File

@@ -0,0 +1,47 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
// 1. All stages: BackGroundID + phases + stage tag (from EnumUnit_SNN)
println!("=== STAGES (StageResource 3c9ae32e) ===");
let mut rows=vec![];
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
if o.schema_hash!=0x3c9ae32e {continue;}
let t=o.tokens();
let bg=o.get_raw("BackGroundID").unwrap_or("?").to_string();
let stage=t.iter().find_map(|s|s.strip_prefix("EnumUnit_").map(|x|x.trim_end_matches(".tbl").to_string())).unwrap_or("?".into());
let phases=t.iter().filter(|s|s.starts_with("Phase_")).count();
let unitgrp=t.iter().any(|s|s.starts_with("UnitGroup_"));
let route=t.iter().any(|s|s.starts_with("Route_"));
let formation=t.iter().any(|s|s.starts_with("FormationSet_"));
rows.push((stage,bg,phases,unitgrp,route,formation));
}
rows.sort();
for (s,bg,p,ug,rt,fm) in &rows{ println!(" {s:8} location={bg:14} phases={p} [squadrons:{} route:{} formations:{}]", if *ug{"Y"}else{"·"},if *rt{"Y"}else{"·"},if *fm{"Y"}else{"·"}); }
// 2. Parse objectives table into (stage,phase) -> key counts
println!("\n=== OBJECTIVES (033b5b7e) — per stage/phase key groups ===");
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
if o.schema_hash!=0x033b5b7e {continue;}
let t=o.tokens();
let mut groups:std::collections::BTreeMap<String,u32>=Default::default();
for tok in t{ if let Some(p)=tok.find("_Objective_").or_else(||tok.find("_Lose_")).or_else(||tok.find("_Hint_")){ let key=&tok[..p]; *groups.entry(key.trim_start_matches(|c:char|!c.is_ascii_alphabetic()).into()).or_default()+=1; } }
let stages:std::collections::BTreeSet<String>=groups.keys().filter_map(|k|k.get(..3).map(|s|s.to_string())).collect();
println!(" covers {} stages: {:?}", stages.len(), stages);
println!(" sample S01_P1 group counts: {:?}", groups.iter().filter(|(k,_)|k.starts_with("S01_P1")).collect::<Vec<_>>());
break;
}
// 3. Resolve objective TEXT: search all pak entries for the key "S01_P1_Objective_00" as an ID with English text
println!("\n=== OBJECTIVE TEXT resolution probe ===");
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens();
if let Some(i)=t.iter().position(|s|s=="S01_P1_Objective_00"){
let lo=i.saturating_sub(1); let hi=(i+3).min(t.len());
println!(" schema {:08x}: ...{:?}...", o.schema_hash, &t[lo..hi]);
}
}
}

View File

@@ -0,0 +1,89 @@
//! RE diagnostic: raw-dump a composite scene graph's node records — name, table
//! pointers, and the FULL joint-table slot values (not just the 8 the decoder
//! currently reads) — to derive the true TRS slot layout against captured
//! ground truth.
//! cargo run --release --example node_dump -- <Stage.xpr path> <composite name> [slots]
use sylpheed_formats::mesh::xbg7_descriptor_range;
fn be32(d: &[u8], o: usize) -> u32 {
if o + 4 > d.len() {
return 0;
}
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
}
fn be_f64(d: &[u8], o: usize) -> f64 {
if o + 8 > d.len() {
return f64::NAN;
}
f64::from_be_bytes(d[o..o + 8].try_into().unwrap())
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let comp = &a[2];
let nslots: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(12);
let (desc, desc_end) = xbg7_descriptor_range(&bytes, comp).expect("composite found");
let d = &bytes[desc..desc_end];
println!("descriptor [{desc:#x}..{desc_end:#x}) len {:#x}", d.len());
let mut i = 0usize;
while i + 3 <= d.len() {
let starts = (i + 4 <= d.len() && &d[i..i + 4] == b"rou_")
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
if !starts {
i += 1;
continue;
}
let mut j = i;
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
j += 1;
}
let name = std::str::from_utf8(&d[i..j]).unwrap_or("?").to_string();
if name.ends_with("_col") || name.ends_with("_spc") || name.ends_with("_gls") || name.ends_with("_lum") {
i = j.max(i + 1);
continue;
}
// find the 0xFFFFFFFF end marker
let mut ff = i;
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
ff += 4;
}
if ff >= scan_end || ff < 0x10 {
i = j.max(i + 1);
continue;
}
let t44 = be32(d, ff - 0x10) as usize;
let t48 = be32(d, ff - 0x0C) as usize;
let child = be32(d, ff - 0x08) as usize;
let sib = be32(d, ff - 0x04) as usize;
// Dump the record's u32s between name end and marker for structure context.
let rec_u32: Vec<String> = ((j + 1)..ff)
.step_by(4)
.map(|o| format!("{:08X}", be32(d, o)))
.collect();
println!("\nNODE {name} @{i:#x} ff@{ff:#x} t44={t44:#x} t48={t48:#x} child={child:#x} sib={sib:#x}");
println!(" rec: {}", rec_u32.join(" "));
if t44 != 0 && t44 < d.len() {
// Joint table: nslots pointer slots. Each slot record appears to be a
// keyframe track: a value ALSO sits 0x48 BEFORE the pointed address
// (the rest-pose key A), while the decoder currently reads ptr+8 (B).
for k in 0..nslots {
let p = be32(d, t44 + k * 4) as usize;
if p == 0 || p + 24 > d.len() {
continue;
}
let a = if p >= 0x48 { be_f64(d, p - 0x48) } else { f64::NAN };
let b = be_f64(d, p + 8);
let head = if p >= 0x50 { be32(d, p - 0x50) } else { 0 };
let t_a = if p >= 0x50 { be_f64(d, p - 0x50) } else { f64::NAN };
let t_b = be_f64(d, p);
println!(
" slot{k:2} @{p:#x} head={head:08X}: A={a:12.5} (t={t_a:8.3}) B={b:12.5} (t={t_b:8.3})"
);
}
}
i = j.max(i + 1);
}
}

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,129 @@
//! How much geometry on the disc was being read one index element late?
//!
//! The pad-scoring fix (2026-08-13) makes `anchor_pool_mesh` choose the pad whose
//! index run is cleanest instead of the first that validates. This measures the
//! blast radius: per container, how many single-block resources now decode from a
//! run that is NOT the old first-match (`pad 0`) one, and how many decoded runs
//! still contain degenerate triangles (the shift signature) afterwards.
//!
//! Usage: pad_shift_audit <resource3d_dir>
use sylpheed_formats::mesh::Xbg7Model;
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
/// Fraction of triangles whose face normal agrees with the stored normals,
/// folded to `max(na, 1-na)` so both authored windings read as ≈1.
fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
let (mut agree, mut n) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c || a.max(b).max(c) >= pos.len() || a >= nrm.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let nv = nrm[a];
n += 1;
if f[0] * nv[0] + f[1] * nv[1] + f[2] * nv[2] > 0.0 {
agree += 1;
}
}
if n == 0 {
return 1.0;
}
let fr = agree as f32 / n as f32;
fr.max(1.0 - fr)
}
fn degenerate(idx: &[u32]) -> usize {
idx.chunks_exact(3)
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
.count()
}
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 moved, mut still_degen) = (0usize, 0usize, 0usize);
let mut weak = 0usize;
let mut weak_wind: Vec<(f32, f32, String)> = Vec::new();
let mut worst: Vec<(usize, String, String)> = Vec::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();
let mut c_moved = 0usize;
for m in Xbg7Model::stage_models(&bytes) {
// Degeneracy is counted over EVERY sub-mesh (grouped pools included —
// they have their own pad choice); the pad-0 comparison below only
// applies to single-block resources, whose run starts at `vb - 2n`.
for sm in &m.meshes {
let d = degenerate(&sm.indices);
if d > 0 {
still_degen += 1;
worst.push((d, m.name.clone(), where_.clone()));
}
}
if m.meshes.len() != 1 {
continue;
}
let sm = &m.meshes[0];
let Some(vb) = sm.vbuf_offset else { continue };
let n = sm.indices.len();
if n < 6 || vb < n * 2 {
continue;
}
total += 1;
let pad0: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 + k * 2)).collect();
if pad0 != sm.indices {
moved += 1;
c_moved += 1;
// How strong was the evidence for moving? A pad-0 run with
// degenerate triangles is a decisive shift signature; one with
// none moved on winding alone, which is weaker.
if degenerate(&pad0) == 0 {
weak += 1;
// Record how far apart the two runs' winding is, to see
// whether these look like the same shift signature as the
// decisive cases (pad-0 winding drifting toward 0.5) or like
// noise between two equally clean readings.
let w0 = winding(&pad0, &sm.positions, &sm.normals);
let w2: f32 = {
let l2: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 - 2 + k * 2)).collect();
winding(&l2, &sm.positions, &sm.normals)
};
weak_wind.push((w0, w2, m.name.clone()));
}
}
}
if c_moved > 0 {
println!("{where_:<24} {c_moved} resources read from a non-pad-0 index run");
}
}
println!("\n{moved} of {total} single-block resources moved off the pad-0 run");
println!("{still_degen} decoded runs still contain a degenerate triangle");
println!("of the moved, {weak} had a pad-0 run with no degenerate triangle (moved on winding alone)");
let clear = weak_wind.iter().filter(|(w0, w2, _)| *w2 - *w0 > 0.15).count();
let close = weak_wind.iter().filter(|(w0, w2, _)| (*w2 - *w0).abs() <= 0.05).count();
println!(" of those: {clear} show the shift signature (pad-2 winding > pad-0 by >0.15), {close} are within 0.05 (noise)");
for (w0, w2, n) in weak_wind.iter().take(8) {
println!(" {n}: pad0 wind {w0:.3} -> pad2 {w2:.3}");
}
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
for (d, n, w) in worst.iter().take(15) {
println!(" {n} ({w}): {d} degenerate triangles");
}
}

View File

@@ -0,0 +1,13 @@
fn main(){
let a:Vec<String>=std::env::args().collect();
let bytes=std::fs::read(&a[1]).unwrap();
let filter=a.get(2).cloned().unwrap_or_default();
for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) {
if !filter.is_empty() && !n.contains(&filter) { continue }
if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) {
let strides = sylpheed_formats::mesh::debug_decl_strides(&bytes, &n);
println!("{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}",
&m[..m.len().min(4)], strides);
}
}
}

View File

@@ -0,0 +1,20 @@
//! Print a resource's first N decoded positions (buffer order), for matching a
//! capture draw's `pos:` dump to a part:
//! cargo run --release --example part_pos -- <xpr path> <resource> [n]
use std::collections::HashSet;
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let n: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(4);
let want: HashSet<String> = [a[2].clone()].into();
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
print!("{} vtx={}:", m.name, vc);
for p in m.meshes.iter().flat_map(|s| &s.positions).take(n) {
print!(" ({:.4},{:.4},{:.4})", p[0], p[1], p[2]);
}
println!();
}
}

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,6 @@
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let vb: usize = a[3].parse().unwrap();
println!("{:?}", sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3));
}

View File

@@ -0,0 +1,36 @@
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, slb, PakArchive};
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn contains(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
let code="eng";
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
let ids=movie_voice::registry_voice_ids(&registry);
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let entries=PakArchive::parse_toc(&stoc).unwrap();
for movie in ["ADV","RT01A","RT01B","RT01C_1","S00A","S01A","hokyu_LS_s02A","hokyu_LS_s09A","hokyu_LS_s02H","hokyu_DS_s07H"]{
let Some(token)=movie_manifest::voice_token(&manifest,movie) else { println!("{movie:16} no token"); continue };
let Some(&id)=ids.get(&token) else { println!("{movie:16} token {token} not in registry"); continue };
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else { println!("{movie:16} no anchor for {token}"); continue };
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
let window=rg(&disc,win_start,8*1024*1024);
let Some(end_local)=movie_voice::find_descriptor(&window,id) else { println!("{movie:16} id {id}: desc NOT FOUND"); continue };
let end=win_start+end_local as u64;
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1))
.or_else(||movie_voice::find_descriptor_before(&window,end_local))
.map(|o|win_start+o as u64)
.filter(|&s|s<end && end-s<1_500_000)
.unwrap_or(anchor);
let region=rg(&disc,start,(end-start) as usize);
let mut riffs=slb::to_xma_riffs(&region);
if riffs.is_empty(){ riffs=slb::to_xma_riff_best(&region).into_iter().collect(); }
let mut d="?".into();
if let Some(r)=riffs.first(){ let xp=format!("/tmp/ra2_{movie}.xma.wav");let wp=format!("/tmp/ra2_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
println!("{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s", (end-start)/1024, riffs.len());
}
}

View File

@@ -0,0 +1,60 @@
//! Which mission should the next unit harvest fly?
//!
//! Unit definitions are instantiated per stage, so coverage grows by visiting
//! missions — but only if the mission fields units we have not read yet. This
//! ranks every stage by how many of its roster units are missing from the
//! harvested CSV.
//!
//! Usage: roster_target <disc-root> <unit-runtime-fields.csv>
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let a: Vec<String> = std::env::args().collect();
let pak = PakArchive::open(format!("{}/dat/GP_MAIN_GAME_E.pak", a[1])).expect("pak");
let have: BTreeSet<String> = std::fs::read_to_string(&a[2])
.expect("csv")
.lines()
.skip(1)
.filter_map(|l| l.split(',').next().map(str::to_string))
.collect();
// Label each roster by STAGE: the TOC stores a hash of the original path, and
// the table names are known (`EnumUnit_SNN.tbl`), so hashing the candidates
// maps entries back to stages. Most rosters carry no `UN_S<NN>_` prop id, which
// is all `UnitRoster::stage` can infer from.
let mut by_hash: BTreeMap<u32, String> = BTreeMap::new();
// The TOC hashes a PATH, and these tables live under a directory — try the
// known schemes (see hash::TOC_NAME_SCHEMES) rather than the bare name.
for i in 1..=29 {
let stage = format!("S{i:02}");
for pre in ["", "unit\\", "battle\\", "stage\\", "enemy\\"] {
for suf in [".tbl", ""] {
by_hash.insert(name_hash(&format!("{pre}EnumUnit_{stage}{suf}")), stage.clone());
}
}
}
let mut rows: Vec<(usize, String, Vec<String>)> = Vec::new();
for e in pak.entries() {
let Some(stage) = by_hash.get(&e.name_hash).cloned() else { continue };
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let mut units: Vec<String> = Vec::new();
for id in o.tokens().iter().filter(|s| s.starts_with("UN_")) {
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
if !prop && !units.iter().any(|u| u == id) {
units.push(id.clone());
}
}
let missing: Vec<String> = units.iter().filter(|u| !have.contains(*u)).cloned().collect();
rows.push((missing.len(), stage, missing));
}
rows.sort_by_key(|(n, _, _)| std::cmp::Reverse(*n));
println!("units already harvested: {}\n", have.len());
println!("stage missing roster units not yet read");
for (n, stage, missing) in rows.iter().take(12) {
let s: Vec<&str> = missing.iter().take(6).map(|s| s.as_str()).collect();
println!("{stage:<8} {n:>5} {}", s.join(", "));
}
}

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,141 @@
//! Audit static ship assembly across ALL stages: flag parts placed far outside
//! their ship's cluster (placement bugs) and joint tracks with more than one
//! keyframe (which the single-key TRS read does not model).
//! cargo run --release --example ship_audit -- <resource3d dir>
use std::collections::HashSet;
use sylpheed_formats::mesh::{xbg7_descriptor_range, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{assemble_ship, ships_in_container};
fn be32(d: &[u8], o: usize) -> u32 {
if o + 4 > d.len() {
return 0;
}
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
}
/// Count joint-track records whose key count != 1 in a composite's descriptor.
fn multikey_tracks(bytes: &[u8], comp: &str) -> usize {
let Some((desc, desc_end)) = xbg7_descriptor_range(bytes, comp) else { return 0 };
let d = &bytes[desc..desc_end];
let mut n = 0usize;
let mut i = 0usize;
while i + 4 <= d.len() {
let starts = &d[i..i.min(d.len() - 4) + 4] == b"rou_"
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
if !starts {
i += 1;
continue;
}
let mut j = i;
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
j += 1;
}
let mut ff = i;
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
ff += 4;
}
if ff < scan_end && ff >= 0x10 {
let t44 = be32(d, ff - 0x10) as usize;
if t44 != 0 && t44 + 32 <= d.len() {
for k in 0..8 {
let p = be32(d, t44 + k * 4) as usize;
if p >= 0x50 && p + 16 <= d.len() {
let head = be32(d, p - 0x50);
if head != 1 && head != 0 {
n += 1;
}
}
}
}
}
i = j.max(i + 1);
}
n
}
fn main() {
let dir = std::env::args().nth(1).unwrap();
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
n.starts_with("Stage_") && n.ends_with(".xpr")
})
.collect();
entries.sort();
for path in entries {
let stage = path.file_name().unwrap().to_string_lossy().to_string();
let bytes = std::fs::read(&path).unwrap();
let names = xbg7_resource_names(&bytes);
// Multi-key animation tracks per composite (breaks the single-key read).
for n in names.iter().filter(|n| n.contains("rou_") && !n.contains("break")) {
let mk = multikey_tracks(&bytes, n);
if mk > 0 {
println!("MULTIKEY {stage} {n}: {mk} tracks");
}
}
for ship in ships_in_container(&bytes) {
if ship.parts.len() < 2 {
continue;
}
let placed = assemble_ship(&bytes, &ship.id, true);
if placed.len() < 2 {
continue;
}
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
// Per-placement world centroid.
let mut cents: Vec<(String, [f32; 3])> = Vec::new();
for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
let (mut c, mut n) = ([0.0f32; 3], 0f32);
for sub in &m.meshes {
for v in &sub.positions {
let w = p.apply(*v);
c[0] += w[0];
c[1] += w[1];
c[2] += w[2];
n += 1.0;
}
}
if n > 0.0 {
cents.push((p.resource.clone(), [c[0] / n, c[1] / n, c[2] / n]));
}
}
if cents.len() < 2 {
continue;
}
// Ship cluster = median centroid; flag parts > 3× the median spread.
let med = |k: usize| -> f32 {
let mut v: Vec<f32> = cents.iter().map(|(_, c)| c[k]).collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v[v.len() / 2]
};
let m = [med(0), med(1), med(2)];
let dists: Vec<f32> = cents
.iter()
.map(|(_, c)| {
((c[0] - m[0]).powi(2) + (c[1] - m[1]).powi(2) + (c[2] - m[2]).powi(2)).sqrt()
})
.collect();
let mut sd: Vec<f32> = dists.clone();
sd.sort_by(|a, b| a.partial_cmp(b).unwrap());
let spread = sd[sd.len() / 2].max(50.0);
for ((res, c), dist) in cents.iter().zip(&dists) {
if *dist > 6.0 * spread && *dist > 800.0 {
println!(
"OUTLIER {stage} {}: {res} centroid=[{:.0} {:.0} {:.0}] dist={:.0} (spread {:.0})",
ship.id, c[0], c[1], c[2], dist, spread
);
}
}
}
}
println!("audit done");
}

View File

@@ -0,0 +1,50 @@
//! Dump a ship's static placement + scene-graph nodes for a stage container FILE
//! (works offline from an extracted disc, no ISO needed).
//! cargo run --release --example ship_dump -- <Stage_SNN.xpr path> <ship_id>
use sylpheed_formats::mesh::{scene_world_nodes, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let (path, id) = (&a[1], &a[2]);
let bytes = std::fs::read(path).unwrap();
let names = xbg7_resource_names(&bytes);
// Base parts + their vertex counts.
let want: HashSet<String> = names.iter().filter(|n| is_base_part(n) && ship_id_of(n)==Some(id.as_str())).cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("== base parts ({}) ==", want.len());
for m in &models {
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
println!(" {:20} vtx={}", m.name, vc);
}
// Composites present for this id.
println!("== composites ==");
for n in &names {
if n.contains(&format!("rou_{id}")) && ship_id_of(n).is_none() {
let nodes = scene_world_nodes(&bytes, n);
println!(" {:24} {} nodes", n, nodes.len());
}
}
// assemble_ship result: centroids + extent.
for ext in [false, true] {
let placed = assemble_ship(&bytes, id, ext);
println!("== assemble_ship(external={ext}) -> {} parts ==", placed.len());
let mut lo=[f32::MAX;3]; let mut hi=[f32::MIN;3];
for p in &placed {
let src = models.iter().find(|m| m.name==p.resource);
let (mut c, mut n) = ([0.0f32;3], 0f32);
if let Some(src)=src { for sub in &src.meshes { for v in &sub.positions {
let w=p.apply(*v); for k in 0..3 { c[k]+=w[k]; lo[k]=lo[k].min(w[k]); hi[k]=hi[k].max(w[k]); } n+=1.0; }}}
let n=n.max(1.0);
println!(" {:20} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
p.resource, p.t[0],p.t[1],p.t[2], c[0]/n,c[1]/n,c[2]/n);
}
if placed.iter().any(|p| models.iter().any(|m| m.name==p.resource)) {
println!(" EXTENT=[{:.0} {:.0} {:.0}]", hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]);
}
}
}

View File

@@ -0,0 +1,141 @@
//! Diagnostic: assemble a ship from the BAKED capture table (or the static
//! scene graph with --static) and render orthographic PNGs + print per-part
//! world bounds, to see exactly what the viewer shows.
//! cargo run --release --example ship_render -- <Stage_SNN.xpr path> <ship_id> <out_prefix> [--static]
use std::collections::HashSet;
use sylpheed_formats::mesh::{ScenePart, Xbg7Model};
use sylpheed_formats::ship::assemble_ship;
use sylpheed_formats::ship_capture::{embedded_placement, to_scene_parts};
fn main() {
let a: Vec<String> = std::env::args().collect();
let use_static = a.iter().any(|s| s == "--static");
let (path, id, out) = (&a[1], &a[2], &a[3]);
let bytes = std::fs::read(path).unwrap();
let placed: Vec<ScenePart> = if use_static {
assemble_ship(&bytes, id, true)
} else {
let cap = embedded_placement(id).expect("ship not in baked table");
to_scene_parts(&cap)
};
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
// World triangles + per-part bounds.
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for sub in &m.meshes {
let w: Vec<[f32; 3]> = sub.positions.iter().map(|v| p.apply(*v)).collect();
for v in &w {
for k in 0..3 {
lo[k] = lo[k].min(v[k]);
hi[k] = hi[k].max(v[k]);
}
}
for t in sub.indices.chunks_exact(3) {
tris.push([w[t[0] as usize], w[t[1] as usize], w[t[2] as usize]]);
}
}
println!(
"{:20} X[{:8.1},{:8.1}] Y[{:8.1},{:8.1}] Z[{:8.1},{:8.1}]",
p.resource, lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]
);
}
// Exhaust cones at the GN_Jet/GN_SJet frames (the game's visible thrusters).
for f in sylpheed_formats::ship::exhaust_frames(&bytes, id) {
const SEG: usize = 12;
let (r, len) = (22.0f32, 140.0f32);
let ring: Vec<[f32; 3]> = (0..SEG)
.map(|s| {
let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
f.apply([a.cos() * r, a.sin() * r, 0.0])
})
.collect();
let apex = f.apply([0.0, 0.0, len]);
for s in 0..SEG {
tris.push([apex, ring[(s + 1) % SEG], ring[s]]);
}
println!(" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]", f.resource, f.t[0], f.t[1], f.t[2]);
}
println!("total {} tris from {} placements", tris.len(), placed.len());
// Orthographic z-buffered flat renders: top (X/Z, look down Y) and side (Z/Y, look down X).
for (name, ax, ay, az) in [("top", 0usize, 2usize, 1usize), ("side", 2usize, 1usize, 0usize)] {
let size = 900usize;
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for t in &tris {
for v in t {
for k in 0..3 {
lo[k] = lo[k].min(v[k]);
hi[k] = hi[k].max(v[k]);
}
}
}
let cx = (lo[ax] + hi[ax]) * 0.5;
let cy = (lo[ay] + hi[ay]) * 0.5;
let ext = (hi[ax] - lo[ax]).max(hi[ay] - lo[ay]) * 0.55;
let scale = (size as f32 * 0.5) / ext;
let mut img = vec![0u8; size * size * 3];
let mut zbuf = vec![f32::MIN; size * size];
for t in &tris {
// screen coords
let p: Vec<[f32; 3]> = t
.iter()
.map(|v| {
[
(v[ax] - cx) * scale + size as f32 * 0.5,
(cy - v[ay]) * scale + size as f32 * 0.5,
v[az],
]
})
.collect();
// flat shade by triangle normal Z-ish
let e1 = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
let e2 = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
let n = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let nl = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-6);
let lum = (n[az].abs() / nl * 200.0 + 40.0) as u8;
// bbox raster
let minx = p.iter().map(|v| v[0]).fold(f32::MAX, f32::min).max(0.0) as usize;
let maxx = (p.iter().map(|v| v[0]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
let miny = p.iter().map(|v| v[1]).fold(f32::MAX, f32::min).max(0.0) as usize;
let maxy = (p.iter().map(|v| v[1]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
let det = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
if det.abs() < 1e-6 {
continue;
}
for y in miny..=maxy {
for x in minx..=maxx {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let w0 = ((p[1][0] - fx) * (p[2][1] - fy) - (p[2][0] - fx) * (p[1][1] - fy)) / det;
let w1 = ((p[2][0] - fx) * (p[0][1] - fy) - (p[0][0] - fx) * (p[2][1] - fy)) / det;
let w2 = 1.0 - w0 - w1;
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
continue;
}
let z = w0 * p[0][2] + w1 * p[1][2] + w2 * p[2][2];
let idx = y * size + x;
if z > zbuf[idx] {
zbuf[idx] = z;
img[idx * 3] = lum;
img[idx * 3 + 1] = lum;
img[idx * 3 + 2] = lum;
}
}
}
}
let file = format!("{out}_{name}.ppm");
let mut ppm = format!("P6\n{size} {size}\n255\n").into_bytes();
ppm.extend_from_slice(&img);
std::fs::write(&file, ppm).unwrap();
println!("wrote {file}");
}
}

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,33 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
// schemas whose type0/content is squadron/formation/route/group/position
let mut hit:BTreeMap<u32,(u32,String,String)>=BTreeMap::new();
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default();
let joined:String=t.iter().take(20).map(|s|s.as_str()).collect::<Vec<_>>().join(" ");
if ["Squadron","Formation","Route","UnitGroup","Position","Spawn","Wave","Frame","NullFrame"].iter().any(|k|t0.contains(k)||joined.contains(k)){
let ent=hit.entry(o.schema_hash).or_insert((0,t0.clone(),joined.chars().take(90).collect()));
ent.0+=1;
}
}
let mut v:Vec<_>=hit.into_iter().collect(); v.sort_by_key(|x|std::cmp::Reverse(x.1.0));
for (s,(c,t0,sample)) in &v{ println!("[{s:08x}] ×{c:<4} type0={t0:<18.18} :: {sample}"); }
// dump a Squadron/UnitGroup blob fully to see if it has positions (binary floats) or refs
println!("\n─── sample squadron/formation blob (first matching) ───");
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default();
if t0.contains("Squadron")||t0.contains("Formation")||t0.contains("UnitGroup"){
println!("schema {:08x} type0={t0} count={} tokens={}, blob {}B", o.schema_hash,o.count,t.len(),b.len());
for (i,tk) in t.iter().take(30).enumerate(){ print!("{i:>2}:{tk:<20.20}"); if i%4==3{println!();} } println!();
// scan blob for float-like values (reasonable coords) in the binary region
let nfloats=(0..b.len().saturating_sub(4)).step_by(4).filter(|&i|{let f=f32::from_be_bytes([b[i],b[i+1],b[i+2],b[i+3]]); f.is_finite()&&f.abs()>1.0&&f.abs()<1e6}).count();
println!(" ~{nfloats} plausible BE-float words in blob (positions live in binary region if high)");
break;
}
}
}

View File

@@ -0,0 +1,11 @@
use sylpheed_formats::{game_data, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let sq=game_data::load_squadrons(&pak);
println!("{} squadron definitions", sq.len());
for s in sq.iter().filter(|s|s.side.as_deref()==Some("TCAF")).take(6){
let mem:Vec<String>=s.members.iter().map(|m|m.trim_start_matches("UN_").chars().take(18).collect()).collect();
println!(" {:8} {:22} {:24} x{} {:?}", s.id.clone().unwrap_or("?".into()), s.formation_id.clone().unwrap_or_default(), s.ai_id.clone().unwrap_or_default(), s.count.unwrap_or(0), mem);
}
}

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,7 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
let stride:usize=a[2].parse().unwrap(); let want:usize=a[3].parse().unwrap();
let s=sylpheed_formats::mesh::debug_vertex_run_starts(&b,stride);
println!("{} candidate starts at stride {}", s.len(), stride);
println!("contains {:#x}: {}", want, s.contains(&want));
let near:Vec<String>=s.iter().filter(|&&o| o.abs_diff(want)<0x200).map(|o|format!("{o:#x}")).collect();
println!("nearby: {}", near.join(" "));}

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,55 @@
//! Scratch analysis: inventory one UI screen's pak — every entry, and for RATC
//! entries the children they bundle. Optionally write an entry's decompressed
//! bytes out for hex inspection.
//!
//! Run: cargo run -p sylpheed-formats --example ui_screen -- <PAK> [--dump 0xHASH out.bin]
use sylpheed_formats::pak::{self, PakArchive};
use sylpheed_formats::ratc;
fn main() {
let mut args = std::env::args().skip(1);
let pak = args.next().expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
let arc = PakArchive::open(&pak).expect("open pak");
let rest: Vec<String> = args.collect();
if rest.first().map(|s| s == "--dump").unwrap_or(false) {
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
std::fs::write(&rest[2], &bytes).unwrap();
println!("wrote {} bytes to {}", bytes.len(), rest[2]);
return;
}
if rest.first().map(|s| s == "--child").unwrap_or(false) {
// --child 0xHASH <child-name> <out>: carve one RATC child out by its
// listed offset/size, so a 165-byte layout record can be hexdumped alone.
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
let kids = ratc::parse(&bytes).expect("ratc");
let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found");
std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap();
println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]);
return;
}
println!("{pak}: {} entries", arc.len());
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else {
println!(" {:08x} <decompress failed>", e.name_hash);
continue;
};
let label = pak::inner_format_label(&bytes);
println!(" {:08x} {:>9} B {}", e.name_hash, bytes.len(), label);
if ratc::is_ratc(&bytes) {
if let Some(kids) = ratc::parse(&bytes) {
for k in &kids {
println!(" - {:<40} {:>9} B {}", k.name, k.size, k.kind);
}
if kids.is_empty() {
println!(" (no children found)");
}
}
}
}
}

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,32 @@
use sylpheed_formats::slb;
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration:stream=channels","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).replace('\n'," ")}
fn movdur(disc:&str,m:&str)->String{ dur(&format!("{disc}/dat/movie/{m}")) }
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
// descriptor trailer global offsets (from scan) => cue N data = [desc(N-1)..desc(N)]
// (id, end_off, movie)
let cues=[(1600u32,437044592u64,"ADV.wmv"),(1601,437345648,"RT01A.wmv"),(1602,437712240,"RT01B.wmv"),
(1603,438080880,"RT01C_1.wmv"),(1604,438451568,"RT01C_2.wmv"),(1605,438789488,"RT02A.wmv")];
for k in 1..cues.len(){
let (id,end,mov)=cues[k];
let start=cues[k-1].1;
let region=rg(&disc,start,(end-start) as usize + 12000); // +tail to include full data before next trailer
let riffs=slb::to_xma_riffs(&region);
let mut total=0.0f32; let mut parts=vec![];
for (j,r) in riffs.iter().enumerate(){
let xp=format!("/tmp/cue{id}_{j}.xma.wav"); let wp=format!("/tmp/cue{id}_{j}.wav");
fs::write(&xp,r).unwrap();
let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();
let d=dur(&wp); parts.push(d.clone());
total+=d.split_whitespace().next().unwrap_or("0").parse::<f32>().unwrap_or(0.0);
}
// spanning?
let span_adv_end=437547264u64;
let spans = start < span_adv_end && end > span_adv_end || (start/1_000 != end/1_000);
println!("cue {id} ({mov}): region[{start}..{end}] {} bytes, {} riff(s), Σ={:.1}s | movie={} parts={:?}",
end-start, riffs.len(), total, movdur(&disc,mov), parts);
}
println!("\nWAVs at /tmp/cue16XX_*.wav (listen: RT01B=1602, RT01C_1=1603, RT01C_2=1604)");
}

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,19 @@
//! Why does a named resource never decode? Reports the furthest gate its best
//! candidate reached. Usage: why_missed <container.xpr> [name-substring]
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let filter = a.get(2).cloned().unwrap_or_default();
let decoded: HashSet<String> = Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
for n in xbg7_resource_names(&bytes) {
if decoded.contains(&n) || (!filter.is_empty() && !n.contains(&filter)) {
continue;
}
match debug_best_rejection(&bytes, &n) {
Some((rank, why)) => println!("{n:<28} rank {rank} {why}"),
None => println!("{n:<28} (no candidate reached any gate / skipped before anchoring)"),
}
}
}

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

@@ -0,0 +1,980 @@
//! Typed loaders for Project Sylpheed's combat data tables.
//!
//! The game keeps its balance data in reflective [`crate::idxd`] tables — one
//! blob per entity, `token[0]` naming the table, fields laid out value-before-key.
//! This module maps the four combat schemas to plain, cloneable structs: the
//! common stats as named `Option<…>` fields, and **every** explicitly-set field in
//! a [`fields`](Weapon::fields) map so nothing is lost.
//!
//! Reverse-engineered 2026-07-23 from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S
//! paks are localized duplicates). Fields left at their default value omit their
//! value on disc — those live in title code, not here — so a `None` means "not set
//! on disc", never a guess.
//!
//! ```no_run
//! use sylpheed_formats::{game_data, PakArchive};
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
//! for w in game_data::load_weapons(&pak) {
//! println!("{} — power {:?}, range {:?}", w.id.unwrap_or_default(), w.power, w.max_range);
//! }
//! ```
use crate::idxd::IdxdObject;
use crate::pak::PakArchive;
use std::collections::BTreeMap;
/// IDXD schema ids of the combat tables (the `schema_hash` field of each blob).
pub mod schema {
/// Player craft config: physics, cameras, scoring, difficulty, render pipeline.
pub const PLAYER: u32 = 0x0426_e81d;
/// Weapon definitions (guns, missiles, beams, bombs).
pub const WEAPON: u32 = 0x6ab4_825a;
/// Craft / units — fighters, bombers, turrets, with their AI flight model.
pub const UNIT: u32 = 0x43fa_a517;
/// Vessels — capital ships, with structural component counts.
pub const VESSEL: u32 = 0x3c5b_0549;
/// Characters — pilots / crew / comms, with faction and portrait set.
pub const CHARACTER: u32 = 0xbd86_d41c;
/// Stage resource manifest — per mission: location, phases, resource tables.
pub const STAGE: u32 = 0x3c9a_e32e;
/// Message / demo dialogue — per line: speaker, portrait, voice clip, pages.
pub const MESSAGE: u32 = 0xb412_e6d8;
}
/// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`).
fn as_f32(v: Option<&String>) -> Option<f32> {
let v = v?;
let v = v.strip_suffix(['f', 'F']).unwrap_or(v);
v.parse().ok()
}
fn as_i64(v: Option<&String>) -> Option<i64> {
v?.parse().ok()
}
/// Collect every explicitly-set field of an IDXD blob into a `key → value` map,
/// plus the identity strings. Identifier-valued fields (`Model`, `ShotType`, …)
/// that carry a bare name rather than a value are not in the map — read those from
/// [`IdxdObject::get_raw`] if needed.
fn field_map(o: &IdxdObject) -> BTreeMap<String, String> {
o.resolved_fields()
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
/// Read every record of `schema` from a pak, mapping each with `build`.
fn load_table<T>(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option<T>) -> Vec<T> {
pak.entries()
.iter()
.filter_map(|e| pak.read(e).ok())
.filter_map(|b| IdxdObject::parse(&b).ok())
.filter(|o| o.schema_hash == schema)
.filter_map(|o| build(&o))
.collect()
}
// ── Weapon ────────────────────────────────────────────────────────────────────
/// A weapon definition (schema [`schema::WEAPON`]).
#[derive(Debug, Clone)]
pub struct Weapon {
pub id: Option<String>,
pub name: Option<String>,
/// Target mask, e.g. `"Vessel,Craft,Structure"`.
pub target_type: Option<String>,
pub power: Option<f32>,
pub velocity: Option<f32>,
pub min_velocity: Option<f32>,
pub max_velocity: Option<f32>,
pub min_range: Option<f32>,
pub max_range: Option<f32>,
/// Ammo / reload budget (`LoadingCount`).
pub loading_count: Option<i64>,
/// Seconds between shots.
pub interval: Option<f32>,
/// Shots per trigger pull (volley / lock count).
pub trigger_shot_count: Option<i64>,
pub mass: Option<f32>,
pub heating: Option<f32>,
pub cooling: Option<f32>,
pub life_time: Option<f32>,
/// All explicitly-set fields (a superset of the named ones above).
pub fields: BTreeMap<String, String>,
}
impl Weapon {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let f = field_map(o);
Some(Weapon {
id: o.get_raw("ID").map(str::to_string),
name: o.get_raw("Name").map(str::to_string),
target_type: o.get_raw("TargetType").map(str::to_string),
power: as_f32(f.get("Power")),
velocity: as_f32(f.get("Velocity")),
min_velocity: as_f32(f.get("MinimumVelocity")),
max_velocity: as_f32(f.get("MaximumVelocity")),
min_range: as_f32(f.get("MinimumRange")),
max_range: as_f32(f.get("MaximumRange")),
loading_count: as_i64(f.get("LoadingCount")),
interval: as_f32(f.get("Interval")),
trigger_shot_count: as_i64(f.get("TriggerShotCount")),
mass: as_f32(f.get("Mass")),
heating: as_f32(f.get("Heating")),
cooling: as_f32(f.get("Cooling")),
life_time: as_f32(f.get("LifeTime")),
fields: f,
})
}
/// Any explicit field parsed as `f32` (for the long-tail stats not named above).
pub fn field_f32(&self, key: &str) -> Option<f32> {
as_f32(self.fields.get(key))
}
}
/// Load every weapon from a pak. Catches all `Weapon`-shaped records, not just the
/// main [`schema::WEAPON`] — player special/missile weapons live in variant schemas
/// (e.g. `Weapon_DSaber_P_wep_*` in `GP_HANGAR_ARSENAL.pak`). Deduplicated by id.
pub fn load_weapons(pak: &PakArchive) -> Vec<Weapon> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
// `Weapon`/`QWeapon`/… but not the `EnumWeapon` name lists.
let t0 = o.tokens().first().map(String::as_str).unwrap_or("");
if !t0.ends_with("Weapon") || t0.contains("Enum") {
continue;
}
if let Some(w) = Weapon::from_idxd(&o) {
if w.id.as_deref().is_some_and(|id| seen.insert(id.to_string())) {
out.push(w);
}
}
}
out
}
// ── Craft / unit ────────────────────────────────────────────────────────────────
/// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the
/// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`,
/// `TurnAttack_*`, …).
#[derive(Debug, Clone)]
pub struct CraftUnit {
pub id: Option<String>,
pub name: Option<String>,
pub hp: Option<f32>,
pub is_destructible: Option<bool>,
pub size_x: Option<f32>,
pub size_y: Option<f32>,
pub size_z: Option<f32>,
pub size_radius: Option<f32>,
pub radar_range: Option<f32>,
pub fcs_range: Option<f32>,
pub cruising_velocity: Option<f32>,
pub maximum_velocity: Option<f32>,
pub acceleration: Option<f32>,
pub deceleration: Option<f32>,
pub shield_ratio: Option<f32>,
pub turret_count: Option<i64>,
pub score_point: Option<i64>,
pub fields: BTreeMap<String, String>,
}
impl CraftUnit {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let f = field_map(o);
Some(CraftUnit {
id: o.get_raw("ID").map(str::to_string),
name: o.get_raw("Name").map(str::to_string),
hp: as_f32(f.get("HP")),
is_destructible: o.get_bool("IsDestructible"),
size_x: as_f32(f.get("Size_X")),
size_y: as_f32(f.get("Size_Y")),
size_z: as_f32(f.get("Size_Z")),
size_radius: as_f32(f.get("Size_Radius")),
radar_range: as_f32(f.get("RadarRange")),
fcs_range: as_f32(f.get("FCSRange")),
cruising_velocity: as_f32(f.get("CruisingVelocity")),
maximum_velocity: as_f32(f.get("MaximumVelocity")),
acceleration: as_f32(f.get("Acceleration")),
deceleration: as_f32(f.get("Deceleration")),
shield_ratio: as_f32(f.get("ShieldRatio")),
turret_count: as_i64(f.get("TurretCount")),
score_point: as_i64(f.get("ScorePoint")),
fields: f,
})
}
pub fn field_f32(&self, key: &str) -> Option<f32> {
as_f32(self.fields.get(key))
}
}
/// Load every craft / unit from a pak.
pub fn load_units(pak: &PakArchive) -> Vec<CraftUnit> {
load_table(pak, schema::UNIT, CraftUnit::from_idxd)
}
// ── Vessel / capital ship ───────────────────────────────────────────────────────
/// A capital ship (schema [`schema::VESSEL`]). Component counts drive the
/// destructible hardpoints.
#[derive(Debug, Clone)]
pub struct Vessel {
pub id: Option<String>,
pub name: Option<String>,
/// The 3D hull resource stem, e.g. `rou_e105` — the `eNNN`/`fNNN` id links to
/// the XBG7 part family (`e105_bdy_*`, `e105_brg_*`, …) inside a `Stage_SNN.xpr`.
pub model: Option<String>,
pub hp: Option<f32>,
pub size_x: Option<f32>,
pub size_y: Option<f32>,
pub size_z: Option<f32>,
pub radar_range: Option<f32>,
pub fcs_range: Option<f32>,
pub maximum_velocity: Option<f32>,
pub shield_ratio: Option<f32>,
pub score_point: Option<i64>,
pub turret_count: Option<i64>,
pub bridge_count: Option<i64>,
pub hatch_count: Option<i64>,
pub shield_generator_count: Option<i64>,
pub thruster_count: Option<i64>,
pub fields: BTreeMap<String, String>,
}
impl Vessel {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let f = field_map(o);
Some(Vessel {
id: o.get_raw("ID").map(str::to_string),
name: o.get_raw("Name").map(str::to_string),
model: o.get_raw("Model").map(str::to_string),
hp: as_f32(f.get("HP")),
size_x: as_f32(f.get("Size_X")),
size_y: as_f32(f.get("Size_Y")),
size_z: as_f32(f.get("Size_Z")),
radar_range: as_f32(f.get("RadarRange")),
fcs_range: as_f32(f.get("FCSRange")),
maximum_velocity: as_f32(f.get("MaximumVelocity")),
shield_ratio: as_f32(f.get("ShieldRatio")),
score_point: as_i64(f.get("ScorePoint")),
turret_count: as_i64(f.get("TurretCount")),
bridge_count: as_i64(f.get("BridgeCount")),
hatch_count: as_i64(f.get("HatchCount")),
shield_generator_count: as_i64(f.get("ShieldGeneratorCount")),
thruster_count: as_i64(f.get("ThrusterCount")),
fields: f,
})
}
pub fn field_f32(&self, key: &str) -> Option<f32> {
as_f32(self.fields.get(key))
}
}
/// Load every capital ship from a pak.
pub fn load_vessels(pak: &PakArchive) -> Vec<Vessel> {
load_table(pak, schema::VESSEL, Vessel::from_idxd)
}
// ── Player config ───────────────────────────────────────────────────────────────
/// Player craft config (schema [`schema::PLAYER`]) — one per mission. Physics,
/// projectile caps and scoring here; cameras, difficulty and the render pipeline
/// live in [`fields`](PlayerConfig::fields).
#[derive(Debug, Clone)]
pub struct PlayerConfig {
pub air_drag_factor: Option<f32>,
pub gravity_factor: Option<f32>,
pub bullet_limit: Option<i64>,
pub laser_limit: Option<i64>,
pub homing_limit: Option<i64>,
pub space_size: Option<f32>,
pub supply_range: Option<f32>,
pub main_mission_bonus: Option<i64>,
pub rank_score_s: Option<i64>,
pub rank_score_a: Option<i64>,
pub rank_score_b: Option<i64>,
pub rank_score_c: Option<i64>,
pub rank_score_d: Option<i64>,
pub fields: BTreeMap<String, String>,
}
impl PlayerConfig {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let f = field_map(o);
Some(PlayerConfig {
air_drag_factor: as_f32(f.get("AirDragFactor")),
gravity_factor: as_f32(f.get("GravityFactor")),
bullet_limit: as_i64(f.get("BulletLimit")),
laser_limit: as_i64(f.get("LaserLimit")),
homing_limit: as_i64(f.get("HomingLimit")),
space_size: as_f32(f.get("SpaceSize")),
supply_range: as_f32(f.get("SupplyRange")),
main_mission_bonus: as_i64(f.get("MainMissionBonus")),
rank_score_s: as_i64(f.get("RankScore_S")),
rank_score_a: as_i64(f.get("RankScore_A")),
rank_score_b: as_i64(f.get("RankScore_B")),
rank_score_c: as_i64(f.get("RankScore_C")),
rank_score_d: as_i64(f.get("RankScore_D")),
fields: f,
})
}
pub fn field_f32(&self, key: &str) -> Option<f32> {
as_f32(self.fields.get(key))
}
}
/// Load every player config (one per mission) from a pak.
pub fn load_player_configs(pak: &PakArchive) -> Vec<PlayerConfig> {
load_table(pak, schema::PLAYER, PlayerConfig::from_idxd)
}
// ── Character ───────────────────────────────────────────────────────────────────
/// A pilot / crew portrait: an emotion id (`FaceRAYMOND_07`) and its texture
/// (`pjf003_C02.t32`, a T8aD tile inside a RATC bundle).
#[derive(Debug, Clone)]
pub struct Face {
pub id: String,
pub texture: String,
}
/// A character (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms.
#[derive(Debug, Clone)]
pub struct Character {
pub id: Option<String>,
/// Localized display-name key (resolve against the game's string tables).
pub name_key: Option<String>,
/// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy).
pub faction: Option<String>,
pub unique: Option<bool>,
/// Portrait set: each emotion face and the texture that draws it.
pub faces: Vec<Face>,
pub fields: BTreeMap<String, String>,
}
impl Character {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let t = o.tokens();
// Faces are `(texture, FaceID)` pairs after the `Faces` marker (value-
// before-key: the FaceID names the emotion, the `.t32` before it is its art).
let mut faces = Vec::new();
if let Some(start) = t.iter().position(|s| s == "Faces") {
for i in (start + 1)..t.len() {
if t[i].starts_with("Face") && i > 0 {
faces.push(Face {
id: t[i].clone(),
texture: t[i - 1].clone(),
});
}
}
}
Some(Character {
id: o.get_raw("ID").map(str::to_string),
name_key: o.get_raw("Name").map(str::to_string),
faction: o.get_raw("SideID").map(str::to_string),
unique: o.get_bool("Unique"),
faces,
fields: field_map(o),
})
}
}
/// Load every character from a pak.
pub fn load_characters(pak: &PakArchive) -> Vec<Character> {
load_table(pak, schema::CHARACTER, Character::from_idxd)
}
// ── Stage / mission ─────────────────────────────────────────────────────────────
/// The raw token immediately before `key` in `tokens` (value-before-key), for
/// identifier-valued fields the generic resolver skips (filenames, locations).
fn raw_before<'a>(tokens: &'a [String], key: &str) -> Option<&'a str> {
let i = tokens.iter().position(|t| t == key)?;
(i > 0).then(|| tokens[i - 1].as_str())
}
/// A mission / stage (schema [`STAGE`](schema::STAGE)). The manifest that wires a
/// mission together: its location, phase count, and the per-stage tables that
/// populate it (unit roster, squadrons, formations, flight routes, AI, objectives).
/// Resolve the referenced `*_S<NN>.tbl` tables with [`load_records`] for the
/// spawn/formation detail.
#[derive(Debug, Clone)]
pub struct Stage {
/// Stage tag derived from the unit-table name, e.g. `"S01"`.
pub id: String,
/// In-world location (`BackGroundID`), e.g. `Lebendorf`, `Acheron`, `Earth`.
pub location: Option<String>,
/// Number of `Phase_N` sections.
pub phases: u32,
pub unit_table: Option<String>,
pub character_table: Option<String>,
pub squadron_table: Option<String>,
pub formation_table: Option<String>,
pub route_table: Option<String>,
pub ai_params_table: Option<String>,
pub subobjective_table: Option<String>,
pub message_table: Option<String>,
/// Every `resource-kind → table` reference in the manifest.
pub resources: BTreeMap<String, String>,
}
impl Stage {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let t = o.tokens();
// Each resource is `table-name → Enumerate<Kind>` (value-before-key).
let mut resources = BTreeMap::new();
for i in 1..t.len() {
let key = t[i].as_str();
if key.starts_with("Enumerate") || key == "MessageSet" || key == "NamePlate" {
resources.insert(key.to_string(), t[i - 1].clone());
}
}
let unit_table = raw_before(t, "EnumerateUnit").map(str::to_string);
let id = unit_table
.as_deref()
.and_then(|s| s.strip_prefix("EnumUnit_"))
.map(|s| s.trim_end_matches(".tbl").to_string())
.unwrap_or_default();
Some(Stage {
id,
location: o.get_raw("BackGroundID").map(str::to_string),
phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32,
unit_table,
character_table: raw_before(t, "EnumerateCharacter").map(str::to_string),
squadron_table: raw_before(t, "EnumerateSquadron").map(str::to_string),
formation_table: raw_before(t, "EnumerateFormation").map(str::to_string),
route_table: raw_before(t, "EnumerateNullFrame").map(str::to_string),
ai_params_table: raw_before(t, "EnumerateAIParams").map(str::to_string),
subobjective_table: raw_before(t, "EnumerateSubobjective").map(str::to_string),
message_table: raw_before(t, "MessageSet").map(str::to_string),
resources,
})
}
}
/// Load every stage / mission manifest from a pak.
pub fn load_stages(pak: &PakArchive) -> Vec<Stage> {
load_table(pak, schema::STAGE, Stage::from_idxd)
}
// ── Squadron / flight group ─────────────────────────────────────────────────────
/// A squadron — a flight group that spawns together: its formation shape, AI
/// behaviour, side, and member craft (with their pilots). Parsed from the
/// per-stage `Enumerate_Squadrons` tables (`UnitGroup_S<NN>.tbl`). The player's
/// Rhino flight, for instance, is a 2-craft TCAF squadron flying `Formation_2_Rhino`.
#[derive(Debug, Clone)]
pub struct Squadron {
/// Squadron tag (`TCN001`, `ADT101`, …), matched positionally to its
/// definition; `None` if the id list and definitions don't line up.
pub id: Option<String>,
pub formation_id: Option<String>,
pub ai_id: Option<String>,
/// Faction — `TCAF` (allied) or `ADAN` (enemy).
pub side: Option<String>,
pub count: Option<i64>,
/// Member craft (`UN_*` unit ids).
pub members: Vec<String>,
}
/// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their
/// schema hash varies per stage, but the table name is stable).
pub fn load_squadrons(pak: &PakArchive) -> Vec<Squadron> {
let mut out = Vec::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let t = o.tokens();
if !t.first().is_some_and(|s| s.contains("Enumerate_Squadron")) {
continue;
}
// Definitions are delimited by the `FormationID` key (its value, the
// formation, sits just before it). The id list precedes the first one.
let fpos: Vec<usize> = t
.iter()
.enumerate()
.filter(|(_, s)| *s == "FormationID")
.map(|(i, _)| i)
.collect();
let Some(&first) = fpos.first() else { continue };
// The squadron id list: tokens after the header up to the first formation.
let ids: Vec<&String> = t[1..first.saturating_sub(1)].iter().collect();
for (k, &fi) in fpos.iter().enumerate() {
let start = fi.saturating_sub(1);
let end = fpos.get(k + 1).map(|&n| n.saturating_sub(1)).unwrap_or(t.len());
let span = &t[start..end];
let before = |key: &str| -> Option<String> {
span.iter().position(|s| s == key).and_then(|i| (i > 0).then(|| span[i - 1].clone()))
};
let count = before("Count").and_then(|v| v.parse::<i64>().ok());
// Members are the first `count` `UN_` craft after the header block
// (which ends at DisableInterval, else Count); anything past them
// belongs to the next squadron or the stage roster.
let member_start = span
.iter()
.position(|s| s == "DisableInterval")
.or_else(|| span.iter().position(|s| s == "Count"))
.map(|i| i + 1)
.unwrap_or(0);
let take = count.unwrap_or(0).max(0) as usize;
out.push(Squadron {
id: (ids.len() == fpos.len()).then(|| ids[k].clone()),
formation_id: Some(t[fi - 1].clone()),
ai_id: before("AIID"),
side: before("SideID"),
count,
members: span[member_start..]
.iter()
.filter(|s| s.starts_with("UN_"))
.take(take)
.cloned()
.collect(),
});
}
}
out
}
// ── Demo message / dialogue line ────────────────────────────────────────────────
/// One dialogue line (schema [`MESSAGE`](schema::MESSAGE)): who speaks, with which
/// portrait and voice clip, and the text-key(s) of its caption pages. Resolve the
/// page keys against a [`crate::localization::TextIndex`] for the words, and the
/// voice clip against the movie-voice banks for the audio.
#[derive(Debug, Clone)]
pub struct DemoMessage {
/// Line id, e.g. `MSG_VOICE_A_007`.
pub id: String,
/// Speaker (`CharacterRAYMOND`) when the line names one; many system /
/// continuation lines are unattributed (`None`).
pub character: Option<String>,
/// Portrait / emotion (`FaceRAYMOND_07`).
pub face: Option<String>,
/// Voice clip token (`VOICE_A_007`).
pub voice_clip: Option<String>,
/// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text.
pub page_keys: Vec<String>,
}
/// Load every dialogue line from a pak. Each `Message_NNN` sub-record within the
/// message blobs becomes one [`DemoMessage`]; fields are found by prefix, so the
/// positional/defaulted layout doesn't matter.
pub fn load_demo_messages(pak: &PakArchive) -> Vec<DemoMessage> {
let is_marker = |s: &str| {
s.strip_prefix("Message_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
};
let mut out = Vec::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
if o.schema_hash != schema::MESSAGE {
continue;
}
let t = o.tokens();
let marks: Vec<usize> = t.iter().enumerate().filter(|(_, s)| is_marker(s)).map(|(i, _)| i).collect();
for (k, &m) in marks.iter().enumerate() {
let end = marks.get(k + 1).copied().unwrap_or(t.len());
let slice = &t[m + 1..end];
let Some(id) = slice.first().cloned() else { continue };
out.push(DemoMessage {
character: slice.iter().find(|s| s.starts_with("Character")).cloned(),
face: slice.iter().find(|s| s.starts_with("Face")).cloned(),
voice_clip: slice.iter().find(|s| s.starts_with("VOICE_")).cloned(),
page_keys: slice
.iter()
.filter(|s| s.len() > id.len() && s.starts_with(&id) && s[id.len()..].starts_with('_'))
.cloned()
.collect(),
id,
});
}
}
out
}
// ── Unit roster (per-mission combatants) ────────────────────────────────────────
/// Schema of the per-stage `EnumUnit` roster tables.
const ENUM_UNIT_SCHEMA: u32 = 0x35b8_dc67;
/// A mission's unit roster — the craft, vessels and props that can appear in one
/// battle (an `EnumUnit_S<NN>` table). Join the ids against [`load_units`] /
/// [`load_vessels`] for stats. The table isn't tagged with its stage on disc, so
/// [`stage`](UnitRoster::stage) is inferred from any `S<NN>_`-prefixed prop id in
/// the roster (asteroid collision meshes etc.) and is `None` when none is present.
#[derive(Debug, Clone)]
pub struct UnitRoster {
pub stage: Option<String>,
/// Distinct combatant unit ids (`UN_*`), excluding stage props (asteroids,
/// collision meshes).
pub units: Vec<String>,
}
/// Load every mission unit roster from a pak.
pub fn load_unit_rosters(pak: &PakArchive) -> Vec<UnitRoster> {
let mut out = Vec::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
if o.schema_hash != ENUM_UNIT_SCHEMA {
continue;
}
let ids: Vec<&str> = o.tokens().iter().filter(|s| s.starts_with("UN_")).map(String::as_str).collect();
// Stage tag from an `UN_S<NN>_…` prop id (e.g. UN_S01_Asteroid_cmesh_…).
let stage = ids.iter().find_map(|s| {
let r = s.trim_start_matches("UN_");
let bytes = r.as_bytes();
(r.len() >= 3 && bytes[0] == b'S' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit())
.then(|| r[..3].to_string())
});
// Combatants: drop the stage props, dedup preserving order.
let mut units = Vec::new();
for id in ids {
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
if !prop && !units.iter().any(|u| u == id) {
units.push(id.to_string());
}
}
if !units.is_empty() {
out.push(UnitRoster { stage, units });
}
}
out
}
// ── Pilot roster (player squadron assignments) ──────────────────────────────────
/// The player squadron's flight assignments for a mission — which pilot flies each
/// callsign (`Rhino1` → `Katana`, `Bird1` → `Sandra`, …). Parsed from the
/// `UNITS`/`ZUNITS` player-unit tables (in `GP_HANGAR_ARSENAL.pak`); assignments
/// vary per mission (different missions field different wingmen).
#[derive(Debug, Clone)]
pub struct PilotRoster {
/// `(callsign, pilot)` pairs in flight order, e.g. `("Rhino1", "Katana")`.
pub pilots: Vec<(String, String)>,
/// The player craft unit id (`UN_f002_TCAF_DeltaSaber…`).
pub player_unit: Option<String>,
}
/// True for a `Callsign<N>-Pilot` token (a single hyphen, digit-suffixed callsign,
/// alphabetic pilot) — the flight-assignment shape.
fn is_assignment(s: &str) -> bool {
let Some((cs, pilot)) = s.split_once('-') else { return false };
!pilot.is_empty()
&& pilot.chars().all(|c| c.is_ascii_alphabetic())
&& cs.len() >= 2
&& cs.as_bytes()[cs.len() - 1].is_ascii_digit()
&& cs.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Load every player pilot roster from a pak (scan `GP_HANGAR_ARSENAL.pak`). One
/// per player-unit config; rosters with no assignment tokens are skipped.
pub fn load_pilot_rosters(pak: &PakArchive) -> Vec<PilotRoster> {
let mut out = Vec::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let t = o.tokens();
let pilots: Vec<(String, String)> = t
.iter()
.filter(|s| is_assignment(s))
.filter_map(|s| s.split_once('-').map(|(c, p)| (c.to_string(), p.to_string())))
.collect();
if pilots.len() >= 2 {
out.push(PilotRoster {
pilots,
player_unit: t.iter().find(|s| s.starts_with("UN_")).cloned(),
});
}
}
out
}
// ── Arsenal (player weapon options per hardpoint) ───────────────────────────────
/// The player's selectable weapons, by hardpoint (from the `UNITS` player-unit
/// tables in `GP_HANGAR_ARSENAL.pak`). These are the arsenal display ids — the
/// Delta Saber's nose guns (`Stiletto_BG1`, `Broad_Sword_SG1`, …) and arm missiles
/// (`Falcon_9AM`, `White_Shark_T53R`, …) the player equips in the hangar.
#[derive(Debug, Clone, Default)]
pub struct Arsenal {
/// Nose slot — the fixed forward gun options.
pub nose: Vec<String>,
/// The three arm hardpoints — missile / bomb / special options.
pub arm1: Vec<String>,
pub arm2: Vec<String>,
pub arm3: Vec<String>,
}
/// The weapon ids listed under a `STANDARD_<hp>` header (skipping its `Type` key),
/// up to the next `STANDARD_`/`PlayerSET_` header.
fn hardpoint_options(tokens: &[String], header: &str) -> Vec<String> {
let Some(start) = tokens.iter().position(|s| s == header) else { return Vec::new() };
tokens[start + 1..]
.iter()
.skip_while(|s| *s == "Type")
.take_while(|s| !s.starts_with("STANDARD_") && !s.starts_with("PlayerSET"))
.filter(|s| *s != "Type")
.cloned()
.collect()
}
/// Load the player arsenal from a pak (`GP_HANGAR_ARSENAL.pak`). Unions the weapon
/// options across every player-unit config, deduped in first-seen order.
pub fn load_arsenal(pak: &PakArchive) -> Arsenal {
let mut a = Arsenal::default();
let mut seen = [(); 4].map(|_| std::collections::BTreeSet::new());
let slots: [(&str, usize); 4] = [
("STANDARD_NOSE", 0),
("STANDARD_ARM1", 1),
("STANDARD_ARM2", 2),
("STANDARD_ARM3", 3),
];
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
if !o.tokens().first().is_some_and(|s| s.ends_with("UNITS")) {
continue;
}
for (header, idx) in slots {
let dst = match idx {
0 => &mut a.nose,
1 => &mut a.arm1,
2 => &mut a.arm2,
_ => &mut a.arm3,
};
for w in hardpoint_options(o.tokens(), header) {
if seen[idx].insert(w.clone()) {
dst.push(w);
}
}
}
}
a
}
// ── Generic record access (any of the ~105 schemas) ─────────────────────────────
/// A generically-decoded IDXD record: its table name, schema id, identity, and
/// every explicitly-set field. Use this to read the ~99 schemas without a bespoke
/// struct (missions, UI layouts, effects, enums, …).
#[derive(Debug, Clone)]
pub struct Record {
/// The table name — `token[0]` (e.g. `Weapon`, `StageResource`, `Sperkers`).
/// May carry a stray leading byte from the string-pool boundary on some
/// records; group by [`schema`](Self::schema), not this, for reliability.
pub table: String,
pub schema: u32,
pub id: Option<String>,
pub name: Option<String>,
/// Every explicitly-set field (`key → value`).
pub fields: BTreeMap<String, String>,
}
impl Record {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
Some(Record {
table: o.tokens().first().cloned().unwrap_or_default(),
schema: o.schema_hash,
id: o.get_raw("ID").map(str::to_string),
name: o.get_raw("Name").map(str::to_string),
fields: field_map(o),
})
}
pub fn f32(&self, key: &str) -> Option<f32> {
as_f32(self.fields.get(key))
}
pub fn i64(&self, key: &str) -> Option<i64> {
as_i64(self.fields.get(key))
}
}
/// Load every record of an arbitrary `schema` from a pak, generically. Pair with
/// the [`schema`] constants or a hash from the IDXD census.
pub fn load_records(pak: &PakArchive, schema: u32) -> Vec<Record> {
load_table(pak, schema, Record::from_idxd)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn as_f32_tolerates_trailing_f() {
assert_eq!(as_f32(Some(&"3.0f".to_string())), Some(3.0));
assert_eq!(as_f32(Some(&"-0.5".to_string())), Some(-0.5));
assert_eq!(as_f32(Some(&"Vessel,Craft".to_string())), None);
}
// Integration tests against a real disc — skipped unless SYLPHEED_DISC is set.
fn pak() -> Option<PakArchive> {
let disc = std::env::var("SYLPHEED_DISC").ok()?;
PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).ok()
}
#[test]
fn loads_weapons() {
let Some(pak) = pak() else { return };
let ws = load_weapons(&pak);
assert!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len());
let rocket = ws
.iter()
.find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket")))
.expect("Delta Saber rocket present");
assert_eq!(rocket.velocity, Some(3000.0));
assert_eq!(rocket.max_range, Some(6000.0));
assert_eq!(rocket.loading_count, Some(1000));
assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure"));
}
#[test]
fn loads_units_and_vessels() {
let Some(pak) = pak() else { return };
let units = load_units(&pak);
assert!(units.len() >= 80);
// Player craft: HP 1000, radar 500000.
let player = units
.iter()
.find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T")))
.expect("player craft present");
assert_eq!(player.hp, Some(1000.0));
assert_eq!(player.radar_range, Some(500000.0));
let vessels = load_vessels(&pak);
let flagship = vessels
.iter()
.find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship")))
.expect("SD-Battleship present");
assert_eq!(flagship.hp, Some(100000.0));
assert_eq!(flagship.turret_count, Some(25));
}
#[test]
fn loads_player_configs() {
let Some(pak) = pak() else { return };
let cfgs = load_player_configs(&pak);
assert!(!cfgs.is_empty());
let c = &cfgs[0];
assert_eq!(c.bullet_limit, Some(512));
assert_eq!(c.laser_limit, Some(32));
assert_eq!(c.rank_score_s, Some(10000));
}
#[test]
fn loads_characters() {
let Some(pak) = pak() else { return };
let chars = load_characters(&pak);
assert!(chars.len() >= 20, "expected many characters, got {}", chars.len());
let raymond = chars
.iter()
.find(|c| c.id.as_deref() == Some("CharacterRAYMOND"))
.expect("Raymond present");
assert_eq!(raymond.faction.as_deref(), Some("TCAF"));
assert!(!raymond.faces.is_empty());
assert!(raymond.faces.iter().all(|f| f.texture.ends_with(".t32")));
}
#[test]
fn loads_stages() {
let Some(pak) = pak() else { return };
let stages = load_stages(&pak);
let s01 = stages.iter().find(|s| s.id == "S01").expect("stage S01 present");
assert_eq!(s01.location.as_deref(), Some("Lebendorf"));
assert_eq!(s01.phases, 3);
assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl"));
assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl"));
// 16-mission main campaign is present.
for n in 1..=16 {
assert!(stages.iter().any(|s| s.id == format!("S{n:02}")), "missing S{n:02}");
}
}
#[test]
fn loads_squadrons() {
let Some(pak) = pak() else { return };
let sq = load_squadrons(&pak);
assert!(sq.len() >= 20, "expected many squadrons, got {}", sq.len());
// A TCAF Rhino flight of Delta Sabers, membership matching its count.
let rhino = sq
.iter()
.find(|s| {
s.side.as_deref() == Some("TCAF")
&& s.formation_id.as_deref().is_some_and(|f| f.contains("Rhino"))
&& s.members.iter().any(|m| m.contains("DeltaSaber"))
})
.expect("a TCAF Rhino Delta Saber squadron");
assert_eq!(rhino.members.len() as i64, rhino.count.unwrap_or(-1));
assert!(rhino.members.iter().all(|m| m.contains("DeltaSaber")));
}
#[test]
fn loads_demo_messages() {
let Some(pak) = pak() else { return };
let msgs = load_demo_messages(&pak);
assert!(msgs.len() > 200, "expected many dialogue lines, got {}", msgs.len());
// Most lines are `MSG_*` ids with a speaker; page keys extend the id.
let msg_ids = msgs.iter().filter(|m| m.id.starts_with("MSG_")).count();
assert!(msg_ids > msgs.len() * 9 / 10, "{msg_ids}/{}", msgs.len());
// Attributed dialogue (an explicit speaker) is a minority — most lines are
// unattributed system/continuation messages — but there are hundreds.
let attributed = msgs.iter().filter(|m| m.character.is_some()).count();
assert!(attributed > 500, "attributed lines: {attributed}");
let m = msgs.iter().find(|m| m.character.is_some() && !m.page_keys.is_empty()).unwrap();
assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id)));
}
#[test]
fn loads_unit_rosters() {
let Some(pak) = pak() else { return };
let rosters = load_unit_rosters(&pak);
assert!(rosters.len() > 20, "expected many rosters, got {}", rosters.len());
// At least a few tag their stage; all rosters carry combatants, no props.
assert!(rosters.iter().filter(|r| r.stage.is_some()).count() >= 4);
assert!(rosters.iter().all(|r| {
!r.units.is_empty() && r.units.iter().all(|u| !u.contains("Asteroid"))
}));
// A roster tagged S01 exists and names the player's Delta Saber.
let s01 = rosters.iter().find(|r| r.stage.as_deref() == Some("S01"));
assert!(s01.is_some_and(|r| r.units.iter().any(|u| u.contains("DeltaSaber"))));
}
#[test]
fn loads_pilot_rosters() {
let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return };
let rosters = load_pilot_rosters(&pak);
assert!(!rosters.is_empty(), "expected player pilot rosters");
// Some config assigns Katana to a Rhino callsign (she leads Rhino flight).
assert!(rosters.iter().any(|r| {
r.pilots.iter().any(|(cs, p)| cs.starts_with("Rhino") && p == "Katana")
}));
// Bird flight exists too.
assert!(rosters.iter().any(|r| r.pilots.iter().any(|(cs, _)| cs.starts_with("Bird"))));
}
#[test]
fn loads_arsenal() {
let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return };
let a = load_arsenal(&pak);
assert!(a.nose.len() >= 5 && a.arm1.len() >= 5, "nose {} arm1 {}", a.nose.len(), a.arm1.len());
assert!(a.nose.iter().any(|w| w.starts_with("Stiletto")));
assert!(a.arm1.iter().any(|w| w.starts_with("Falcon")));
// Headers never leak into the option lists.
assert!([&a.nose, &a.arm1, &a.arm2, &a.arm3]
.iter()
.all(|v| v.iter().all(|w| !w.starts_with("STANDARD_") && *w != "Type")));
}
#[test]
fn generic_records_read_any_schema() {
let Some(pak) = pak() else { return };
// Stage-resource table via the generic reader (no bespoke struct).
let stages = load_records(&pak, 0x3c9a_e32e);
assert!(!stages.is_empty());
assert!(stages.iter().all(|r| r.table.ends_with("StageResource")));
}
}

View File

@@ -59,6 +59,22 @@ pub mod slb;
// The movie manifest: authoritative movie → subtitle → voice index.
pub mod movie_manifest;
pub mod movie_voice;
pub mod game_data;
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;
/// Re-export the most commonly used types at the crate root.
pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject};

View File

@@ -0,0 +1,179 @@
//! Localization — resolve the game's text keys to display strings.
//!
//! Project Sylpheed stores its UI/story text in `IXUD` entries as **UTF-16LE**,
//! interleaved `text, key, text, key, …`: a prose string immediately followed by
//! the identifier that names it. Objectives (`S01_P1_Objective_00`), hints, lose
//! conditions, character names (`CharacterName_CharacterRAYMOND`) and cutscene
//! dialogue (`MSG_*`) all resolve this way. One pak per language — pass
//! `GP_MAIN_GAME_E.pak` for English, `GP_MAIN_GAME_J.pak` for Japanese, etc.
//!
//! ```no_run
//! use sylpheed_formats::{localization::TextIndex, PakArchive};
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
//! let text = TextIndex::build(&pak);
//! assert_eq!(text.character_name("CharacterRAYMOND"), Some("Raymond"));
//! for line in text.objectives("S01", 1) { println!("• {line}"); }
//! ```
use crate::pak::PakArchive;
use std::collections::BTreeMap;
/// A UTF-16 unit that belongs to a printable text run (ASCII + Latin-1 + Latin
/// Extended-A). Control codes and the CJK planes split tokens.
fn is_text_unit(u: u16) -> bool {
matches!(u, 0x20..=0x7e | 0xa0..=0xff | 0x100..=0x17f)
}
/// Split a UTF-16LE blob into printable tokens (mirrors the subtitle tokenizer:
/// step 2 inside a run, 1 on a break to resync odd alignment).
fn utf16_tokens(bytes: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut i = 0;
while i + 1 < bytes.len() {
let u = u16::from_le_bytes([bytes[i], bytes[i + 1]]);
if is_text_unit(u) {
if let Some(c) = char::from_u32(u as u32) {
cur.push(c);
}
i += 2;
} else {
if cur.chars().count() >= 2 {
out.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
i += 1;
}
}
if cur.chars().count() >= 2 {
out.push(cur);
}
out
}
/// An identifier token — the key half of a `text, key` pair.
fn is_key(s: &str) -> bool {
s.len() >= 5
&& s.contains('_')
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s.chars().any(|c| c.is_ascii_uppercase())
}
/// A display-text token — anything with a lowercase letter that isn't itself a key.
fn is_prose(s: &str) -> bool {
!is_key(s) && s.chars().any(|c| c.is_ascii_lowercase())
}
/// Text-key → display-string index for one language, built from a `GP_MAIN_GAME_*`
/// pak.
#[derive(Debug, Clone, Default)]
pub struct TextIndex {
entries: BTreeMap<String, String>,
}
impl TextIndex {
/// Scan a language pak's `IXUD` entries and index every `text → key` pair.
pub fn build(pak: &PakArchive) -> Self {
let mut entries = BTreeMap::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
if b.len() < 4 || &b[0..4] != b"IXUD" {
continue;
}
let toks = utf16_tokens(&b);
for w in toks.windows(2) {
if is_key(&w[1]) && is_prose(&w[0]) {
entries.entry(w[1].clone()).or_insert_with(|| w[0].clone());
}
}
}
TextIndex { entries }
}
/// The display string for a text key, if present.
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
/// Number of indexed keys.
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// The objective lines for a stage/phase, e.g. `("S01", 1)`. Empty if none
/// resolve (unused objective slots are simply absent on disc).
pub fn objectives(&self, stage: &str, phase: u32) -> Vec<&str> {
self.numbered(&format!("{stage}_P{phase}_Objective"))
}
/// The lose-condition lines for a stage/phase.
pub fn lose_conditions(&self, stage: &str, phase: u32) -> Vec<&str> {
self.numbered(&format!("{stage}_P{phase}_Lose"))
}
/// The hint lines for a stage/phase.
pub fn hints(&self, stage: &str, phase: u32) -> Vec<&str> {
self.numbered(&format!("{stage}_P{phase}_Hint"))
}
/// A character's display name from its id (`CharacterRAYMOND` → `Raymond`).
pub fn character_name(&self, character_id: &str) -> Option<&str> {
self.get(&format!("CharacterName_{character_id}"))
}
/// Collect a `<base>_NN` run of lines while they resolve.
fn numbered(&self, base: &str) -> Vec<&str> {
(0..16)
.filter_map(|i| self.get(&format!("{base}_{i:02}")))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenizes_mixed_runs() {
// "Hi" NUL "K_1" as UTF-16LE → two tokens.
let mut b = Vec::new();
for s in ["Hi", "\0", "K_1x"] {
for c in s.chars() {
b.extend_from_slice(&(c as u16).to_le_bytes());
}
}
let t = utf16_tokens(&b);
assert!(t.contains(&"Hi".to_string()));
assert!(t.contains(&"K_1x".to_string()));
}
#[test]
fn key_and_prose_classify() {
assert!(is_key("S01_P1_Objective_00"));
assert!(is_key("CharacterName_CharacterRAYMOND"));
assert!(!is_key("Repel the attack."));
assert!(is_prose("Repel the attack."));
assert!(is_prose("Raymond"));
assert!(!is_prose("S01_P1_Objective_00"));
}
// Disc-backed — skipped unless SYLPHEED_DISC is set.
#[test]
fn resolves_real_text() {
let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")) else { return };
let t = TextIndex::build(&pak);
assert!(t.len() > 5000, "expected a large index, got {}", t.len());
assert_eq!(t.character_name("CharacterRAYMOND"), Some("Raymond"));
assert_eq!(
t.get("S01_P1_Objective_00"),
Some("Repel the enemy's surprise attack.")
);
assert!(!t.objectives("S01", 1).is_empty());
}
}

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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,81 @@
//! The movie manifest: the game's authoritative movie → subtitle → **voice**
//! index (statically reversed).
//! The movie manifest: the game's authoritative **mission → phase → cutscene**
//! table (statically reversed). Beyond the movie→voice binding, it defines the
//! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay,
//! + voice) plays at each mission phase, and of what kind (story intro, in-mission
//! phase cutscene, resupply, …).
//!
//! `dat/tables.pak` holds one `IDXD` table (a `Z1`+zlib block) whose string pool
//! lists every cutscene in play order. Each movie contributes a run of ASCII
//! strings, always led by `<movie>.wmv` and optionally followed by:
//! - `<pak>+…​.prt` — an overlay/print-art reference,
//! - `<pak>+SUBTITLE_<movie>.tbl` — its caption timing track,
//! - `VOICE_<token>` — the **voice track basename**.
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
//!
//! The voice token is the payoff: it is the real bank name to look up in
//! `sounds.tbl`, and it is **not** always `VOICE_<movie>`. Most story/radio
//! movies use `VOICE_<movie>` (in `<lang>\Movie\`), but several `hokyu_*`
//! (resupply) movies bind to in-mission radio clips like `VOICE_D_450` (in
//! `<lang>\etc\`), and many `hokyu_*` movies + the boot logos have **no** voice
//! token at all — i.e. the game plays no voice-over for them. Guessing
//! `VOICE_<movie>` therefore both misses the `VOICE_D_*` cases and invents a
//! non-existent track for the silent ones; this manifest is the ground truth.
//! One `IDXD` table (`Z1`+zlib block), schema `0x067025b9`. Unlike a flat IDXD
//! object, this one is an **array of records**, and its string pool is laid out
//! as **two consecutive arrays**:
//! 1. the **slot KEYS** — `LOGO1`, `ADVERTISE_MOVIE`, `STAFF_ROLL`, then one
//! per cutscene: `MS<NN><X>`, `STAGE<NN>_PHASE0<N>`, `STAGE<NN>_PHASE_END…`,
//! `S<NN>_SUPPLY_<ACROPOLIS|TANKER>`, in play order;
//! 2. the **value RECORDS**, in the same order — each a run led by `<movie>.wmv`
//! and (optionally) a `<pak>+…​.prt` overlay (field `TELOP`), a
//! `<pak>+SUBTITLE_<movie>.tbl` (field `SUBTITLE`), and a `VOICE_<token>`
//! (field `VOICETRACK`). A few slot keys are **valueless** (e.g. stages 5 and
//! 12 have no resupply video) — those slots have no record and are skipped.
//!
//! We read the manifest by grouping the string pool on `.wmv` (the pool is
//! emitted in the same order as the records), which is robust without decoding
//! the `IDXD` record structure.
//! The **slot key is the semantic role** — this is how the game separates a
//! cutscene voice from in-mission radio chatter (structurally, by which slot/bank,
//! not one boolean): `MS*` = story intro (`VOICE_S*`), `STAGE*_PHASE*` = radio-box
//! cutscene (`VOICE_RT*`), `S*_SUPPLY_*` = resupply (`VOICE_D_45x` or none).
//! Resupply videos are **reused across stages** (`S04_SUPPLY_ACROPOLIS` →
//! `hokyu_DS_s02A`), so the mapping is genuinely a table, not derivable from names.
//!
//! The voice token is likewise **not** always `VOICE_<movie>`: most movies use
//! `VOICE_<movie>` (in `<lang>\Movie\`), but the resupply cutscenes bind to
//! in-mission radio clips like `VOICE_D_450` (in `<lang>\etc\`), and some have no
//! voice at all. This manifest is the ground truth.
//!
//! ## Alignment
//!
//! Slot keys and value records would be a 1:1 positional zip but for the valueless
//! slots. We recover the gaps without decoding the binary node/index region by
//! using the fully-derivable **`MS<NN><X>` → `S<NN><X>` anchors**: a non-`MS` slot
//! whose record would be an `MS` target actually belongs to the upcoming `MS`
//! slot, so the non-`MS` slot is a gap. (Decoding the node region would make this
//! exact and also unlock the defaulted numeric fields in other IDXD tables.)
use crate::slb::VoiceLang;
/// One movie's manifest row.
/// The role a cutscene slot plays in the mission flow, from its slot key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovieKind {
/// `LOGO*`, `ADVERTISE_MOVIE`, `STAFF_ROLL` — boot/system movies.
System,
/// `MS<NN><X>` — story intro movie (camera on the speaker; stereo `VOICE_S*`).
Intro,
/// `STAGE<NN>_PHASE0<N>` — in-mission phase cutscene (radio box; `VOICE_RT*`).
Phase,
/// `STAGE<NN>_PHASE_END[_0N]` — phase-end cutscene.
PhaseEnd,
/// `S<NN>_SUPPLY_<ACROPOLIS|TANKER>` — resupply (hokyu) cutscene.
Supply,
}
/// One cutscene slot's manifest row: its semantic slot plus the bound assets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovieEntry {
/// Movie basename without extension, e.g. `S13A`, `hokyu_DS_s13A`.
/// The slot key, e.g. `STAGE01_PHASE01`, `MS00A`, `S02_SUPPLY_ACROPOLIS`.
/// Empty when parsed from a blob without the slot-key array (fallback).
pub slot: String,
/// The slot's role in the mission flow (intro vs. phase cutscene vs. supply …).
pub kind: MovieKind,
/// Mission/stage number (`STAGE<NN>` / `MS<NN>` / `S<NN>_SUPPLY`), if any.
pub mission: Option<u8>,
/// Phase number for [`MovieKind::Phase`]/[`MovieKind::PhaseEnd`], if any.
pub phase: Option<u8>,
/// Movie basename without extension, e.g. `S13A`, `RT01A`, `hokyu_DS_s13A`.
pub movie: String,
/// Voice bank basename (e.g. `VOICE_S13A`, `VOICE_D_450`), or `None` when the
/// game assigns no voice-over to this movie.
/// Voice bank basename (`VOICE_S13A`, `VOICE_D_450`), or `None` for no voice.
pub voice_token: Option<String>,
/// Subtitle table basename (`SUBTITLE_S13A.tbl`), pak prefix stripped, if any.
pub subtitle: Option<String>,
/// On-screen text-overlay (`TELOP`) basename (`pwrt01.prt`), if any.
pub telop: Option<String>,
}
/// Does this blob look like the movie manifest? (`IDXD` whose string pool
@@ -44,25 +89,154 @@ pub fn is_manifest(bytes: &[u8]) -> bool {
&& contains(bytes, b"SUBTITLE_")
}
/// Parse the manifest's string pool into per-movie rows, in play order.
/// The field-type keys that appear once in the value region as a schema template.
const FIELD_KEYS: [&str; 4] = ["MOVIE", "VOICETRACK", "SUBTITLE", "TELOP"];
/// Parse the manifest into per-slot cutscene rows, in play order.
///
/// Each returned [`MovieEntry`] carries a movie; valueless slots (e.g. stage 5's
/// absent resupply) are omitted. When the two-array slot/record layout is present
/// (the real manifest) every row is tagged with its authoritative slot/kind/
/// mission/phase; otherwise (a blob without the key array) the kind is inferred
/// from the movie name and `slot` is empty.
pub fn parse(bytes: &[u8]) -> Vec<MovieEntry> {
let mut entries: Vec<MovieEntry> = Vec::new();
for run in ascii_runs(bytes, 3) {
if let Some(name) = run.strip_suffix(".wmv") {
entries.push(MovieEntry {
let toks = ascii_runs(bytes, 3);
// Locate the slot-key array (`LOGO1` … first `<movie>.wmv`) and the value
// region (from the first `.wmv` onward). Fall back to a keyless scrape.
let first_wmv = toks.iter().position(|t| t.ends_with(".wmv"));
let key_start = toks.iter().position(|t| t == "LOGO1");
let (slot_keys, value_toks): (Vec<&str>, &[String]) = match (key_start, first_wmv) {
(Some(k), Some(w)) if k < w => (
toks[k..w].iter().map(String::as_str).collect(),
&toks[w..],
),
_ => (Vec::new(), toks.as_slice()),
};
// Build the value records (drop the schema-template field keys).
let mut records: Vec<MovieEntry> = Vec::new();
for t in value_toks.iter().filter(|t| !FIELD_KEYS.contains(&t.as_str())) {
if let Some(name) = t.strip_suffix(".wmv") {
records.push(MovieEntry {
slot: String::new(),
kind: kind_from_movie(name),
mission: None,
phase: None,
movie: name.to_string(),
voice_token: None,
subtitle: None,
telop: None,
});
} else if run.starts_with("VOICE_") {
// Belongs to the movie record currently being built.
if let Some(last) = entries.last_mut() {
if last.voice_token.is_none() {
last.voice_token = Some(run.clone());
} else if let Some(rec) = records.last_mut() {
if let Some(v) = t.strip_prefix("VOICE_") {
if rec.voice_token.is_none() {
rec.voice_token = Some(format!("VOICE_{v}"));
}
} else if t.ends_with(".tbl") {
rec.subtitle.get_or_insert_with(|| after_plus(t));
} else if t.ends_with(".prt") {
rec.telop.get_or_insert_with(|| after_plus(t));
}
}
}
entries
// Attach slot keys to records, recovering valueless-slot gaps via MS anchors.
if !slot_keys.is_empty() {
let ms_targets: std::collections::HashSet<String> = slot_keys
.iter()
.filter_map(|k| ms_target(k))
.collect();
let mut j = 0usize;
for slot in &slot_keys {
if j >= records.len() {
break;
}
// A gap: this slot's "record" is actually the next MS slot's movie.
let would_be = &records[j].movie;
let is_gap = match ms_target(slot) {
// MS slot: only binds if its record is the expected `S<NN><X>`.
Some(expected) => *would_be != expected,
// Non-MS slot: a gap iff the record belongs to an upcoming MS slot.
None => ms_targets.contains(would_be),
};
if is_gap {
continue;
}
let (kind, mission, phase) = classify_slot(slot);
let rec = &mut records[j];
rec.slot = (*slot).to_string();
rec.kind = kind;
rec.mission = mission;
rec.phase = phase;
j += 1;
}
}
records
}
/// If `slot` is an `MS<NN><X>` intro slot, the movie it must bind to (`S<NN><X>`).
fn ms_target(slot: &str) -> Option<String> {
let rest = slot.strip_prefix("MS")?;
// `<NN><X>`: two digits then a letter suffix.
if rest.len() >= 3 && rest[..2].bytes().all(|b| b.is_ascii_digit()) {
Some(format!("S{rest}"))
} else {
None
}
}
/// Semantic role + mission/phase from a slot key.
fn classify_slot(slot: &str) -> (MovieKind, Option<u8>, Option<u8>) {
if let Some(rest) = slot.strip_prefix("MS") {
if rest.len() >= 2 {
return (MovieKind::Intro, rest[..2].parse().ok(), None);
}
}
if let Some(rest) = slot.strip_prefix("STAGE") {
let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(m) = nn.parse::<u8>() {
if slot.contains("PHASE_END") {
// trailing `_NN` if present (`STAGE12_PHASE_END_02`)
let phase = slot.rsplit('_').next().and_then(|x| x.parse().ok());
return (MovieKind::PhaseEnd, Some(m), phase);
}
// `STAGE<NN>_PHASE0<N>`
let phase = rest[nn.len()..]
.strip_prefix("_PHASE")
.and_then(|p| p.parse().ok());
return (MovieKind::Phase, Some(m), phase);
}
}
if slot.contains("_SUPPLY") {
if let Some(rest) = slot.strip_prefix('S') {
let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
return (MovieKind::Supply, nn.parse().ok(), None);
}
}
(MovieKind::System, None, None)
}
/// Fallback kind from a movie basename (used when no slot-key array is present).
fn kind_from_movie(movie: &str) -> MovieKind {
if movie.starts_with("hokyu_") {
MovieKind::Supply
} else if movie.starts_with("RT") {
MovieKind::Phase
} else if movie.len() >= 3
&& movie.starts_with('S')
&& movie[1..3].bytes().all(|b| b.is_ascii_digit())
{
MovieKind::Intro
} else {
MovieKind::System
}
}
/// The part of a `<pak>+<name>` reference after the `+` (or the whole string).
fn after_plus(s: &str) -> String {
s.rsplit('+').next().unwrap_or(s).to_string()
}
/// The voice bank basename bound to `movie`, if the manifest lists one.
@@ -151,12 +325,82 @@ mod tests {
#[test]
fn groups_movies_and_binds_voice() {
// Keyless fallback path: movie + voice + subtitle + inferred kind.
let m = parse(&synth());
assert_eq!(m.len(), 3);
assert_eq!(m[0].movie, "S13A");
assert_eq!(m[0].voice_token.as_deref(), Some("VOICE_S13A"));
assert_eq!(m[0].kind, MovieKind::Intro);
assert_eq!(m[0].subtitle.as_deref(), Some("SUBTITLE_S13A.tbl"));
assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450"));
assert_eq!(m[1].kind, MovieKind::Supply);
assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over");
assert_eq!(m[2].kind, MovieKind::Supply);
}
/// Build a two-array (keys-then-records) manifest like the real one.
fn synth_two_array(keys: &[&str], records: &[&[&str]]) -> Vec<u8> {
let mut b = b"IDXD".to_vec();
b.extend_from_slice(&[0, 0, 0, 0x05, 0, 0]);
for k in keys {
b.extend_from_slice(k.as_bytes());
b.push(0);
}
for rec in records {
for s in *rec {
b.extend_from_slice(s.as_bytes());
b.push(0);
}
}
b
}
#[test]
fn two_array_slots_kind_mission_phase() {
let blob = synth_two_array(
&["LOGO1", "MS01A", "STAGE01_PHASE01", "STAGE01_PHASE_END_02"],
&[
&["logo1.wmv", "MOVIE"],
&["S01A.wmv", "eng.pak+SUBTITLE_S01A.tbl", "VOICE_S01A"],
&["RT01A.wmv", "eng.pak+pwrt01.prt", "VOICE_RT01A", "VOICETRACK"],
&["RT01C_2.wmv", "VOICE_RT01C_2"],
],
);
let m = parse(&blob);
assert_eq!(m.len(), 4);
assert_eq!((m[0].slot.as_str(), m[0].kind), ("LOGO1", MovieKind::System));
assert_eq!(
(m[1].slot.as_str(), m[1].kind, m[1].mission),
("MS01A", MovieKind::Intro, Some(1))
);
assert_eq!(
(m[2].slot.as_str(), m[2].kind, m[2].mission, m[2].phase),
("STAGE01_PHASE01", MovieKind::Phase, Some(1), Some(1))
);
assert_eq!(m[2].telop.as_deref(), Some("pwrt01.prt"));
assert_eq!(
(m[3].slot.as_str(), m[3].kind, m[3].phase),
("STAGE01_PHASE_END_02", MovieKind::PhaseEnd, Some(2))
);
}
#[test]
fn valueless_supply_slot_is_skipped_via_ms_anchor() {
// S05_SUPPLY has no record; its "record" (S06A) belongs to the next MS slot.
let blob = synth_two_array(
&["LOGO1", "MS05A", "S05_SUPPLY_ACROPOLIS", "MS06A"],
&[
&["logo1.wmv"],
&["S05A.wmv", "VOICE_S05A"],
&["S06A.wmv", "VOICE_S06A"],
],
);
let m = parse(&blob);
assert_eq!(m.len(), 3, "the valueless supply slot yields no entry");
assert_eq!((m[1].slot.as_str(), m[1].movie.as_str()), ("MS05A", "S05A"));
// S06A binds to MS06A, NOT to the intervening valueless supply slot.
assert_eq!((m[2].slot.as_str(), m[2].movie.as_str()), ("MS06A", "S06A"));
assert!(m.iter().all(|e| e.slot != "S05_SUPPLY_ACROPOLIS"));
}
#[test]

View File

@@ -162,6 +162,43 @@ pub fn track_demo_ids(lang_pak: &PakArchive, movie_basename: &str) -> Vec<u32> {
.collect()
}
/// Per-line `(demo id, start seconds)` for a movie's timing track — the radio-box
/// voice schedule. Each `MSG_DEMO_<id>` reference takes the start time of the
/// timing that follows it (a caption may list two demo lines under one timing;
/// both then share that start). Empty for inline-text / absent tracks (those are
/// story movies whose voice is the single manifest `VOICETRACK`, not per-line).
pub fn track_voice_cues(lang_pak: &PakArchive, movie_basename: &str) -> Vec<(u32, f32)> {
let Some(Ok(track)) = lang_pak.read_by_hash(track_key(movie_basename)) else {
return Vec::new();
};
if !is_ixud(&track) {
return Vec::new();
}
let toks = utf16le_tokens(&track);
let start = toks
.iter()
.position(|t| t.ends_with("SUBTITLE"))
.map(|i| i + 1)
.unwrap_or(0);
let mut out = Vec::new();
let mut pending: Vec<u32> = Vec::new();
for tok in &toks[start..] {
match parse_timing(tok) {
Some((s, _)) => {
for d in pending.drain(..) {
out.push((d, s));
}
}
None => {
if let Some(d) = demo_ref(tok) {
pending.push(d);
}
}
}
}
out
}
/// Build `demo id → ordered caption lines` from a `GP_MAIN_GAME_<L>` pack.
///
/// Scans every IXUD block, pairing each text token with the immediately

View File

@@ -0,0 +1,142 @@
//! Cutscene-voice resolution: movie → sound-id → continuous-stream byte region.
//!
//! Reverse-engineered 2026-07-22 (Canary file-I/O trace, user-confirmed by ear).
//! The movie voices form **one continuous XMA stream**, physically chunked into
//! `<lang>\Movie\VOICE_*.slb` `sound.pak` TOC entries whose boundaries do **not**
//! align with the cutscene cues — a single cue routinely spans two `.slb` chunks,
//! so a `.slb` does not necessarily hold the track its name claims. Each cue's
//! audio ends at an inline **trailer descriptor** `(sound_id: u32be, 0x11, …)`
//! whose id repeats at +0x800. Cue N's audio = the stream bytes
//! `[descriptor(N-1) .. descriptor(N)]`, read continuously across chunk
//! boundaries. Verified: decoded voice length matches each movie within ~0.3 s,
//! including cross-boundary cues (RT01B/RT01C span `VOICE_ADV`→`VOICE_RT01A`→…).
//!
//! Resolution chain:
//! 1. movie → cue name (`ADVERTISE_MOVIE` manifest `VOICETRACK`, e.g. `VOICE_RT01A`)
//! 2. cue name → sound-id (master sound registry, [`registry_voice_ids`], → 1601)
//! 3. sound-id → `[start, end]` global offsets via [`find_descriptor`] over the
//! `sound.pNN` stream (the caller supplies the bytes + the physical anchor).
use std::collections::HashMap;
/// Parse the master sound registry — the large per-language `tables.pak` IDXD
/// entry (schema `0x13cb84ba`) that carries the `<lang>\Movie\VOICE_*.slb` paths —
/// into `cue-name → sound-id` from its `NAME\0 ID\0` string pool
/// (e.g. `VOICE_ADV`→1600, `VOICE_RT01A`→1601).
pub fn registry_voice_ids(registry: &[u8]) -> HashMap<String, u32> {
let mut toks: Vec<&[u8]> = Vec::new();
let mut start = 0usize;
for i in 0..registry.len() {
if registry[i] == 0 {
if i > start {
toks.push(&registry[start..i]);
}
start = i + 1;
}
}
let mut out = HashMap::new();
for w in toks.windows(2) {
let (name, num) = (w[0], w[1]);
if name.starts_with(b"VOICE_")
&& !num.is_empty()
&& num.iter().all(u8::is_ascii_digit)
{
if let (Ok(n), Ok(id)) = (
std::str::from_utf8(name),
std::str::from_utf8(num).unwrap().parse::<u32>(),
) {
out.entry(n.to_string()).or_insert(id);
}
}
}
out
}
/// The constant marker following a cue's sound-id in its inline trailer.
const DESC_MARK: u32 = 0x11;
/// The trailer repeats its id this many bytes later; used to reject a coincidental
/// `(id, 0x11)` pair inside XMA audio (probability of a false match is ~2⁻⁶⁴).
const DESC_REPEAT: usize = 0x800;
/// Byte offset (within `buf`, a slice of the continuous voice stream) of cue
/// `id`'s trailer descriptor: the first big-endian `(id, 0x11)` pair whose id
/// repeats at `+0x800`. `None` if absent in the slice.
pub fn find_descriptor(buf: &[u8], id: u32) -> Option<usize> {
if buf.len() < DESC_REPEAT + 4 {
return None;
}
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
if be(o) == id && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
return Some(o);
}
o += 4;
}
None
}
/// Plausible sound-id range for a trailer (all real cue ids are well under this).
const ID_MAX: u32 = 0x1_0000;
/// Offset of the nearest cue trailer strictly **before** `before` — any plausible
/// id. Used to bound a cue's START when its `id-1` predecessor doesn't exist
/// (the sound-id sequence has gaps, e.g. `VOICE_D_453`=7142 → `VOICE_D_454`=7188).
/// Scans downward and returns the first (largest-offset) valid `(id, 0x11)` with
/// the `+0x800` id-repeat. `None` if there is no trailer below `before`.
pub fn find_descriptor_before(buf: &[u8], before: usize) -> Option<usize> {
if before < 4 {
return None;
}
let cap = buf.len().checked_sub(DESC_REPEAT + 4)?;
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
// Start strictly below `before` (4-aligned) so the target's own trailer is
// never returned as its predecessor.
let mut o = (before - 1).min(cap) & !3;
loop {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
return Some(o);
}
if o < 4 {
return None;
}
o -= 4;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_parses_name_id_pairs() {
let mut b = Vec::new();
for tok in [
b"VOICE_ADV".as_slice(),
b"1600",
b"VOICE_RT01A",
b"1601",
b"NOT_A_VOICE",
b"9",
] {
b.extend_from_slice(tok);
b.push(0);
}
let ids = registry_voice_ids(&b);
assert_eq!(ids.get("VOICE_ADV"), Some(&1600));
assert_eq!(ids.get("VOICE_RT01A"), Some(&1601));
assert_eq!(ids.get("NOT_A_VOICE"), None);
}
#[test]
fn find_descriptor_matches_repeat() {
let mut b = vec![0u8; 0x900];
b[0..4].copy_from_slice(&1601u32.to_be_bytes());
b[4..8].copy_from_slice(&0x11u32.to_be_bytes());
b[DESC_REPEAT..DESC_REPEAT + 4].copy_from_slice(&1601u32.to_be_bytes());
assert_eq!(find_descriptor(&b, 1601), Some(0));
assert_eq!(find_descriptor(&b, 1600), None);
}
}

View File

@@ -0,0 +1,687 @@
//! Whole-ship assembly from XBG7 part families.
//!
//! Project Sylpheed's capital ships are never stored as a single mesh. A ship is
//! modelled, then **split into destructible parts** — hull segments, bridge,
//! engines, integral turrets — each shipped as its own XBG7 resource inside a
//! `hidden/resource3d/Stage_SNN.xpr` container. The split lets the game hide a
//! part when the player destroys it (bridge, shield generator, thruster) and swap
//! in a `_break` variant.
//!
//! Each part's vertices are authored around the origin; the world placement lives
//! in the **primary composite scene graph** `e_rou_<id>` (see [`assemble_ship`]).
//! Its node hierarchy (`rou_<id>_root`→`…_front`/`…_rear`→`rou_<id>_bdy_NN`) names
//! the separate hull resources and carries their world TRS — the cross-resource
//! analogue of how [`mesh::node_transforms`] poses one model's sub-parts. The
//! `GN_*` nodes in the same composite are the Vessel hardpoint frames (bridge /
//! shield / engine / turret mounts, matching the IDXD recipe `Frame` names).
//!
//! Resource naming (learned from the retail stage containers):
//! - `<id>` is `[a-z]NNN` (`e106`, `f105`, `n050`). The leading letter is the
//! faction: `e` = ADAN (enemy), `f` = TCAF (player side), `n` = neutral /
//! structures / debris.
//! - A base part is `<id>_<name>` (`e106_bdy_01`, `e106_brg_01`, `f105_sld_02`).
//! - `_l` / `_m` / `_d`(`NN`) suffixes are lower level-of-detail copies; `_bNN` /
//! `_dead` / `_break` are destruction geometry; `_joint` / `_open` / `_Near` are
//! logic/animation nodes. All are skipped.
//! - `e_rou_<id>` is the primary composite; `e_rou_<id>_eng` / `_wep_*` are LOCAL
//! detail rigs (their nodes are in their own frame, not ship-space) and are NOT
//! used for placement.
use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart};
use std::collections::HashSet;
/// Which side a ship belongs to, from the first letter of its resource id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Faction {
/// `e…` — ADAN (the enemy).
Adan,
/// `f…` — TCAF (the player's coalition).
Tcaf,
/// `n…` — neutral: stations, structures, asteroids, debris.
Neutral,
/// Anything else (`j…`, `s…`, …).
Other,
}
impl Faction {
fn from_id(id: &str) -> Faction {
match id.as_bytes().first() {
Some(b'e') => Faction::Adan,
Some(b'f') => Faction::Tcaf,
Some(b'n') => Faction::Neutral,
_ => Faction::Other,
}
}
/// Short human label.
pub fn label(self) -> &'static str {
match self {
Faction::Adan => "ADAN",
Faction::Tcaf => "TCAF",
Faction::Neutral => "Neutral",
Faction::Other => "Other",
}
}
}
/// A ship reconstructed from one XBG7 part family in a stage container.
#[derive(Debug, Clone)]
pub struct ShipModel {
/// The family id, e.g. `e106` — matches a [`crate::game_data::Vessel`] whose
/// `model` is `rou_e106`.
pub id: String,
/// Faction inferred from the id's leading letter.
pub faction: Faction,
/// Base geometry resource names to draw together, in directory order.
pub parts: Vec<String>,
/// Whether a composite scene graph (`e_rou_<id>`) exists — i.e. this is a
/// real assembled ship. Families without one (fighter morph sets, shared
/// turret/prop parts) have no placement data and would stack at the origin.
pub has_composite: bool,
}
/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
/// → `e106`). Returns `None` for scene/logic resources (`e_rou_e106`) and
/// anything not starting with a letter + three digits.
pub fn ship_id_of(resource: &str) -> Option<&str> {
let b = resource.as_bytes();
if b.len() >= 4
&& b[0].is_ascii_lowercase()
&& b[1].is_ascii_digit()
&& b[2].is_ascii_digit()
&& b[3].is_ascii_digit()
{
// Reject a longer alnum run (`e1234…`): the id is exactly letter+3 digits,
// followed by end-of-string or a non-alphanumeric (`_`).
if b.get(4).is_none_or(|c| !c.is_ascii_alphanumeric()) {
return Some(&resource[..4]);
}
}
None
}
/// Whether a resource is a base geometry part (not a LOD copy, destruction
/// variant, or scene/logic node) that should be drawn in a whole-ship render.
pub fn is_base_part(resource: &str) -> bool {
if ship_id_of(resource).is_none() {
return false;
}
// Level-of-detail copies of a base part: a trailing `_l`/`_m`/`_d` token,
// optionally with a number (`_d01`, `_l02`).
if let Some(last) = resource.rsplit('_').next() {
let mut cs = last.chars();
if matches!(cs.next(), Some('l' | 'm' | 'd')) && cs.all(|c| c.is_ascii_digit()) {
return false;
}
}
// Destruction / logic geometry.
if resource.contains("break")
|| resource.contains("dead")
|| resource.contains("_joint")
|| resource.contains("_open")
|| resource.contains("_Near")
{
return false;
}
// Break-piece geometry carries a bare `b` / `bNN` token (`e106_brg_01_b_02`,
// `e108_eng_01_b01`). `bdy` etc. are safe — only an exact `b`-then-digits token
// counts.
if resource.split('_').skip(1).any(|t| {
t == "b" || (t.starts_with('b') && t.len() >= 2 && t[1..].bytes().all(|c| c.is_ascii_digit()))
}) {
return false;
}
true
}
/// Group every base part in an XPR2 container into whole ships, keyed by id.
///
/// Ships are returned sorted by id. A stage container also holds shared turret /
/// prop models as their own ids — every group with at least one base part is
/// returned; the caller can filter by [`ShipModel::parts`] length or faction.
pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
let names = xbg7_resource_names(bytes);
let mut ids: Vec<String> = Vec::new();
let mut ships: std::collections::HashMap<String, ShipModel> = std::collections::HashMap::new();
for n in &names {
if !is_base_part(n) {
continue;
}
let id = ship_id_of(n).unwrap().to_string();
let entry = ships.entry(id.clone()).or_insert_with(|| {
ids.push(id.clone());
let has_composite = !composites_for(&names, &id).is_empty();
ShipModel {
id: id.clone(),
faction: Faction::from_id(&id),
parts: Vec::new(),
has_composite,
}
});
entry.parts.push(n.clone());
}
ids.sort();
ids.into_iter().map(|id| ships.remove(&id).unwrap()).collect()
}
/// Map a composite scene-graph node name to the geometry resource it draws.
/// Hull nodes are `rou_<id>_<part>[_root|_mov|_NN]`; the geometry resource is the
/// `<id>_<part>` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to
/// nothing and only pose their children.
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
let try_stem = |s: &str| -> Option<String> {
if resources.contains(s) {
return Some(s.to_string());
}
// Squeezed digit run (`wep02` names the `wep_02_01` resource): insert an
// underscore before the digits and try the `_01` first-part too.
if let Some(pos) = s.rfind(|c: char| c.is_ascii_digit()) {
let mut start = pos;
while start > 0 && s.as_bytes()[start - 1].is_ascii_digit() {
start -= 1;
}
if start > 0 && s.as_bytes()[start - 1] != b'_' {
let split = format!("{}_{}", &s[..start], &s[start..]);
if resources.contains(&split) {
return Some(split);
}
let first = format!("{split}_01");
if resources.contains(&first) {
return Some(first);
}
}
}
None
};
if let Some(r) = try_stem(stem) {
return Some(r);
}
for suf in ["_root", "_mov"] {
if let Some(s) = stem.strip_suffix(suf) {
if let Some(r) = try_stem(s) {
return Some(r);
}
}
}
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
if let Some(pos) = stem.rfind('_') {
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
if let Some(r) = try_stem(&stem[..pos]) {
return Some(r);
}
}
}
None
}
/// The trailing digit run of a name (`GN_Bridge_01` → `01`, `sld_02` → `02`).
fn trailing_index(name: &str) -> &str {
let bytes = name.as_bytes();
let mut start = bytes.len();
while start > 0 && bytes[start - 1].is_ascii_digit() {
start -= 1;
}
&name[start..]
}
/// Which stage-container resources are the composite scene graphs for ship `id`
/// (`e_rou_e106`, `e_rou_e106_eng`, …). Excludes destruction (`_break`) scenes.
fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> {
let needle = format!("rou_{id}");
names
.iter()
.filter(|n| {
n.contains(&needle)
&& ship_id_of(n).is_none()
&& !n.contains("break")
&& !n.contains("dead")
})
.collect()
}
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
/// ship-local frame — the placement the game builds at runtime.
///
/// Fully static and EXACT (validated part-for-part against an e106 runtime
/// capture — see `docs/re/ship-placement-runtime-capture.md`):
/// 1. **Composite nodes** — every `rou_*` node in the primary composite
/// (`e_rou_<id>`) places a geometry resource, INCLUDING repeated instances
/// and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull).
/// 2. **Detail rigs** — `e_rou_<id>_eng` is the engine cluster rig; its nodes
/// (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary
/// composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`.
/// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at
/// their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS).
/// 4. **Mirrored twins** — a port/starboard pair (`bdy_01`/`bdy_02`) shares one
/// geometry; the instance whose lateral offset points the geometry's dominant
/// side inboard is drawn X-reflected (capture-verified; det < 0 → the caller
/// reverses winding).
///
/// `include_external` keeps tiers 24 (off = bare composite-node hull).
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
let names = xbg7_resource_names(bytes);
let resources: HashSet<String> = names.iter().cloned().collect();
// The PRIMARY composite carries the ship-space layout (hull bodies, turret
// mounts + every `GN_*` hardpoint frame): the one with the most nodes.
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
.into_iter()
.filter(|c| !c.ends_with("_joint"))
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
.collect();
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
// No composite at all → raw fallback below.
let mut placed = Vec::new();
const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
}
return placed;
};
let mut placed: Vec<ScenePart> = Vec::new();
let mut placed_res: HashSet<String> = HashSet::new();
// GN hardpoint frames, world-space, from the primary composite.
let mut frames: Vec<ScenePart> = Vec::new();
// Tier 1: every composite node placement, instances included. Cross-id
// resources (shared e303/e4NN turret models the composite mounts) count too.
for node in nodes {
if node.resource.starts_with("GN_") {
frames.push(node.clone());
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
if ship_id_of(&res) != Some(id) && !include_external {
continue; // hull-only view: own parts only
}
// Distinct INSTANCES keep their own placement; a nested alias of the
// same resource at the same transform (`bdy_01_mov` + its identity
// `bdy_01` child) collapses to one draw.
let dup = placed.iter().any(|p| {
p.resource == res
&& (p.t[0] - node.t[0]).abs() < 0.01
&& (p.t[1] - node.t[1]).abs() < 0.01
&& (p.t[2] - node.t[2]).abs() < 0.01
});
if !dup {
placed_res.insert(res.clone());
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
}
}
}
// Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes
// are in the FRAME's local space (two mirrored `eng_01` nacelles + centre
// `eng_02` for e106) — capture-verified to 0.1 units.
if include_external {
let rig_name = format!("e_rou_{id}_eng");
if names.contains(&rig_name) {
if let Some(frame) = frames.iter().find(|f| f.resource.starts_with("GN_Engine")) {
for rn in scene_world_nodes(bytes, &rig_name) {
let Some(res) = resolve_hull_resource(&rn.resource, &resources) else {
continue;
};
// world = frame ∘ rig_local
let m = m3_mul_pub(frame.m, rn.m);
let rt = m3_vec_pub(frame.m, rn.t);
let t = [rt[0] + frame.t[0], rt[1] + frame.t[1], rt[2] + frame.t[2]];
placed_res.insert(res.clone());
placed.push(ScenePart { resource: res, m, t, s: rn.s });
}
}
}
}
// Tier 3: remaining external parts at their exact GN frame (full TRS).
const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")];
for part in names
.iter()
.filter(|_| include_external)
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id))
{
if placed_res.contains(part) {
continue;
}
let rest = &part[(id.len() + 1).min(part.len())..]; // "brg_01"
let mut toks = rest.split('_');
let cat = toks.next().unwrap_or("");
let idx = toks.next().unwrap_or("");
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
continue;
};
// 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());
}
}
// Tier 4: mirror one of a shared-geometry twin pair. The engine uploads the
// second of a port/starboard pair X-reflected (capture-verified on
// bdy_01/bdy_02): for two same-geometry resources placed at ±TX, the
// instance whose TX sign matches the geometry's dominant-X side draws
// mirrored (so the pair ends up symmetric instead of twice the same side).
apply_twin_mirrors(bytes, &mut placed);
// Fallback for a ship with no composite scene graph (a simple prop): draw its
// base parts untransformed rather than nothing.
if placed.is_empty() {
const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
}
}
placed
}
/// The ship's exhaust mount frames (`GN_Jet_*` / `GN_SJet_*`), world-space, from
/// the primary composite. These are where the game renders the engine exhaust
/// FX — the visible "thrusters". The engine GEOMETRY itself sits recessed in
/// the hull (capture-verified across 43 e106 instances: the game draws
/// `eng_01`/`eng_02` exactly where [`assemble_ship`] puts them); without the
/// FX the assembled ship reads engine-less, so a viewer can draw exhaust
/// markers at these frames.
pub fn exhaust_frames(bytes: &[u8], id: &str) -> Vec<ScenePart> {
let names = xbg7_resource_names(bytes);
let scenes: Vec<Vec<ScenePart>> = composites_for(&names, id)
.into_iter()
.filter(|c| !c.ends_with("_joint"))
.map(|c| scene_world_nodes(bytes, c))
.collect();
let Some(nodes) = scenes.into_iter().max_by_key(|n| n.len()) else {
return Vec::new();
};
nodes
.into_iter()
.filter(|n| n.resource.starts_with("GN_Jet") || n.resource.starts_with("GN_SJet"))
.collect()
}
/// Detect shared-geometry port/starboard twin RESOURCES placed at ±TX and mark
/// the dominant-side instance as an X-reflection (negate the matrix X column).
/// Twins are `<stem>_01`/`<stem>_02` pairs with equal vertex data; the reflected
/// one is the instance whose TX sign equals the geometry's mean-X sign (the
/// un-reflected drawing already covers that side's opposite).
fn apply_twin_mirrors(bytes: &[u8], placed: &mut [ScenePart]) {
use crate::mesh::Xbg7Model;
// Candidate pairs: resources differing only in a trailing _01/_02, both
// placed exactly once, at opposite-sign TX of equal magnitude.
let mut pairs: Vec<(usize, usize)> = Vec::new();
for i in 0..placed.len() {
let a = &placed[i];
let Some(stem) = a.resource.strip_suffix("_01") else { continue };
let twin = format!("{stem}_02");
let Some(j) = placed.iter().position(|p| p.resource == twin) else { continue };
let b = &placed[j];
if (a.t[0] + b.t[0]).abs() < 0.5 && a.t[0].abs() > 1.0 {
pairs.push((i, j));
}
}
if pairs.is_empty() {
return;
}
let want: HashSet<String> = pairs
.iter()
.flat_map(|&(i, j)| [placed[i].resource.clone(), placed[j].resource.clone()])
.collect();
let models = Xbg7Model::models_named(bytes, &want, &|| false);
for (i, j) in pairs {
let get = |r: &str| models.iter().find(|m| m.name == r);
let (Some(ma), Some(mb)) = (get(&placed[i].resource), get(&placed[j].resource)) else {
continue;
};
let first = |m: &Xbg7Model| -> Vec<[f32; 3]> {
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).take(16).collect()
};
let (fa, fb) = (first(ma), first(mb));
if fa.len() != fb.len()
|| !fa.iter().zip(&fb).all(|(p, q)| {
(p[0] - q[0]).abs() < 1e-3 && (p[1] - q[1]).abs() < 1e-3 && (p[2] - q[2]).abs() < 1e-3
})
{
continue; // distinct (already-mirrored) geometries — nothing to do
}
let mean_x: f32 = ma
.meshes
.iter()
.flat_map(|s| s.positions.iter())
.map(|p| p[0])
.sum::<f32>()
/ ma.meshes.iter().map(|s| s.positions.len()).sum::<usize>().max(1) as f32;
if mean_x.abs() < 1e-3 {
continue; // symmetric geometry — mirroring is a no-op
}
// Reflect the instance whose lateral offset OPPOSES the geometry's
// dominant side (drawn plain it would fold onto the centreline; the
// capture shows the engine reflects exactly this one — for e106 the
// 264 port instance draws the file data plain, the +264 one mirrored).
let k = if (placed[i].t[0] > 0.0) == (mean_x > 0.0) { j } else { i };
for row in &mut placed[k].m {
row[0] = -row[0];
}
}
}
// Small helpers mirroring mesh.rs's private affine ops.
fn m3_mul_pub(a: [[f32; 3]; 3], b: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
let mut o = [[0.0f32; 3]; 3];
for r in 0..3 {
for c in 0..3 {
o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
}
}
o
}
fn m3_vec_pub(a: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
[
a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_extraction() {
assert_eq!(ship_id_of("e106_bdy_01"), Some("e106"));
assert_eq!(ship_id_of("f105_sld_02_l"), Some("f105"));
assert_eq!(ship_id_of("e_rou_e106"), None);
assert_eq!(ship_id_of("_rou_e106_break"), None);
assert_eq!(ship_id_of("Base"), None);
}
#[test]
fn base_part_filter() {
assert!(is_base_part("e106_bdy_01"));
assert!(is_base_part("e106_brg_01"));
assert!(is_base_part("f105_wep_02_01"));
assert!(!is_base_part("e106_bdy_01_l")); // LOD
assert!(!is_base_part("e106_bdy_01_m")); // LOD
assert!(!is_base_part("e106_brg_01_b_02")); // break piece
assert!(!is_base_part("_rou_e106_break")); // logic
assert!(!is_base_part("e_rou_e106")); // scene node
}
#[test]
fn faction_from_id() {
assert_eq!(Faction::from_id("e106"), Faction::Adan);
assert_eq!(Faction::from_id("f105"), Faction::Tcaf);
assert_eq!(Faction::from_id("n050"), Faction::Neutral);
assert_eq!(Faction::from_id("j004"), Faction::Other);
}
// Disc-gated: reconstruct the ADAN cruiser family from a real stage container.
#[test]
fn ships_from_real_stage() {
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
eprintln!("SYLPHEED_ISO unset — skipping");
return;
};
let bytes = {
use crate::xiso::open_iso;
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
rt.block_on(async {
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap()
})
};
let ships = ships_in_container(&bytes);
assert!(!ships.is_empty(), "stage should hold ships");
// The ADAN frigate e108 appears in S02 with hull + engine + weapon parts.
let e108 = ships.iter().find(|s| s.id == "e108").expect("e108 present");
assert_eq!(e108.faction, Faction::Adan);
assert!(e108.parts.iter().any(|p| p.contains("_bdy_")));
assert!(e108.parts.iter().all(|p| is_base_part(p)));
}
// Disc-gated GROUND-TRUTH test: the fully-static assembly must reproduce the
// runtime F10 capture (the baked e106 table) part-for-part — translations
// AND the engine-rig rotation — plus the multi-instance parts the capture's
// vbase-dedup could not see (two nacelles, two turrets).
#[test]
fn static_assembly_matches_runtime_capture() {
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
eprintln!("SYLPHEED_ISO unset — skipping");
return;
};
let bytes = {
use crate::xiso::open_iso;
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
rt.block_on(async {
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
r.read_file("hidden/resource3d/Stage_S01.xpr").await.unwrap()
})
};
let placed = assemble_ship(&bytes, "e106", true);
let cap = crate::ship_capture::embedded_placement("e106").expect("e106 baked");
// Express static placements relative to bdy_04 (the capture's reference).
let r4 = placed
.iter()
.find(|p| p.resource == "e106_bdy_04")
.expect("bdy_04 placed")
.clone();
let rel = |p: &crate::mesh::ScenePart| -> [f32; 3] {
[p.t[0] - r4.t[0], p.t[1] - r4.t[1], p.t[2] - r4.t[2]]
};
for want in &cap.parts {
let best = placed
.iter()
.filter(|p| p.resource == want.part)
.map(|p| {
let t = rel(p);
let d = (t[0] - want.t[0]).abs()
+ (t[1] - want.t[1]).abs()
+ (t[2] - want.t[2]).abs();
(d, p)
})
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
.unwrap_or_else(|| panic!("{} not placed statically", want.part));
assert!(
best.0 < 1.0,
"{}: static rel-T {:?} != captured {:?} (Δ={:.2})",
want.part,
rel(best.1),
want.t,
best.0
);
// Rotations must match too (the engine rig is the interesting case).
let m = &best.1.m;
for r in 0..3 {
for c in 0..3 {
assert!(
(m[r][c] - want.m[r][c]).abs() < 0.02,
"{}: static M row{r} {:?} != captured {:?}",
want.part,
m[r],
want.m[r]
);
}
}
}
// ── 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");
// 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 plain (mirror is in the data)");
assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
}
// Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at
// the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits
// clearly aft of its forward hull body (a real ship layout, not a pile).
#[test]
fn assemble_spreads_parts() {
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
eprintln!("SYLPHEED_ISO unset — skipping");
return;
};
let bytes = {
use crate::xiso::open_iso;
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
rt.block_on(async {
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap()
})
};
let placed = assemble_ship(&bytes, "e106", true);
assert!(placed.len() >= 5, "e106 should place its hull + external parts");
// The forward hull body and the bridge must land at distinct fore/aft Z.
let z = |res: &str| placed.iter().find(|p| p.resource == res).map(|p| p.t[2]);
let bdy = z("e106_bdy_01").expect("bdy_01 placed");
let brg = z("e106_brg_01").expect("brg_01 placed at its GN_Bridge frame");
assert!(
(bdy - brg).abs() > 200.0,
"bridge ({brg}) and forward body ({bdy}) must be far apart, not stacked"
);
}
}

View File

@@ -0,0 +1,797 @@
//! Exact capital-ship part placement from a Canary **F10 ship-capture** log —
//! the runtime ground truth that static [`crate::ship::assemble_ship`] only
//! approximates for external parts.
//!
//! ## Why a capture is needed
//!
//! A captured part's vertex BUFFER holds **local** coordinates (byte-identical to
//! the `.xpr`), so capital-ship parts are placed **entirely in the vertex shader**
//! — the buffer carries no placement. The per-part transform lives in the ship
//! shader's **vertex float constants**: `c0..c2` are the **WorldViewProjection**
//! matrix rows. Each row's norm is `(sx, sy, 1)` (the projection x/y scales, with
//! `sy/sx ≈ 1.78 = 16:9`); dividing a row by its norm yields the rigid
//! **WorldView** row (verified orthonormal, `det = +1`) and `row[3]/norm` is that
//! axis' view-space translation.
//!
//! The camera View cancels when every part is expressed relative to a **reference
//! part**: `rel_p = WV_ref⁻¹ · WV_p = (Rᵀ_ref·R_p , Rᵀ_ref·(T_p T_ref))` — a pure
//! ship-space rigid transform. That is what [`correlate`] emits and what the
//! checked-in [placement table](parse_table) stores, so the viewer can assemble a
//! ship exactly without re-capturing.
//!
//! ## Pipeline
//!
//! 1. Canary F10 → `xenia_ship_capture.log` (per-draw `vbase`/`vcount` + the first
//! 48 VS float4 constants). [`parse_capture`] → [`CapturedDraw`]s.
//! 2. [`correlate`] matches each ship base part to a draw **by vertex count**
//! (unique per part) and expresses it in the reference part's frame →
//! [`ShipPlacement`].
//! 3. [`serialize_table`]/[`parse_table`] persist it as a checked-in data file
//! (`data/ship_placements.txt`, embedded via [`embedded_placement`]); the viewer
//! prefers it over the static assembler when present.
use crate::mesh::ScenePart;
type M3 = [[f64; 3]; 3];
/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space
/// translation `t`, recovered from the `c0..c2` WVP constants.
#[derive(Debug, Clone, PartialEq)]
pub struct CapturedDraw {
/// Guest vertex-buffer base address (the draw's identity for de-duping).
pub vbase: u32,
/// Vertex count — the key that matches a draw to a decoded part.
pub vcount: u32,
/// WorldView rotation rows (orthonormal).
pub r: M3,
/// WorldView view-space translation.
pub t: [f64; 3],
/// First few LOCAL vertex positions dumped with the draw (buffer order).
/// Used to disambiguate same-vcount twins (mirrored port/starboard parts).
pub pos: Vec<[f32; 3]>,
/// The draw's INDEX buffer, when the capture recorded one (`ib base=…`,
/// added 2026-08-13): guest base address, index count, and the min/max index
/// value the emulator read out of guest memory. `None` for older logs and
/// for auto-index draws. This is ground truth for two things the offline
/// decoder can only assume — where a block's index buffer lives relative to
/// its vertex buffer, and how much of the vertex pool a draw really covers.
pub ib: Option<CapturedIndexBuffer>,
}
/// The index buffer a captured draw used. See [`CapturedDraw::ib`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapturedIndexBuffer {
/// Guest base address of the index data.
pub ibase: u32,
/// Number of indices the draw issued (== `VGT_DRAW_INITIATOR.num_indices`).
pub icount: u32,
/// Lowest index value in the buffer.
pub imin: u32,
/// Highest index value in the buffer — with `vcount` this says whether the
/// draw covers its whole vertex pool or only a sub-range.
pub imax: u32,
/// The first indices, verbatim (the capture prints up to 24). Byte-level
/// ground truth for the offline index decode: a matched block's decoded
/// index prefix must equal this run.
pub head: [u32; 24],
/// How many of `head` the capture actually carried.
pub head_len: u8,
}
/// A ship part to match against the capture. `part` is the **base** part name
/// (what goes in the placement table); `vcount`/`ref_pos` come from whichever
/// resource variant is being tried (base or an `_m`/`_l` LOD copy — a LOD is the
/// same part in the same local frame, so its captured transform is the part's).
#[derive(Debug, Clone, PartialEq)]
pub struct PartKey {
/// Base part name for the table, e.g. `e106_bdy_01`.
pub part: String,
/// The tried resource's vertex count (the draw match key).
pub vcount: u32,
/// The tried resource's decoded positions (any order — validation is
/// set-based), used to validate a vcount hit and to route mirrored twins.
/// Empty = match by vcount alone.
pub ref_pos: Vec<[f32; 3]>,
}
/// A part's ship-relative rigid placement (in the reference part's frame).
#[derive(Debug, Clone, PartialEq)]
pub struct PartPlacement {
/// Geometry resource name, e.g. `e106_eng_01`.
pub part: String,
/// Rotation rows.
pub m: [[f32; 3]; 3],
/// Ship-relative translation.
pub t: [f32; 3],
}
/// A whole ship's captured placement: every part in a shared ship-local frame.
#[derive(Debug, Clone, PartialEq)]
pub struct ShipPlacement {
/// Ship family id, e.g. `e106`.
pub id: String,
/// The reference part whose frame the placements are expressed in.
pub reference: String,
/// Ship-relative placement of each matched part.
pub parts: Vec<PartPlacement>,
}
/// Parse a Canary ship-capture log into per-draw rigid WorldView transforms.
///
/// Only draws that carry the `c0..c2` constants are returned (a culled/occluded
/// part produces no draw and is simply absent). Robust to the exact spacing of
/// the `DRAW …` / `vsconst …` lines.
pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
let mut out = Vec::new();
let mut vbase = 0u32;
let mut vcount = 0u32;
let mut pos: Vec<[f32; 3]> = Vec::new();
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut ib: Option<CapturedIndexBuffer> = None;
let flush = |vbase: u32,
vcount: u32,
pos: &mut Vec<[f32; 3]>,
ib: &mut Option<CapturedIndexBuffer>,
consts: &[(usize, [f64; 4])],
out: &mut Vec<CapturedDraw>| {
let pos = std::mem::take(pos);
let ib = ib.take();
if vbase == 0 {
return;
}
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else {
return; // no WorldView for this draw — skip it
};
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase, vcount, r, t, pos, ib });
}
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") {
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
consts.clear();
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0);
vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0);
} else if let Some(rest) = l.strip_prefix("ib base=0x") {
// `ib base=0x… count=N fmt=u16 endian=E len=L delta_vb=D min=a max=b idx: …`
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
let base = rest
.split_whitespace()
.next()
.and_then(|s| u32::from_str_radix(s, 16).ok());
if let (Some(ibase), Some(icount)) = (base, f("count=").and_then(|s| s.parse().ok())) {
let mut head = [0u32; 24];
let mut head_len = 0u8;
if let Some((_, list)) = l.split_once("idx:") {
for tok in list.split_whitespace() {
let Ok(v) = tok.parse::<u32>() else { break };
if head_len as usize >= head.len() {
break;
}
head[head_len as usize] = v;
head_len += 1;
}
}
ib = Some(CapturedIndexBuffer {
ibase,
icount,
imin: f("min=").and_then(|s| s.parse().ok()).unwrap_or(0),
imax: f("max=").and_then(|s| s.parse().ok()).unwrap_or(0),
head,
head_len,
});
}
} else if l.starts_with("pos:") || l.starts_with("positions:") {
pos = parse_pos_line(l, 8);
} else if l.starts_with("vsconst") {
for cap in l.split('c').skip(1) {
let Some((idx, rest)) = cap.split_once('=') else { continue };
let Ok(i) = idx.trim().parse::<usize>() else { continue };
let nums: Vec<f64> = rest
.trim_start_matches('(')
.split(')')
.next()
.unwrap_or("")
.split(',')
.filter_map(|x| x.trim().parse().ok())
.collect();
if nums.len() == 4 {
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
}
}
}
}
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
out
}
/// Parse a `pos: (x,y,z) (x,y,z) …` dump line into up to `max` positions.
fn parse_pos_line(l: &str, max: usize) -> Vec<[f32; 3]> {
let mut out = Vec::new();
for group in l.split('(').skip(1) {
let Some(inner) = group.split(')').next() else { continue };
let nums: Vec<f32> = inner.split(',').filter_map(|x| x.trim().parse().ok()).collect();
if nums.len() == 3 {
out.push([nums[0], nums[1], nums[2]]);
if out.len() >= max {
break;
}
}
}
out
}
/// The vertex shader capital ships (and the player fighter) are drawn with — the
/// `c0..c2` WVP-row layout [`parse_capture`]/[`parse_drawlog`] rely on.
pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
/// Parse the **draw-logger** format (`xenia_re_draws.log` / `mission_draws.log`,
/// the `--log_draws` cvar) into per-part rigid transforms — the alternative to the
/// F10 [`parse_capture`] snapshot. That log is what a normal instrumented run
/// already produces, so a capital ship seen in-mission can be baked without a
/// dedicated F10 capture.
///
/// A capital ship's parts each own a **distinct vertex buffer**, so we group by
/// the `stream … base=…` address, take its vertex count as `size_words /
/// stride_words`, and keep the first `c0..c2` WVP seen for that buffer. Only draws
/// 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();
let mut is_ship = false;
let mut base = 0u32;
let mut stride = 0u32;
let mut size = 0u32;
let mut pos: Vec<[f32; 3]> = Vec::new();
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut flush = |base: u32,
size: u32,
stride: u32,
pos: &mut Vec<[f32; 3]>,
consts: &[(usize, [f64; 4])],
seen: &mut std::collections::HashSet<u32>,
out: &mut Vec<CapturedDraw>| {
let pos = std::mem::take(pos);
if base == 0 || stride == 0 || !seen.insert(base) {
return;
}
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return };
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
// The draw-logger format carries an index base too, but it de-dups
// by vertex declaration, so it never lines up per part — left None.
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos, ib: None });
}
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") {
flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
consts.clear();
base = 0;
stride = 0;
size = 0;
is_ship = rest.contains(&format!("vs={SHIP_VS_HASH}"));
} else if is_ship && l.starts_with("positions:") {
pos = parse_pos_line(l, 8);
} else if is_ship && l.starts_with("stream ") {
let f = |k: &str| l.split_whitespace().find_map(|t| t.strip_prefix(k));
if let Some(b) = f("base=0x").and_then(|s| u32::from_str_radix(s, 16).ok()) {
base = b;
}
stride = f("stride_words=").and_then(|s| s.parse().ok()).unwrap_or(stride);
size = f("size_words=").and_then(|s| s.parse().ok()).unwrap_or(size);
} else if is_ship && l.starts_with('c') {
// `c<idx> x y z w` — the vsconst rows (space-separated).
let mut it = l.splitn(2, char::is_whitespace);
let Some(tag) = it.next() else { continue };
let Ok(i) = tag[1..].parse::<usize>() else { continue };
let nums: Vec<f64> = it.next().unwrap_or("").split_whitespace().filter_map(|x| x.parse().ok()).collect();
if nums.len() >= 4 {
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
}
}
}
flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
out
}
/// Normalize the three `c0..c2` WVP rows to a rigid WorldView `(R rows, T)` by
/// dividing each row by its (projection-scale) norm. `None` if any row is
/// degenerate.
fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> {
let mut r = [[0.0; 3]; 3];
let mut t = [0.0; 3];
for (i, row) in rows.iter().enumerate() {
let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt();
if n < 1e-6 {
return None;
}
r[i] = [row[0] / n, row[1] / n, row[2] / n];
t[i] = row[3] / n;
}
Some((r, t))
}
/// Validate a vcount hit by the draw's dumped positions against the part's
/// decoded position SET (order-independent — decode order can differ from buffer
/// order). Returns `Some((hits, mirrored))` when at least half the dumped
/// positions are found among the part's vertices, either directly or **all
/// X-negated** — the engine uploads the second of a mirrored port/starboard pair
/// as an X-reflection of the shared file geometry, so the guest buffer disagrees
/// in X sign with every decoded copy. `None` = the dump belongs to a different
/// model (a coincidental vcount).
fn pos_validate(draw: &CapturedDraw, ref_pos: &[[f32; 3]]) -> Option<(usize, bool)> {
if draw.pos.is_empty() || ref_pos.is_empty() {
return Some((0, false)); // no data to validate with — accept neutrally
}
let near = |a: &[f32; 3], b: &[f32; 3]| {
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
};
let mut direct = 0usize;
let mut mirror = 0usize;
for p in &draw.pos {
if ref_pos.iter().any(|v| near(p, v)) {
direct += 1;
}
let pm = [-p[0], p[1], p[2]];
if ref_pos.iter().any(|v| near(&pm, v)) {
mirror += 1;
}
}
let need = draw.pos.len().div_ceil(2);
if direct >= need && direct >= mirror {
Some((direct, false))
} else if mirror >= need {
Some((mirror, true))
} else {
None
}
}
/// Correlate captured draws to a ship's parts and express each in the reference
/// part's frame.
///
/// Each [`PartKey`]'s `vcount` is the match key; when several draws share the
/// vcount (mirrored port/starboard twins) the draw whose dumped positions match
/// the part's `ref_pos` validates best is chosen. `ref_sub` selects the reference part by
/// substring (e.g. `bdy_04`); the first matched part is used if none contains it.
/// Parts with no matching captured draw (culled at that camera angle) are
/// omitted. Returns `None` if nothing matched.
pub fn correlate(
id: &str,
draws: &[CapturedDraw],
parts: &[PartKey],
ref_sub: &str,
) -> Option<ShipPlacement> {
let mut matched: Vec<(String, M3, [f64; 3], bool)> = Vec::new();
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
for key in parts {
// Several PartKeys may carry the same part (one per LOD-variant vcount);
// the first that validates wins, the rest are skipped.
if matched.iter().any(|(p, ..)| p == &key.part) {
continue;
}
// A vcount hit alone can be a coincidence (small LODs share counts across
// unrelated models — a 51-vert draw once matched the bridge but was a
// different mesh). Candidates failing position validation are REJECTED;
// among validated candidates the best hit count wins (routes twins), and
// a mirror-validated match records the X-reflection.
let mut best: Option<(&CapturedDraw, usize, bool)> = None;
for d in draws.iter().filter(|d| d.vcount == key.vcount && !used.contains(&d.vbase)) {
let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else {
continue; // positions disagree — not this part
};
if best.map_or(true, |(_, s, _)| score > s) {
best = Some((d, score, mirrored));
}
}
if let Some((d, _, mirrored)) = best {
used.insert(d.vbase);
matched.push((key.part.clone(), d.r, d.t, mirrored));
}
}
let ref_idx = matched.iter().position(|(p, ..)| p.contains(ref_sub)).unwrap_or(0);
let (ref_part, ref_r, ref_t, _) = matched.get(ref_idx)?.clone();
let rt_ref = transpose(&ref_r);
let parts_out = matched
.iter()
.map(|(part, r, t, mirrored)| {
let mut rel_r = mmul(&rt_ref, r);
let dt = [t[0] - ref_t[0], t[1] - ref_t[1], t[2] - ref_t[2]];
let rel_t = mat_vec(&rt_ref, dt);
// The captured WorldView transforms the *uploaded* buffer; for the
// mirrored twin that buffer is the X-reflection of the file geometry,
// so the file-local placement is R·diag(1,1,1) — negate column 0.
if *mirrored {
for row in &mut rel_r {
row[0] = -row[0];
}
}
PartPlacement {
part: part.clone(),
m: snap_m3(&rel_r),
t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32],
}
})
.collect();
Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out })
}
/// Snap near-axis rotation entries (float noise from the WV products) to exact
/// 0/±1 so the checked-in table is clean; real rotations are untouched.
fn snap_m3(m: &M3) -> [[f32; 3]; 3] {
let snap = |v: f64| -> f32 {
if v.abs() < 5e-4 {
0.0
} else if (v - 1.0).abs() < 5e-4 {
1.0
} else if (v + 1.0).abs() < 5e-4 {
-1.0
} else {
v as f32
}
};
[
[snap(m[0][0]), snap(m[0][1]), snap(m[0][2])],
[snap(m[1][0]), snap(m[1][1]), snap(m[1][2])],
[snap(m[2][0]), snap(m[2][1]), snap(m[2][2])],
]
}
/// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale).
pub fn to_scene_parts(ship: &ShipPlacement) -> Vec<ScenePart> {
ship.parts
.iter()
.map(|p| ScenePart { resource: p.part.clone(), m: p.m, t: p.t, s: [1.0, 1.0, 1.0] })
.collect()
}
/// The checked-in placement table, embedded at build time. Empty until captures
/// are baked in with `correlate_capture --emit`.
const EMBEDDED_TABLE: &str = include_str!("../data/ship_placements.txt");
/// The captured placement for ship `id` from the embedded table, if baked in.
pub fn embedded_placement(id: &str) -> Option<ShipPlacement> {
parse_table(EMBEDDED_TABLE).into_iter().find(|s| s.id == id)
}
/// Serialize a placement table to the checked-in text format (see [`parse_table`]).
pub fn serialize_table(ships: &[ShipPlacement]) -> String {
let mut s = String::new();
s.push_str("# Capital-ship part placements — runtime-captured ground truth.\n");
s.push_str("# Generated by: cargo run --release --example correlate_capture -- \\\n");
s.push_str("# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit\n");
s.push_str("# Per line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>\n");
for ship in ships {
s.push_str(&format!("\nship {} ref={}\n", ship.id, ship.reference));
for p in &ship.parts {
s.push_str(&format!(
" {} {} {} {} {} {} {} {} {} {} {} {} {}\n",
p.part,
p.m[0][0], p.m[0][1], p.m[0][2],
p.m[1][0], p.m[1][1], p.m[1][2],
p.m[2][0], p.m[2][1], p.m[2][2],
p.t[0], p.t[1], p.t[2],
));
}
}
s
}
/// Parse the checked-in placement table. `#` comments and blank lines are ignored;
/// a `ship <id> ref=<part>` line starts a block, and each following
/// `<part> <9 rotation floats> <3 translation floats>` line is one placement.
pub fn parse_table(text: &str) -> Vec<ShipPlacement> {
let mut ships: Vec<ShipPlacement> = Vec::new();
for line in text.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
continue;
}
if let Some(rest) = l.strip_prefix("ship ") {
let mut it = rest.split_whitespace();
let id = it.next().unwrap_or("").to_string();
let reference = it
.next()
.and_then(|s| s.strip_prefix("ref="))
.unwrap_or("")
.to_string();
ships.push(ShipPlacement { id, reference, parts: Vec::new() });
} else if let Some(ship) = ships.last_mut() {
let mut it = l.split_whitespace();
let part = it.next().unwrap_or("").to_string();
let nums: Vec<f32> = it.filter_map(|x| x.parse().ok()).collect();
if part.is_empty() || nums.len() != 12 {
continue;
}
ship.parts.push(PartPlacement {
part,
m: [
[nums[0], nums[1], nums[2]],
[nums[3], nums[4], nums[5]],
[nums[6], nums[7], nums[8]],
],
t: [nums[9], nums[10], nums[11]],
});
}
}
ships
}
fn transpose(m: &M3) -> M3 {
[
[m[0][0], m[1][0], m[2][0]],
[m[0][1], m[1][1], m[2][1]],
[m[0][2], m[1][2], m[2][2]],
]
}
fn mmul(a: &M3, b: &M3) -> M3 {
let mut o = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
o
}
fn mat_vec(m: &M3, v: [f64; 3]) -> [f64; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
#[cfg(test)]
mod tests {
use super::*;
/// A `vsconst` line for an already-unit WorldView (norm 1) so R = rows and
/// T = row[3]: identity rotation, translation `t`.
fn draw_line(vbase: u32, vcount: u32, t: [f64; 3]) -> String {
format!(
"DRAW vbase=0x{vbase:X} stride=32 vcount={vcount} indices=0 prim=tri vs=0x1\n \
vsconst base=0: c0=(1,0,0,{}) c1=(0,1,0,{}) c2=(0,0,1,{})\n",
t[0], t[1], t[2]
)
}
fn key(part: &str, vcount: u32) -> PartKey {
PartKey { part: part.to_string(), vcount, ref_pos: Vec::new() }
}
#[test]
fn parse_recovers_worldview() {
let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]);
let draws = parse_capture(&log);
assert_eq!(draws.len(), 1);
assert_eq!(draws[0].vcount, 3);
assert_eq!(draws[0].t, [10.0, 0.0, 0.0]);
assert_eq!(draws[0].r, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
}
#[test]
fn parse_normalizes_projection_scale() {
// A row scaled by the projection sx=2 must normalize back to unit, and its
// translation divides by the same norm.
let log = "DRAW vbase=0x2000 vcount=4\n \
vsconst base=0: c0=(2,0,0,20) c1=(0,2,0,0) c2=(0,0,1,0)\n";
let d = &parse_capture(log)[0];
assert!((d.r[0][0] - 1.0).abs() < 1e-9);
assert!((d.t[0] - 10.0).abs() < 1e-9, "20/2 = 10");
}
#[test]
fn correlate_expresses_parts_in_reference_frame() {
// Two parts, identity rotation: ref at view (10,0,0), other at (10,0,50).
// Relative to ref, the other must sit at (0,0,50).
let log = format!(
"{}{}",
draw_line(0x1000, 3, [10.0, 0.0, 0.0]),
draw_line(0x2000, 4, [10.0, 0.0, 50.0])
);
let draws = parse_capture(&log);
let parts = vec![key("e106_bdy_04", 3), key("e106_eng_01", 4)];
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
assert_eq!(ship.reference, "e106_bdy_04");
let refp = ship.parts.iter().find(|p| p.part == "e106_bdy_04").unwrap();
assert_eq!(refp.t, [0.0, 0.0, 0.0]);
let eng = ship.parts.iter().find(|p| p.part == "e106_eng_01").unwrap();
assert_eq!(eng.t, [0.0, 0.0, 50.0]);
}
#[test]
fn drawlog_format_parses_capital_ship_parts() {
// Two capital-ship parts, each its own vertex buffer (distinct base),
// stride 6 → vcount = size_words/6. Identity WVP with known translations.
let log = format!(
"DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
stream fc=0 base=0x12A60228 stride_words=6 size_words=9798 endian=2 type=3\n \
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n\
DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
stream fc=0 base=0x12ABF2CC stride_words=6 size_words=4890 endian=2 type=3\n \
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 50.0\n\
DRAW prim=4 indices=3 src=0 index[...] vs=0xDEADBEEF00000000\n \
stream fc=0 base=0x99990000 stride_words=6 size_words=18 endian=2 type=3\n \
vsconst:\n c0 1.0 0.0 0.0 0.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n"
);
let draws = parse_drawlog(&log);
// Two ship-shader buffers; the non-ship shader draw is ignored.
assert_eq!(draws.len(), 2);
assert_eq!(draws[0].vcount, 1633); // 9798/6
assert_eq!(draws[1].vcount, 815); // 4890/6
// Correlate: part B sits 50 along Z from reference part A.
let parts = vec![key("e106_bdy_04", 1633), key("e106_bdy_03", 815)];
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap();
assert_eq!(b.t, [0.0, 0.0, 50.0]);
}
/// The real e106 bdy_01/bdy_02 case: both twin resources decode to
/// IDENTICAL file geometry; the engine uploads the second instance as an
/// X-reflection, so one captured buffer disagrees in X sign with the file.
/// Both draws must be placed, and the mirrored one must bake the X-flip
/// (negated first matrix column) so file-local geometry lands port-side.
#[test]
fn runtime_mirrored_twin_placed_with_reflection() {
let log = "DRAW vbase=0x1000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \
pos: (134.4215,133.8319,-118.1757) (178.8384,85.3847,238.0463)\n \
vsconst base=0: c0=(1,0,0,264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
DRAW vbase=0x2000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \
pos: (-134.4215,133.8319,-118.1757) (-178.8384,85.3847,238.0463)\n \
vsconst base=0: c0=(1,0,0,-264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \
vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
let draws = parse_capture(log);
assert_eq!(draws.len(), 3);
assert_eq!(draws[0].pos.len(), 2);
// Both twins carry the SAME (file) positions — +X side geometry.
let file_pos = vec![[134.4215, 133.8319, -118.1757], [178.8384, 85.3847, 238.0463]];
let parts = vec![
PartKey { part: "e106_bdy_01".to_string(), vcount: 426, ref_pos: file_pos.clone() },
PartKey { part: "e106_bdy_02".to_string(), vcount: 426, ref_pos: file_pos },
key("e106_bdy_04", 558),
];
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
assert_eq!(ship.parts.len(), 3, "both twins + reference placed");
let get = |p: &str| ship.parts.iter().find(|x| x.part == p).unwrap().clone();
// bdy_01 validated directly → the +264 draw, identity rotation.
let a = get("e106_bdy_01");
assert_eq!(a.t, [264.0, 0.0, 0.0]);
assert_eq!(a.m[0][0], 1.0);
// bdy_02 validated as the MIRROR → the 264 draw, X-flip baked in.
let b = get("e106_bdy_02");
assert_eq!(b.t, [-264.0, 0.0, 0.0]);
assert_eq!(b.m[0][0], -1.0, "mirrored twin must negate the X column");
assert_eq!(b.m[1][1], 1.0);
}
/// A draw whose vcount matches but whose position dump disagrees must be
/// REJECTED, not placed — the real false-positive: a foreign 51-vert model
/// matched `e106_brg_01_l` by count alone and put the bridge 2 km off-hull.
#[test]
fn vcount_coincidence_rejected_by_positions() {
let log = "DRAW vbase=0x1000 stride=24 vcount=51 indices=36 prim=4 vs=0x1\n \
pos: (-0.0000,46.2359,-12.9454) (-0.0000,-6.1936,264.8687)\n \
vsconst base=0: c0=(1,0,0,1975) c1=(0,1,0,0) c2=(0,0,1,0)\n\
DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \
vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
let draws = parse_capture(log);
let parts = vec![
PartKey {
part: "e106_brg_01".to_string(),
vcount: 51,
// The REAL bridge LOD's vertices — disagree with the dump.
ref_pos: vec![[35.2480, 26.0376, 49.2504], [6.0, 55.9632, 41.1370]],
},
key("e106_bdy_04", 558),
];
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
assert!(
!ship.parts.iter().any(|p| p.part == "e106_brg_01"),
"coincidental vcount match must not place the bridge"
);
assert_eq!(ship.parts.len(), 1);
}
#[test]
fn table_round_trips() {
let ship = ShipPlacement {
id: "e106".to_string(),
reference: "e106_bdy_04".to_string(),
parts: vec![
PartPlacement {
part: "e106_bdy_04".to_string(),
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
t: [0.0, 0.0, 0.0],
},
PartPlacement {
part: "e106_eng_01".to_string(),
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
t: [131.0, -133.0, -132.0],
},
],
};
let text = serialize_table(std::slice::from_ref(&ship));
let back = parse_table(&text);
assert_eq!(back, vec![ship]);
}
#[test]
fn embedded_table_parses() {
// The checked-in data file must always parse (even if empty).
let _ = parse_table(EMBEDDED_TABLE);
}
/// The baked e106 capture (2026-07-26 F10, Stage_S01): all 8 parts placed,
/// port/starboard pair symmetric with the mirror on bdy_02, bridge on the
/// centreline. Guards the checked-in data against accidental edits.
#[test]
fn embedded_e106_is_complete() {
let e106 = embedded_placement("e106").expect("e106 baked in");
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, **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_01").m[0][0], 1.0);
// Bridge: centreline, above and aft of the hull reference.
let brg = get("e106_brg_01");
assert!(brg.t[0].abs() < 0.1 && brg.t[1] > 150.0 && brg.t[2] < -160.0);
}
}

View File

@@ -79,7 +79,12 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
}
if i - start >= 6 {
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") {
// Every spoken-line category, so the standalone player covers them
// all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices
// (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in
// `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name).
let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\");
if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice {
if seen.insert(s.to_string()) {
out.push(parse_voice_clip(s));
}
@@ -113,8 +118,58 @@ fn parse_voice_clip(name: &str) -> VoiceClip {
}
}
/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in
/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves
/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`,
/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be
/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller
/// decodes each to PCM, concatenates in order, and clamps to the movie length —
/// that yields the full track for segment banks while the clamp drops the
/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O
/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.)
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
if find(slb, b"RIFF", 0).is_none() {
// Headerless single-stream bank.
if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) {
if !data.is_empty() {
out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data));
}
}
return out;
}
let mut pos = 0usize;
while let Some(ri) = find(slb, b"RIFF", pos) {
// Parse this sub-wave's fmt + data (declared size is honest per sub-wave).
let Some(fi) = find(slb, b"fmt ", ri) else { break };
let Some(fsz) = le32(slb, fi + 4) else { break };
let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else {
break;
};
if fmt_end > slb.len() {
break;
}
let Some(di) = find(slb, b"data", fi) else { break };
let Some(dsz) = le32(slb, di + 4) else { break };
let Some(ds) = di.checked_add(8) else { break };
let de = ds
.checked_add(dsz as usize)
.unwrap_or(slb.len())
.min(slb.len());
if let Some(data) = slb.get(ds..de) {
if !data.is_empty() {
out.push(build_riff(&slb[fi..fmt_end], data));
}
}
// Advance past this sub-wave's data to find the next RIFF.
pos = de.max(ri + 4);
}
out
}
/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's
/// `xma1`. Returns `None` if the bank is too small / malformed.
/// `xma1`. Returns `None` if the bank is too small / malformed. This is the
/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks.
pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
if let Some(ri) = find(slb, b"RIFF", 0) {
// RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each
@@ -145,6 +200,38 @@ pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
}
}
/// Rebuild a standalone XMA1 `RIFF/WAVE` from a single-stream `.slb`, robust to
/// the layout variants seen in `<lang>\etc\` radio clips. Unlike [`to_xma_riff`]
/// (which assumes `RIFF → fmt → data` in order), this picks the **largest `data`
/// chunk anywhere** in the bank — some radio banks store the audio *before* the
/// trailing `RIFF`/`fmt` metadata (an empty post-`RIFF` `data` chunk), which the
/// ordered scan misses. Pairs it with the first `fmt ` chunk; falls back to the
/// headerless layout. Returns `None` only when no usable audio can be found.
pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
// Largest usable `data` chunk (bounded by its declared size and the buffer).
let mut best: Option<(usize, usize)> = None; // (data offset, usable payload len)
let mut i = 0;
while let Some(di) = find(slb, b"data", i) {
let declared = le32(slb, di + 4).unwrap_or(0) as usize;
let usable = declared.min(slb.len().saturating_sub(di + 8));
if best.map_or(true, |(_, b)| usable > b) {
best = Some((di, usable));
}
i = di + 4;
}
if let (Some(fi), Some((di, sz))) = (find(slb, b"fmt ", 0), best) {
if sz > 512 {
let fsz = le32(slb, fi + 4)? as usize;
let fmt_end = (fi + 8 + fsz).min(slb.len());
let data = slb.get(di + 8..di + 8 + sz)?;
return Some(build_riff(slb.get(fi..fmt_end)?, data));
}
}
// Headerless fallback: fixed data offset, synthesized XMA1 stereo/48k fmt.
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
(!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data))
}
/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<u8> {
let mut fmt = Vec::with_capacity(40);
@@ -235,6 +322,40 @@ mod tests {
assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]);
}
#[test]
fn list_voice_clips_covers_movie_radio_and_briefing() {
// The standalone player must enumerate every spoken-line category: bound
// movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\,
// whose BR<NN>_<MM> names lack "VOICE"). Music (BGM_*) must stay excluded.
let mut tbl = Vec::new();
for s in [
"eng\\Movie\\VOICE_S13A.slb",
"eng\\etc\\VOICE_D_450.slb",
"eng\\Voice\\VOICE_ADAN_010.slb",
"eng\\Briefing\\BR01_01.slb",
"eng\\bgm\\BGM_001.slb",
] {
tbl.extend_from_slice(s.as_bytes());
tbl.push(0);
}
let names: Vec<String> = list_voice_clips(&tbl, VoiceLang::English)
.into_iter()
.map(|c| c.name)
.collect();
for want in [
"eng\\Movie\\VOICE_S13A.slb",
"eng\\etc\\VOICE_D_450.slb",
"eng\\Voice\\VOICE_ADAN_010.slb",
"eng\\Briefing\\BR01_01.slb",
] {
assert!(names.iter().any(|n| n == want), "missing {want}");
}
assert!(
!names.iter().any(|n| n.contains("BGM_")),
"music must not be listed as a voice clip"
);
}
#[test]
fn rebuilds_riff_from_headerless() {
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];

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);
@@ -424,3 +424,59 @@ fn stage_models_quality_audit() {
assert!(huge < models.len() / 20, "few huge (skybox-plane) models");
assert!(worst_deg < 0.35, "no model should be mostly-degenerate");
}
/// A correctly located index run has **no degenerate triangles**. That is the
/// signature the 2026-08-13 pad-scoring fix keys on: an index list read one
/// element late still passes every count-based gate but wires arbitrary
/// vertices, which produces triangles with a repeated index. Before the fix
/// **579** decoded runs on the disc carried such triangles (and a runtime
/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it and
/// the two follow-ups (pad scoring in the grouped path, and preferring a
/// degenerate-free candidate over an earlier dirty one), exactly **one** does.
/// Locking the
/// number in, because the defect is invisible to coverage and to the anchor
/// oracle: every count stays correct while the geometry is mis-wired.
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn decoded_index_runs_have_almost_no_degenerate_triangles() {
let Some(dir) = res3d_dir() else {
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
return;
};
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();
let mut offenders: Vec<(String, String, usize)> = Vec::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::stage_models(&bytes) {
for sm in &m.meshes {
let d = sm
.indices
.chunks_exact(3)
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
.count();
if d > 0 {
offenders.push((m.name.clone(), where_.clone(), d));
}
}
}
}
// The one at default settings: `_rou_f402_dead` in `Stage_S09`, whose
// degenerate-free block is claimed by `e_rou_f003_Near` — both 24-vertex
// bounding boxes, the identity class that needs descriptor-level data. (With
// `XBG7_SUBMESH_DECLS=1` four newly decoded `ptc_pack` `.dat` composites join
// it; that knob is off by default.) Anything else here is a regression.
assert!(
offenders.len() <= 1,
"{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}",
offenders.len(),
&offenders[..offenders.len().min(20)]
);
}

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}");
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,11 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
IsoLoaderSystemSet,
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
};
use crate::ViewerState;
use sylpheed_formats::SubLang;
@@ -25,6 +26,8 @@ impl Plugin for ViewerUiPlugin {
app.insert_resource(FileBrowserState::default());
// Run after the iso_loader chain so we see the frame's final state.
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
}
}
@@ -136,6 +139,8 @@ struct UiEvents<'w> {
subtitles: EventWriter<'w, RequestSubtitles>,
audio: EventWriter<'w, RequestAudio>,
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>,
}
fn draw_viewer_ui(
@@ -193,6 +198,18 @@ fn draw_viewer_ui(
}
ui.close_menu();
}
if ui.button("🗃 Game Data…").clicked() {
// The Game Data window (its own system) reads this event to
// open + lazily decode the tables.
events.game_data.send_default();
ui.close_menu();
}
if ui.button("🚀 Ships…").clicked() {
// The Ships window (its own system) reads this event to open +
// lazily scan the stage containers for assemblable ships.
events.ships.send_default();
ui.close_menu();
}
});
ui.menu_button("Help", |ui| {
@@ -227,37 +244,73 @@ fn draw_viewer_ui(
ui.label("Filter:");
ui.text_edit_singleline(&mut voice_lib.filter);
});
let f = voice_lib.filter.to_lowercase();
// Group the thousands of entries as directory → speaker so the
// list is navigable (e.g. browse `Voice` by character to find a
// cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`.
use std::collections::BTreeMap;
let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> =
BTreeMap::new();
for c in &voice_lib.clips {
if !f.is_empty() && !c.name.to_lowercase().contains(&f) {
continue;
}
let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
groups
.entry(dir)
.or_default()
.entry(c.speaker.as_str())
.or_default()
.push(c);
}
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
ui.label(
egui::RichText::new(format!("{} clips", voice_lib.clips.len()))
egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len()))
.weak()
.small(),
);
ui.separator();
let f = voice_lib.filter.to_lowercase();
let clips: Vec<_> = voice_lib
.clips
.iter()
.filter(|c| f.is_empty() || c.name.to_lowercase().contains(&f))
.take(2000)
.cloned()
.collect();
let filtering = !f.is_empty();
egui::ScrollArea::vertical().show(ui, |ui| {
for c in &clips {
ui.horizontal(|ui| {
if ui.button("").on_hover_text(&c.name).clicked() {
audio.generation = audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.name = c.display.clone();
events.audio.send(RequestAudio {
clip: c.name.clone(),
display: c.display.clone(),
movie: None,
generation: audio.generation,
});
}
ui.label(&c.display);
});
for (dir, speakers) in &groups {
let dtotal: usize = speakers.values().map(Vec::len).sum();
egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})"))
.id_salt(("vdir", *dir))
.default_open(filtering)
.show(ui, |ui| {
for (speaker, clips) in speakers {
egui::CollapsingHeader::new(format!(
"{speaker} ({})",
clips.len()
))
.id_salt(("vspk", *dir, *speaker))
.default_open(filtering || clips.len() <= 6)
.show(ui, |ui| {
for c in clips {
ui.horizontal(|ui| {
if ui
.button("")
.on_hover_text(&c.name)
.clicked()
{
audio.generation =
audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.name = c.display.clone();
events.audio.send(RequestAudio {
clip: c.name.clone(),
display: c.display.clone(),
movie: None,
generation: audio.generation,
});
}
ui.label(&c.display);
});
}
});
}
});
}
});
}
@@ -1244,3 +1297,397 @@ fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
});
});
}
// ── Game Data browser ──────────────────────────────────────────────────────────
fn fnum(v: Option<f32>) -> String {
match v {
Some(x) if x == x.trunc() => format!("{}", x as i64),
Some(x) => format!("{x}"),
None => "·".into(),
}
}
fn fint(v: Option<i64>) -> String {
v.map(|x| x.to_string()).unwrap_or_else(|| "·".into())
}
/// The Game Data browser — a floating window over the decoded IDXD tables
/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so
/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData`
/// event (from the View menu) opens it and lazily kicks off decoding.
#[cfg(not(target_arch = "wasm32"))]
fn draw_game_data_ui(
mut contexts: EguiContexts,
mut game_data: ResMut<GameData>,
mut requests: EventReader<RequestGameData>,
) {
if requests.read().next().is_some() {
game_data.open = true;
if !game_data.loaded {
game_data.loading = true;
}
}
if !game_data.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
egui::Window::new("🗃 Game Data")
.default_width(700.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if game_data.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Decoding game tables…");
});
ctx.request_repaint();
return;
}
if !game_data.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
let GameData { category, filter, snapshot, .. } = &mut *game_data;
ui.horizontal_wrapped(|ui| {
for cat in GameCategory::ALL {
ui.selectable_value(category, cat, cat.label());
}
});
let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights);
if filterable {
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(filter);
if !filter.is_empty() && ui.small_button("").clicked() {
filter.clear();
}
});
}
ui.separator();
let f = filter.to_lowercase();
let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category {
GameCategory::Weapons => {
egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] {
ui.strong(h);
}
ui.end_row();
for w in &snapshot.weapons {
let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_");
if !hit(name) {
continue;
}
ui.label(name);
ui.label(w.target_type.as_deref().unwrap_or("·"));
ui.label(fnum(w.power));
ui.label(fnum(w.velocity));
ui.label(fnum(w.max_range));
ui.label(fint(w.loading_count));
ui.end_row();
}
});
}
GameCategory::Craft => {
egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] {
ui.strong(h);
}
ui.end_row();
for u in &snapshot.craft {
let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
if !hit(name) {
continue;
}
ui.label(name);
ui.label(fnum(u.hp));
ui.label(fnum(u.cruising_velocity));
ui.label(fnum(u.acceleration));
ui.label(fnum(u.radar_range));
ui.label(fint(u.turret_count));
ui.end_row();
}
});
}
GameCategory::Vessels => {
egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] {
ui.strong(h);
}
ui.end_row();
for v in &snapshot.vessels {
let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
if !hit(name) {
continue;
}
ui.label(name);
ui.label(fnum(v.hp));
ui.label(fnum(v.size_z));
ui.label(fint(v.turret_count));
ui.label(fint(v.bridge_count));
ui.label(fint(v.shield_generator_count));
ui.end_row();
}
});
}
GameCategory::Characters => {
egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| {
for h in ["Name", "Faction", "Portraits"] {
ui.strong(h);
}
ui.end_row();
for c in &snapshot.characters {
if !hit(&c.name) && !hit(&c.faction) {
continue;
}
ui.label(&c.name);
let col = if c.faction == "TCAF" {
egui::Color32::from_rgb(90, 160, 232)
} else if c.faction == "ADAN" {
egui::Color32::from_rgb(224, 86, 122)
} else {
egui::Color32::GRAY
};
ui.colored_label(col, if c.faction.is_empty() { "" } else { &c.faction });
ui.label(c.faces.to_string());
ui.end_row();
}
});
}
GameCategory::Missions => {
for m in &snapshot.missions {
if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) {
continue;
}
let head = format!("{} · {} · {} phases", m.id, m.location, m.phases);
egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| {
if !m.objectives.is_empty() {
ui.strong("Objectives");
for o in &m.objectives {
ui.label(format!("{o}"));
}
}
if !m.lose.is_empty() {
ui.add_space(4.0);
ui.strong("Fail conditions");
for l in &m.lose {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l);
}
}
if !m.enemies.is_empty() {
ui.add_space(4.0);
ui.strong("Enemy roster");
for e in &m.enemies {
ui.label(format!("{e}"));
}
}
});
}
}
GameCategory::Arsenal => {
ui.columns(4, |cols| {
for (i, (title, list)) in [
("Nose", &snapshot.arsenal.nose),
("Arm 1", &snapshot.arsenal.arm1),
("Arm 2", &snapshot.arsenal.arm2),
("Arm 3", &snapshot.arsenal.arm3),
]
.into_iter()
.enumerate()
{
cols[i].strong(format!("{title} ({})", list.len()));
for w in list {
cols[i].label(w.replace('_', " "));
}
}
});
}
GameCategory::Flights => {
ui.label(
egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(),
);
ui.add_space(4.0);
for (n, lineup) in snapshot.flights.iter().enumerate() {
egui::CollapsingHeader::new(format!("Line-up {}", n + 1))
.id_salt(n)
.default_open(n == 0)
.show(ui, |ui| {
egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| {
for (cs, pilot) in lineup {
ui.label(cs);
ui.strong(pilot);
ui.end_row();
}
});
});
}
}
});
});
game_data.open &= open;
}
// ── Ships browser ───────────────────────────────────────────────────────────────
/// The Ships browser — a floating window over the capital ships reconstructed
/// from XBG7 part families. Picking a ship assembles its parts and renders the
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
#[cfg(not(target_arch = "wasm32"))]
fn draw_ships_ui(
mut contexts: EguiContexts,
mut ships: ResMut<ShipBrowser>,
mut requests: EventReader<RequestShipCatalog>,
mut render: EventWriter<RequestShipRender>,
) {
if requests.read().next().is_some() {
ships.open = true;
}
if !ships.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
egui::Window::new("🚀 Ships")
.default_width(560.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if ships.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Scanning stage models for ships…");
});
ctx.request_repaint();
return;
}
if !ships.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
// Faction filter chips.
ui.horizontal(|ui| {
for (label, val) in
[("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
{
let sel = ships.faction == val;
if ui.selectable_label(sel, label).clicked() {
ships.faction = val.to_string();
}
}
});
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(&mut ships.filter);
if !ships.filter.is_empty() && ui.small_button("").clicked() {
ships.filter.clear();
}
});
ui.horizontal(|ui| {
ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
ui.label(
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
.weak()
.small(),
);
});
ui.separator();
let ShipBrowser { rows, filter, faction, selected, show_external, .. } = &mut *ships;
let show_external = *show_external;
let needle = filter.to_lowercase();
let mut to_render: Option<(String, String, String)> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
let mut last_section = String::new();
for row in rows.iter() {
if !faction.is_empty() && &row.faction != faction {
continue;
}
if !needle.is_empty()
&& !row.name.to_lowercase().contains(&needle)
&& !row.id.contains(&needle)
{
continue;
}
// Section header: capital ships first, then other assemblies.
let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
if section != last_section {
ui.add_space(4.0);
ui.label(egui::RichText::new(section).strong().weak());
last_section = section.to_string();
}
let is_sel = selected.as_deref() == Some(row.id.as_str());
let color = match row.faction.as_str() {
"TCAF" => egui::Color32::from_rgb(120, 170, 235),
"ADAN" => egui::Color32::from_rgb(235, 120, 120),
_ => egui::Color32::from_rgb(180, 180, 180),
};
egui::CollapsingHeader::new(
egui::RichText::new(format!("{} · {}", row.name, row.id)).color(color),
)
.id_salt(&row.id)
.default_open(false)
.show(ui, |ui| {
egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
ui.label("Faction");
ui.strong(&row.faction);
ui.end_row();
if let Some(hp) = row.hp {
ui.label("Hull HP");
ui.strong(format!("{hp:.0}"));
ui.end_row();
}
if let Some((x, y, z)) = row.size {
ui.label("Size (m)");
ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
ui.end_row();
}
if row.turrets.is_some() || row.shield_gens.is_some() {
ui.label("Hardpoints");
ui.strong(format!(
"{} turrets · {} bridges · {} shield gens",
fint(row.turrets),
fint(row.bridges),
fint(row.shield_gens),
));
ui.end_row();
}
ui.label("Model parts");
ui.strong(format!("{}", row.parts.len()));
ui.end_row();
ui.label("Appears in");
ui.strong(row.stages.join(", "));
ui.end_row();
});
let btn = egui::Button::new(if is_sel {
"● Showing in 3D view"
} else {
"▶ Assemble & view in 3D"
});
if ui.add(btn).clicked() {
to_render = Some((
row.id.clone(),
row.stage_file.clone(),
format!("{} ({})", row.name, row.id),
));
}
});
}
});
if let Some((id, file, label)) = to_render {
let id2 = id.clone();
*selected = Some(id);
render.send(RequestShipRender { file, id: id2, external: show_external, label });
}
});
ships.open &= open;
}

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