62 Commits

Author SHA1 Message Date
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
MechaCat02
95de29f290 feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:15:41 +02:00
MechaCat02
578c71a1b9 [formats,viewer] Decode grouped-pool XBG7 models; fix mesh routing + albedo
Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index
buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal
agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the
winding-consistency gate.

Crack the grouped-pool layout used by the hero ship and ~150 detailed
models. A resource's sub-meshes share one index pool (buffers 4-byte
aligned, in descriptor-marker order) followed by one vertex pool (each
vtx_count*stride, same order); the vertex pool is 4-byte aligned after
the index pool. The whole resource is derived from one anchored pivot:
  ib0 = vb0 - span - pad   (pad in 0..=3, alignment)
  ib[i] = align4(ib[i-1] + idx_count[i-1]*2)
  vb[i] = vb[i-1] + vtx_count[i-1]*stride
vb0 is found via the unit-normal vertex-run scan; the alignment is
confirmed by validating the LARGEST sub-mesh (most reliable), after
which the rest are read/validated. A single marker reduces this to the
existing adjacency anchor, so grouped generalises it.

Results (cross-checked against a Canary GPU draw-log capture of
DeltaSaber_T.xpr):
- DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh
  0-degenerate / full-coverage / winding-agreement 1.000.
- All 19 previously-declined weapon models now decode (they hit pad 2
  and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus
  audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer).
- Stages unchanged (single-marker path is byte-identical; grouped falls
  back to the old anchor on failure). 7/7 disc tests green incl. the
  strict stage quality audit; new hero_ship_grouped_pool_decodes test.

Viewer: route by count_xbg7; --only matches an exact model name (so the
neutral pose renders without the mnv*/turn180 animation poses). Fix the
albedo matcher: match a sub-model to its _col map by entity stem
(e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage
sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour
correctness (channel order/sRGB) remains a separate dynamic-RE item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:54:39 +02:00
MechaCat02
a937779c77 [formats] XBG7 meshes: index buffers are triangle STRIPS, not lists
The weapon/prop models (rou_fxxx_wep_nn.xpr) rendered with triangular holes in
the hull and misplaced/extra spikes. Root cause: XBG7 sub-mesh index buffers are
triangle STRIPS, but the decoder read them as triangle LISTS — a strip of N
indices is N-2 triangles, a list is only N/3, so ~2/3 of every hull was missing
(the holes) and each list-triple of strip data spanned unrelated vertices (the
spikes). The tell: triangles-per-vertex was 0.5-1.0 with 0 unreferenced vertices
(a closed surface needs ~2.0).

- mesh.rs: expand_triangle_strip() converts strip -> list with alternating
  winding, skipping degenerate triangles (repeated index = strip restart).
  Applied in both from_xpr2 (weapons) and read_pool_mesh (stage sub-models).
  All 71 weapon submeshes now have healthy ratios; hulls fill in (wep_03 cannon,
  wep_04 pod, wep_11, wep_37 verified coherent).
- Module doc updated (strip, not list).
- sylpheed-cli: `mesh info` reports per-submesh degenerate/unref/spanning-triangle
  counts; `mesh render` gains --dist (camera zoom). XMESHDBG env dumps the
  descriptor index markers.

Known residual: an index buffer may concatenate several strips with no degenerate
bridge, leaving ~2 spanning "spike" triangles at each restart (index jump to a new
vertex region) — <1% of tris on most models. Decoding the restart mechanism is a
follow-up (a blanket spanning-filter is unsafe: clean models have legit elongated
tip triangles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:04:12 +02:00
MechaCat02
7143ea18fe [formats] Fully crack T8aD: decode the header's per-tile offset table
Follow-up to the 256×256-tiling reversal — the residual boundary seams on
≥2×2-tile textures are gone. Decoding the file header revealed the exact
layout (no oracle capture needed):

  0x00  44        base header
  0x1c  4         tile count = ceil(w/256) * ceil(h/256)
  0x2c  tiles*4   BE-u32 absolute offset of each row-major 256×256 tile
  <off> 16        per-tile header, then tile_w*tile_h*4 A8R8G8B8 pixels

The old seams came from ignoring the offset table and the 16-byte per-tile
header (contiguous-packing drifted 16 bytes per tile). Small textures decoded
before only by luck: one tile puts pixels at 44+4+16 = 64, the old type-1
"header size". Now the 8AX title background, the prselect_win1 window frame,
and preff04 all decode pixel-perfect (verified against the title screen).

- t8ad.rs: parse() walks the offset table; dropped detile_256 + the
  header-size-by-type table. Non-tilecount entries (field != ceil*ceil) return
  None (likely DXT/other, deferred). New multi-tile round-trip test.
- sylpheed-cli: pak textures decodes via parse(); XDUMPHDR=1 dumps the base
  header + offset table for RE. Removed the now-obsolete XTILE/XSCORE/XDESTRIP
  experiment knobs and the reconstruct_tiles/TV-sweep helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:55:25 +02:00
MechaCat02
4ba723b9a5 [formats] Crack large-T8aD tiling: 256×256 row-major raster tiles
Verified against the running game (freeze-fixed Canary title screen) that
T8aD colours are correct (no R↔B swap) and that wide textures were garbled
by an unreversed tile layout, not a colour bug.

Reversed the layout: large T8aD are stored as 256×256 raster tiles in
row-major order, each tile raster internally, edge tiles clipped to the
image (payload is exactly w*h*4, no padding). Surfaces ≤256px wide are a
single tile column, identical to plain linear — which is why small UI
textures always decoded correctly.

- t8ad.rs: add detile_256() + apply in parse(). Single tile-row / single
  tile-column textures now decode exactly (ptcopyright, ptbtn, ptlogo1 =
  clean "PROJECT" logo, previously pure noise). Known residual: ≥2×2-tile
  textures (>256 in both dims, e.g. the 8AX background) come out coherent
  but with boundary seams — the multi-tile order is a subtle swizzle, TBD.
- sylpheed-cli: new `pak textures <pak> <out>` command — decodes every
  T8aD (direct, RATC-nested, LSTA frames) to PNG for A/B against the game.
  Env debug knobs for layout RE: XTILE/XDESTRIP/XDETILE_W/XREINTERPRET_PITCH
  /XSCORE (TV-ranked tile sweep), plus --verbose size classification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:37:35 +02:00
156 changed files with 31371 additions and 650 deletions

1
.gitignore vendored
View File

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

40
Cargo.lock generated
View File

@@ -1892,6 +1892,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -4107,6 +4126,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -4587,6 +4626,7 @@ dependencies = [
"binrw",
"flate2",
"futures",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.18",

View File

@@ -122,6 +122,17 @@ enum PakCommands {
/// Entry name-hash, e.g. `0x7c96296c`
hash: String,
},
/// Decode every T8aD texture in the pak (direct, RATC-nested, and LSTA
/// frames) to PNG — our decoder's output, for A/B against the running game.
Textures {
/// Path to the `.pak` index
pak: PathBuf,
/// Output directory for the PNGs (created if missing)
output: PathBuf,
/// Print per-texture dimensions + tile count.
#[arg(long)]
verbose: bool,
},
}
#[derive(Subcommand)]
@@ -146,6 +157,9 @@ enum MeshCommands {
/// Camera pitch in degrees
#[arg(long, default_value_t = 22.0)]
pitch: f32,
/// Camera distance multiplier (1.0 = framed; <1 zooms in, >1 out)
#[arg(long, default_value_t = 1.0)]
dist: f32,
/// Force the stage grid layout even for single models
#[arg(long)]
row: bool,
@@ -194,13 +208,16 @@ async fn main() -> Result<()> {
},
Commands::Mesh { cmd } => match cmd {
MeshCommands::Info { file } => cmd_mesh_info(&file),
MeshCommands::Render { file, output, size, yaw, pitch, row, only } => {
cmd_mesh_render(&file, &output, size, yaw, pitch, row, only)
MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => {
cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only)
}
},
Commands::Pak { cmd } => match cmd {
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::Textures { pak, output, verbose } => {
cmd_pak_textures(&pak, &output, verbose)
}
},
}
}
@@ -406,17 +423,30 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
/// yields more geometry.
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
use sylpheed_formats::mesh::Xbg7Model;
let single = Xbg7Model::from_xpr2(bytes)
use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
// Route by container kind, NOT by whichever decoder yields more verts (that
// old heuristic let `stage_models`' content-anchoring win on single-model
// files, fabricating phantom / duplicate / mis-anchored blocks). A file with
// one XBG7 resource is a single model (weapon / prop) → use only the
// validated records-based list decode; content-anchoring a single-model file
// invents geometry. Many XBG7 resources → a Stage_* collection → anchor them.
if count_xbg7(bytes) > 1 {
// Multi-resource Stage_* collection → content-anchor every resource
// (each anchor now gated by stored-normal agreement).
Xbg7Model::stage_models(bytes)
} else if let Some(m) = Xbg7Model::from_xpr2(bytes)
.ok()
.filter(|m| !m.meshes.is_empty());
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
let stage = Xbg7Model::stage_models(bytes);
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
if !stage.is_empty() && stage_verts > single_verts {
stage
.filter(|m| !m.meshes.is_empty())
{
// Single model the records-based list decode carved (authoritative).
vec![m]
} else {
single.into_iter().collect()
// Single model from_xpr2 couldn't locate (its sequential carve missed
// the block) — fall back to content-anchoring, which finds it by shape.
// Use a STRICT winding-consistency gate (0.85): on a single-model file a
// mis-anchor is an obvious phantom / spike-mess and must be declined,
// unlike the large stage corpus which keeps the ungated path.
Xbg7Model::anchor_models(bytes, 0.85)
}
}
@@ -452,6 +482,65 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
hi[1] - lo[1],
hi[2] - lo[2]
);
// Per-sub-mesh integrity diagnostics: degenerate triangles (a zero-area
// "hole"), vertices referenced by no triangle (dropped geometry), and the
// referenced index range vs the vertex count (short/over reads).
for (si, sub) in m.meshes.iter().enumerate() {
let nv = sub.positions.len();
let mut referenced = vec![false; nv];
let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
for tri in sub.indices.chunks_exact(3) {
let (a, b, c) = (tri[0], tri[1], tri[2]);
imax = imax.max(a).max(b).max(c);
if a == b || b == c || a == c {
degen += 1;
}
for &i in tri {
if (i as usize) < nv {
referenced[i as usize] = true;
} else {
oob += 1;
}
}
}
let unref = referenced.iter().filter(|&&r| !r).count();
// Spanning triangles: longest edge ≫ the median (strip-junction spikes).
let edge = |a: u32, b: u32| {
let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]);
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
};
let mut maxedges: Vec<f32> = sub
.indices
.chunks_exact(3)
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
.collect();
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6);
let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count();
println!(
" sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}",
sub.indices.len() / 3,
nv.saturating_sub(1),
if oob > 0 { format!(", OOB {oob}") } else { String::new() },
);
// XDUMPVERT=1 → print the first few vertex positions per sub-mesh, for
// content-matching a decoded sub-mesh against the GPU draw log.
if std::env::var("XDUMPVERT").is_ok() {
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for p in &sub.positions {
for a in 0..3 {
lo[a] = lo[a].min(p[a]);
hi[a] = hi[a].max(p[a]);
}
}
println!(
" bbox X[{:.2}..{:.2}] Y[{:.2}..{:.2}] Z[{:.2}..{:.2}] ctr({:.2},{:.2},{:.2})",
lo[0], hi[0], lo[1], hi[1], lo[2], hi[2],
(lo[0]+hi[0])/2.0, (lo[1]+hi[1])/2.0, (lo[2]+hi[2])/2.0
);
}
}
}
println!(
" {} {} sub-models · {} verts · {} tris",
@@ -470,13 +559,21 @@ fn cmd_mesh_render(
size: u32,
yaw: f32,
pitch: f32,
dist: f32,
force_row: bool,
only: Option<String>,
) -> Result<()> {
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
let mut models = decode_models(&bytes);
if let Some(sub) = &only {
models.retain(|m| m.name.contains(sub.as_str()));
// Prefer an exact name match (e.g. `f001` for the neutral ship pose,
// excluding the `_rou_f001_mnv*` animation poses that also *contain*
// "f001"); fall back to substring when nothing matches exactly.
if models.iter().any(|m| m.name == *sub) {
models.retain(|m| m.name == *sub);
} else {
models.retain(|m| m.name.contains(sub.as_str()));
}
}
if models.is_empty() {
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
@@ -488,7 +585,32 @@ fn cmd_mesh_render(
// uniformly scaled to a fixed cell, so all are equally visible regardless of
// native scale (mirrors `spawn_stage_models`).
let multi = models.len() > 1 || force_row;
// XMIRROR=x|y|z → negate that axis, to test an Xbox(LH)→Bevy(RH) handedness
// flip against reference screenshots.
let mirror: [f32; 3] = match std::env::var("XMIRROR").ok().as_deref() {
Some("x") => [-1.0, 1.0, 1.0],
Some("y") => [1.0, -1.0, 1.0],
Some("z") => [1.0, 1.0, -1.0],
_ => [1.0, 1.0, 1.0],
};
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
// XCOLORSUB=1 tints each sub-mesh a distinct colour (to see which sub is
// which part / where the "extra fin" comes from). Parallel to `tris`.
let color_sub = std::env::var("XCOLORSUB").is_ok();
// XONLYSUB=N renders only the N-th global sub-mesh (to isolate one part).
let only_sub: Option<usize> = std::env::var("XONLYSUB").ok().and_then(|s| s.parse().ok());
let mut tints: Vec<[f32; 3]> = Vec::new();
const PALETTE: [[f32; 3]; 8] = [
[1.0, 1.0, 1.0], // sub0 body = white
[1.0, 0.35, 0.35], // sub1 red
[0.35, 1.0, 0.35], // sub2 green
[0.4, 0.55, 1.0], // sub3 blue
[1.0, 0.9, 0.3], // sub4 yellow
[1.0, 0.5, 1.0], // sub5 magenta
[0.3, 1.0, 1.0], // sub6 cyan
[1.0, 0.6, 0.2], // sub7 orange
];
let mut sub_gi = 0usize;
const CELL: f32 = 10.0;
const GAP: f32 = 4.0;
let grid_pitch = CELL + GAP;
@@ -520,29 +642,105 @@ fn cmd_mesh_render(
} else {
(1.0, [0.0, 0.0, 0.0])
};
for sub in &m.meshes {
for tri in sub.indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a >= sub.positions.len() || b >= sub.positions.len() || c >= sub.positions.len() {
// XNODEXFORM=1 applies the XBG7 scene-graph node placement (fins move to
// the tail) — to verify the transforms recovered from the graph.
let placements = if std::env::var("XNODEXFORM").is_ok() {
sylpheed_formats::mesh::node_transforms(&bytes, &m.name)
} else {
Vec::new()
};
for (sub_local, sub) in m.meshes.iter().enumerate() {
// Every scene-graph instance that draws this sub-mesh (mirrored fin
// pair, L/R winglets…); `None` = no graph placement → identity.
let mine: Vec<Option<&sylpheed_formats::mesh::NodePlacement>> = {
let v: Vec<_> = placements
.iter()
.filter(|p| p.sub_index == sub_local)
.map(Some)
.collect();
if v.is_empty() {
vec![None]
} else {
v
}
};
// Sub-mesh indices are a triangle list (the decoder has already
// expanded the file's triangle strips).
let n = sub.positions.len();
// XSPANONLY=1 renders ONLY long-edge ("spanning") triangles; XSPANHIDE=1
// renders everything EXCEPT them — to see whether the flagged spanning
// triangles are real geometry or decode artifacts (phantom sheets).
let span_only = std::env::var("XSPANONLY").is_ok();
let span_hide = std::env::var("XSPANHIDE").is_ok();
let med = {
let mut e: Vec<f32> = sub
.indices
.chunks_exact(3)
.filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n)
.map(|t| {
let d = |a: u32, b: u32| {
let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]);
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2))
.sqrt()
};
d(t[0], t[1]).max(d(t[1], t[2])).max(d(t[0], t[2]))
})
.collect();
e.sort_by(|a, b| a.partial_cmp(b).unwrap());
e.get(e.len() / 2).copied().unwrap_or(1.0).max(1e-6)
};
if let Some(want) = only_sub {
if sub_gi != want {
sub_gi += 1;
continue;
}
}
let tint = if color_sub {
PALETTE[sub_gi % PALETTE.len()]
} else {
[1.0, 1.0, 1.0]
};
for place in &mine {
let f = |i: usize| {
let p = sub.positions[i];
let p = place.map(|pl| pl.apply(sub.positions[i])).unwrap_or(sub.positions[i]);
[
(p[0] - center[0]) * scale + cell[0],
(p[1] - center[1]) * scale + cell[1],
(p[2] - center[2]) * scale + cell[2],
(p[0] - center[0]) * scale * mirror[0] + cell[0],
(p[1] - center[1]) * scale * mirror[1] + cell[1],
(p[2] - center[2]) * scale * mirror[2] + cell[2],
]
};
tris.push([f(a), f(b), f(c)]);
for tri in sub.indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a < n && b < n && c < n {
if span_only || span_hide {
let d = |i: usize, j: usize| {
let (p, q) = (sub.positions[i], sub.positions[j]);
((p[0] - q[0]).powi(2)
+ (p[1] - q[1]).powi(2)
+ (p[2] - q[2]).powi(2))
.sqrt()
};
let spanning = d(a, b).max(d(b, c)).max(d(a, c)) > 6.0 * med;
if span_only && !spanning {
continue;
}
if span_hide && spanning {
continue;
}
}
tris.push([f(a), f(b), f(c)]);
tints.push(tint);
}
}
}
sub_gi += 1;
}
}
if tris.is_empty() {
anyhow::bail!("no triangles to render");
}
let rgba = rasterize(&tris, size, yaw, pitch);
let rgba = rasterize(&tris, &tints, size, yaw, pitch, dist);
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
.with_context(|| format!("writing PNG {}", output.display()))?;
println!(
@@ -561,7 +759,14 @@ fn cmd_mesh_render(
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
/// headlight shading over a flat grey material on a dark background. Enough to
/// judge whether recovered geometry is coherent.
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> Vec<u8> {
fn rasterize(
tris: &[[[f32; 3]; 3]],
tints: &[[f32; 3]],
size: u32,
yaw_deg: f32,
pitch_deg: f32,
dist: f32,
) -> Vec<u8> {
let n = size as usize;
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
let (cy, sy) = (yaw.cos(), yaw.sin());
@@ -588,7 +793,7 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) ->
}
}
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
let scale = (n as f32) * 0.9 / span;
let scale = (n as f32) * 0.9 / (span * dist.max(1e-3));
let cx = (lo[0] + hi[0]) * 0.5;
let cyv = (lo[1] + hi[1]) * 0.5;
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
@@ -609,7 +814,8 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) ->
[l[0] / m, l[1] / m, l[2] / m]
};
for t in tris {
for (ti, t) in tris.iter().enumerate() {
let tint = tints.get(ti).copied().unwrap_or([1.0, 1.0, 1.0]);
let v0 = view(t[0]);
let v1 = view(t[1]);
let v2 = view(t[2]);
@@ -657,9 +863,9 @@ fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) ->
let idx = py * n + px;
if z < depth[idx] {
depth[idx] = z;
color[idx * 4] = shade;
color[idx * 4 + 1] = shade;
color[idx * 4 + 2] = (shade as f32 * 1.02).min(255.0) as u8;
color[idx * 4] = (shade as f32 * tint[0]).min(255.0) as u8;
color[idx * 4 + 1] = (shade as f32 * tint[1]).min(255.0) as u8;
color[idx * 4 + 2] = (shade as f32 * tint[2] * 1.02).min(255.0) as u8;
}
}
}
@@ -833,3 +1039,179 @@ fn cmd_pak_dump(pak: &Path, hash_str: &str) -> Result<()> {
}
Ok(())
}
// ── pak textures ─────────────────────────────────────────────────────────────
/// Turn a child name into a filesystem-safe fragment.
fn safe_name(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect()
}
#[derive(Default)]
struct TexStats {
written: usize,
skipped: usize,
}
fn be32_at(b: &[u8], off: usize) -> u32 {
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
/// Decode one T8aD slice (whose first bytes are the magic) to PNG. With
/// `verbose`, prints its dimensions + tile count; the `XDUMPHDR` env var dumps
/// the raw header (base + offset table) for format RE.
fn emit_t8ad(
slice: &[u8],
hash: u32,
stem: &str,
output: &Path,
verbose: bool,
stats: &mut TexStats,
) -> Result<()> {
use sylpheed_formats::t8ad;
if !t8ad::is_t8ad(slice) || slice.len() < 0x40 {
return Ok(());
}
let (w, h, tiles) = (
be32_at(slice, 0x14),
be32_at(slice, 0x18),
be32_at(slice, 0x1c),
);
// Debug: dump the header — 44-byte base + the `tiles`-entry u32 offset table.
if std::env::var("XDUMPHDR").is_ok() {
println!("\n{stem} {w}x{h} tiles={tiles}");
print!(" base[0x00..0x2c]:");
for i in (0..44).step_by(4) {
print!(" {:08x}", be32_at(slice, i));
}
print!("\n offsets:");
for t in 0..(tiles as usize).min(64) {
if 0x2c + t * 4 + 4 <= slice.len() {
print!(" {}", be32_at(slice, 0x2c + t * 4));
}
}
println!();
return Ok(());
}
if verbose {
println!(" {hash:08x} {stem:<34} {w:>4}x{h:<4} tiles {tiles}");
}
match t8ad::parse(slice) {
Some(img) => {
let out =
output.join(format!("{hash:08x}_{stem}_{}x{}.png", img.width, img.height));
image::save_buffer(
&out,
&img.rgba,
img.width,
img.height,
image::ExtendedColorType::Rgba8,
)
.with_context(|| format!("writing PNG {}", out.display()))?;
stats.written += 1;
}
None => stats.skipped += 1,
}
Ok(())
}
/// Decode every T8aD in a pak (direct entries, RATC-nested children, LSTA
/// frames) to PNG — our decoder's exact output — for A/B against the game.
fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
use sylpheed_formats::{lsta, ratc, t8ad};
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
std::fs::create_dir_all(output)
.with_context(|| format!("creating {}", output.display()))?;
println!(
"{} {}{}",
"Textures".green().bold(),
pak.display().to_string().cyan(),
output.display().to_string().cyan(),
);
let mut stats = TexStats::default();
for e in arc.entries() {
let payload = match arc.read(e) {
Ok(p) => p,
Err(_) => continue,
};
let hash = e.name_hash;
// Direct T8aD entry.
if t8ad::is_t8ad(&payload) {
emit_t8ad(&payload, hash, "direct", output, verbose, &mut stats)?;
continue;
}
// LSTA sprite list = N inline T8aD frames (walk by magic, emit each).
if lsta::is_lsta(&payload) {
let mut off = 0usize;
let mut idx = 0usize;
while let Some(pos) = payload[off..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
{
let start = off + pos;
let next = payload[start + 4..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.map(|p| start + 4 + p)
.unwrap_or(payload.len());
emit_t8ad(
&payload[start..next],
hash,
&format!("lsta{idx:03}"),
output,
verbose,
&mut stats,
)?;
idx += 1;
off = next;
}
continue;
}
// RATC bundle: decode its T8aD children (named, e.g. `foo.t32`).
if ratc::is_ratc(&payload) {
if let Some(children) = ratc::parse(&payload) {
for (i, child) in children.iter().enumerate() {
if child.kind != "T8aD" {
continue;
}
let end = (child.offset + child.size).min(payload.len());
if child.offset >= end {
continue;
}
let stem = if child.name.is_empty() {
format!("child{i:03}")
} else {
safe_name(&child.name)
};
emit_t8ad(&payload[child.offset..end], hash, &stem, output, verbose, &mut stats)?;
}
}
continue;
}
}
println!(
"\n {} PNG(s) written, {} undecodable (non-tilecount variants — likely DXT)",
stats.written.to_string().green(),
stats.skipped.to_string().yellow(),
);
Ok(())
}

View File

@@ -19,5 +19,11 @@ tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
# Native-only: parallelise the per-resource XBG7 stage anchoring (hundreds of
# independent sub-models per container). rayon needs OS threads, so it is not
# pulled in for the wasm32 build, which falls back to a sequential decode.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = "1"
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,21 @@
# 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
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,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,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,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,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,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,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,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,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,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,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,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,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 @@
//! 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,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,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,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,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,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,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

@@ -50,10 +50,32 @@ pub mod mesh;
// Audio parsing — scaffold for XMA handling
pub mod audio;
// Movie cutscene subtitles — the movie → track → text chain.
pub mod movie_subtitle;
// XACT `.slb` sound banks (voice / music) → decodable XMA1 RIFF.
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;
// 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};
pub use ixud::{Cue, Subtitle};
pub use movie_subtitle::{SubCue, SubLang};
pub use mesh::{GameMesh, Xbg7Model};
pub use ratc::RatcChild;
pub use t8ad::T8adImage;

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,428 @@
//! 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, …).
//!
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
//!
//! 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.
//!
//! 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;
/// 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 {
/// 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 (`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
/// carries movie + subtitle + voice references.) Used to locate it in
/// `tables.pak` without hardcoding a hash.
pub fn is_manifest(bytes: &[u8]) -> bool {
bytes.len() >= 4
&& &bytes[0..4] == b"IDXD"
&& contains(bytes, b".wmv")
&& contains(bytes, b"VOICE_")
&& contains(bytes, b"SUBTITLE_")
}
/// 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 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 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));
}
}
}
// 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.
///
/// Returns `None` both when the movie is absent and when it is present with no
/// voice — callers that need to distinguish should use [`parse`].
pub fn voice_token(bytes: &[u8], movie: &str) -> Option<String> {
parse(bytes)
.into_iter()
.find(|e| e.movie == movie)
.and_then(|e| e.voice_token)
}
/// Resolve a movie to its full `sound.pak` voice-entry name for `lang`, using the
/// manifest for the *token* and `sounds.tbl` for the token's *directory* (Movie /
/// etc / Voice differ per token). `None` = the movie has no voice-over.
pub fn resolve_voice_entry(
manifest: &[u8],
sounds_tbl: &[u8],
movie: &str,
lang: VoiceLang,
) -> Option<String> {
let token = voice_token(manifest, movie)?;
resolve_token_path(sounds_tbl, &token, lang)
}
/// Find the full `<lang>\…\<token>.slb` path for a voice `token` in a decompressed
/// `sounds.tbl` (the token's subdir — `Movie`, `etc`, `Voice` — is not fixed).
pub fn resolve_token_path(sounds_tbl: &[u8], token: &str, lang: VoiceLang) -> Option<String> {
let prefix = format!("{}\\", lang.code_pub());
let suffix = format!("\\{token}.slb");
ascii_runs(sounds_tbl, 6)
.into_iter()
.find(|r| r.starts_with(&prefix) && r.ends_with(&suffix))
}
fn contains(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// Collect printable-ASCII runs of at least `min` chars.
fn ascii_runs(bytes: &[u8], min: usize) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for &b in bytes {
if (0x20..=0x7e).contains(&b) {
cur.push(b as char);
} else {
if cur.len() >= min {
out.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
}
}
if cur.len() >= min {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn synth() -> Vec<u8> {
// Minimal IDXD string pool: three movies — a standard one, a hokyu bound
// to a VOICE_D radio clip, and a hokyu with no voice.
let mut b = b"IDXD".to_vec();
b.extend_from_slice(&[0, 0, 0, 0x69, 0, 0]); // binary header (as on disc)
for s in [
"S13A.wmv",
"eng.pak+SUBTITLE_S13A.tbl",
"VOICE_S13A",
"hokyu_LS_s02A.wmv",
"eng.pak+SUBTITLE_hokyu_LS_s02A.tbl",
"VOICE_D_450",
"hokyu_DS_s13A.wmv",
"eng.pak+SUBTITLE_hokyu_DS_s13A.tbl",
] {
b.extend_from_slice(s.as_bytes());
b.push(0);
}
b
}
#[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]
fn resolves_token_directory_from_sounds_tbl() {
// sounds.tbl places the two tokens in different subdirs.
let mut tbl = Vec::new();
for s in ["eng\\Movie\\VOICE_S13A.slb", "eng\\etc\\VOICE_D_450.slb"] {
tbl.extend_from_slice(s.as_bytes());
tbl.push(0);
}
let man = synth();
assert_eq!(
resolve_voice_entry(&man, &tbl, "S13A", VoiceLang::English).as_deref(),
Some("eng\\Movie\\VOICE_S13A.slb")
);
assert_eq!(
resolve_voice_entry(&man, &tbl, "hokyu_LS_s02A", VoiceLang::English).as_deref(),
Some("eng\\etc\\VOICE_D_450.slb")
);
assert_eq!(
resolve_voice_entry(&man, &tbl, "hokyu_DS_s13A", VoiceLang::English),
None
);
}
}

View File

@@ -0,0 +1,454 @@
//! Movie cutscene subtitles: the movie → track → text chain.
//!
//! Reverse-engineered statically (see `docs/re/structures/movie-subtitles.md`).
//! A cutscene's on-screen captions are assembled from three places on the disc:
//!
//! 1. `dat/movie/<lang>.pak` holds one **timing track** per movie, keyed by
//! [`track_key`] = `name_hash("subtitle_<movie>.tbl")`. Each track is a
//! `Z1`+zlib block that decompresses to an **IXUD** container whose UTF-16**LE**
//! payload is `SUBTITLE MSG_DEMO_<demo> <mm:ss.cc> …` — i.e. *which*
//! demo-message shows *when* (radio / resupply movies), or inline text.
//! 2. `dat/GP_MAIN_GAME_<L>.pak` holds the **caption text**: more `Z1`+zlib IXUD
//! blocks where each line is stored as `text` immediately followed by its key
//! `MSG_DEMO_<demo>_<page>_<line>` (multi-line captions split across lines).
//!
//! [`load`] joins the two into timed [`SubCue`]s.
//!
//! Note: the IXUD payload here is UTF-16 **little-endian** (verified on the real
//! disc); this is deliberately separate from the older big-endian [`crate::ixud`]
//! presenter, which targets a different (uncompressed) variant.
use std::collections::BTreeMap;
use crate::hash::name_hash;
use crate::pak::PakArchive;
/// Subtitle language. `pak_code` selects `dat/movie/<code>.pak`; `game_code`
/// selects `dat/GP_MAIN_GAME_<code>.pak` (the caption text).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubLang {
English,
Japanese,
German,
French,
Spanish,
Italian,
}
impl SubLang {
/// All languages, in menu order.
pub const ALL: [SubLang; 6] = [
SubLang::English,
SubLang::Japanese,
SubLang::German,
SubLang::French,
SubLang::Spanish,
SubLang::Italian,
];
/// `dat/movie/<code>.pak` stem (the timing tracks + caption font).
pub fn pak_code(self) -> &'static str {
match self {
SubLang::English => "eng",
SubLang::Japanese => "jpn",
SubLang::German => "deu",
SubLang::French => "fra",
SubLang::Spanish => "esp",
SubLang::Italian => "ita",
}
}
/// `dat/GP_MAIN_GAME_<code>.pak` suffix (the caption text pack).
pub fn game_code(self) -> &'static str {
match self {
SubLang::English => "E",
SubLang::Japanese => "J",
SubLang::German => "D",
SubLang::French => "F",
SubLang::Spanish => "S",
SubLang::Italian => "I",
}
}
/// Human label for a UI selector. Kept to Latin script so it renders in a
/// default (non-CJK) UI font; Japanese is romanized for the same reason.
pub fn label(self) -> &'static str {
match self {
SubLang::English => "English",
SubLang::Japanese => "Japanese",
SubLang::German => "Deutsch",
SubLang::French => "Français",
SubLang::Spanish => "Español",
SubLang::Italian => "Italiano",
}
}
}
/// One timed caption line.
#[derive(Debug, Clone, PartialEq)]
pub struct SubCue {
pub start: f32,
pub end: Option<f32>,
pub text: String,
}
/// The `<lang>.pak` TOC key for a movie's subtitle timing track.
pub fn track_key(movie_basename: &str) -> u32 {
name_hash(&format!("subtitle_{movie_basename}.tbl"))
}
/// Load and resolve a movie's timed captions in `lang`.
///
/// `lang_pak` is `dat/movie/<lang>.pak` (+segments); `text_pak` is
/// `dat/GP_MAIN_GAME_<L>.pak` (+segments). Returns the cues in start order, or an
/// empty vec if the movie has no timed track (e.g. story movies whose text is a
/// pre-rendered title card).
pub fn load(movie_basename: &str, lang_pak: &PakArchive, text_pak: &PakArchive) -> Vec<SubCue> {
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 raw = parse_track(&track);
if raw.is_empty() {
return Vec::new();
}
// Only build the (large) text table if the track actually references demos.
let needs_text = raw.iter().any(|(t, _, _)| demo_ref(t).is_some());
let text = if needs_text {
build_demo_text(text_pak)
} else {
BTreeMap::new()
};
let mut cues = Vec::new();
for (token, start, end) in raw {
let resolved = match demo_ref(&token) {
Some(demo) => match text.get(&demo) {
Some(lines) => lines.join("\n"),
None => continue, // demo id with no text on disc → skip
},
None => clean(&token), // already inline text
};
if resolved.trim().is_empty() {
continue;
}
cues.push(SubCue {
start,
end,
text: resolved,
});
}
cues.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal));
cues
}
/// The `MSG_DEMO_<id>` ids a movie's timing track references, in track order
/// (empty for inline-text or absent tracks). Language-independent — the same
/// demo ids appear in every `<lang>.pak`. Used to share a voice clip between
/// movies that play the same demo line (the resupply cutscenes reuse one clip
/// per line while only the video differs).
pub fn track_demo_ids(lang_pak: &PakArchive, movie_basename: &str) -> Vec<u32> {
let Some(Ok(track)) = lang_pak.read_by_hash(track_key(movie_basename)) else {
return Vec::new();
};
if !is_ixud(&track) {
return Vec::new();
}
parse_track(&track)
.iter()
.filter_map(|(t, _, _)| demo_ref(t))
.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
/// following `MSG_DEMO_<demo>_<page>_<line>` key, and groups the lines per demo
/// ordered by (page, line).
pub fn build_demo_text(text_pak: &PakArchive) -> BTreeMap<u32, Vec<String>> {
// demo -> (page,line) -> text
let mut by_demo: BTreeMap<u32, BTreeMap<(u32, u32), String>> = BTreeMap::new();
for entry in text_pak.entries() {
let Ok(bytes) = text_pak.read(entry) else {
continue;
};
if !is_ixud(&bytes) {
continue;
}
let toks = utf16le_tokens(&bytes);
for w in toks.windows(2) {
if let Some((demo, page, line)) = text_key(&w[1]) {
// Pair only when the preceding token is real text — not another
// MSG_DEMO key (bare keys are also serialized consecutively in the
// record directory, which would otherwise masquerade as text).
if demo_ref(&w[0]).is_none()
&& text_key(&w[0]).is_none()
&& !w[0].trim().is_empty()
{
by_demo
.entry(demo)
.or_default()
.insert((page, line), clean(&w[0]));
}
}
}
}
by_demo
.into_iter()
.map(|(d, m)| (d, m.into_values().collect()))
.collect()
}
/// Parse a timing track's IXUD payload into `(token, start, end)` triples, where
/// `token` is either a `MSG_DEMO_<d>` reference or an inline caption string.
///
/// A single on-screen caption may be stored as **several consecutive text
/// tokens** followed by one timing (the source hard-splits a multi-line caption
/// into one token per line — e.g. `"Look at it father"` + `"& beautiful isn't
/// it"` share the `01:14.80-01:17.60` timing). We therefore accumulate every
/// text token seen since the last timing and join them with a newline when the
/// timing arrives, instead of pairing strictly 1:1 (which silently dropped every
/// line but the last of a multi-line caption).
fn parse_track(ixud: &[u8]) -> Vec<(String, f32, Option<f32>)> {
let toks = utf16le_tokens(ixud);
let start = toks
.iter()
.position(|t| t.ends_with("SUBTITLE"))
.map(|i| i + 1)
.unwrap_or(0);
let rest = &toks[start..];
let mut out = Vec::new();
let mut pending: Vec<&str> = Vec::new();
for tok in rest {
match parse_timing(tok) {
Some((s, e)) => {
if !pending.is_empty() {
out.push((pending.join("\n"), s, e));
pending.clear();
}
}
None => pending.push(tok),
}
}
out
}
fn is_ixud(b: &[u8]) -> bool {
b.len() >= 4 && &b[0..4] == b"IXUD"
}
/// Normalize a caption string: the source stores line breaks as the literal
/// two-character escape `\n`; turn it into a real newline and trim.
fn clean(s: &str) -> String {
s.replace("\\n", "\n").trim().to_string()
}
/// `MSG_DEMO_<d>` → `Some(d)` (reference token, no page/line).
fn demo_ref(t: &str) -> Option<u32> {
let rest = t.strip_prefix("MSG_DEMO_")?;
if rest.contains('_') {
return None; // that's a text key (has page/line), not a bare reference
}
rest.parse().ok()
}
/// `MSG_DEMO_<d>_<page>_<line>` → `Some((d,page,line))` (text key).
fn text_key(t: &str) -> Option<(u32, u32, u32)> {
let rest = t.strip_prefix("MSG_DEMO_")?;
let mut it = rest.split('_');
let d = it.next()?.parse().ok()?;
let p = it.next()?.parse().ok()?;
let l = it.next()?.parse().ok()?;
if it.next().is_some() {
return None;
}
Some((d, p, l))
}
/// Extract UTF-16**LE** text runs from a blob, **independent of byte alignment**.
///
/// The IXUD string pool packs records at odd byte offsets, so a fixed 2-byte
/// stride from the block start misreads every character. Instead we scan a
/// sliding window, decoding each 16-bit LE unit and keeping runs of "text-like"
/// code points (ASCII, Latin-1 supplement — the German umlauts / accented
/// Latin — and Latin Extended-A). A non-text unit ends the run and we advance by
/// one byte to re-lock onto the correct parity of the next run. Accepting the
/// Latin ranges (not just ASCII) is what keeps `müsste`, `Français`, `español`
/// intact — earlier the accented char *and its predecessor* were dropped.
///
/// CJK (Japanese) is deliberately out of range: those units also occur as noise
/// at the wrong parity, and the UI font can't render them anyway.
fn utf16le_tokens(bytes: &[u8]) -> Vec<String> {
let mut tokens = 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 {
tokens.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
i += 1;
}
}
if cur.chars().count() >= 2 {
tokens.push(cur);
}
tokens
}
/// Whether a UTF-16 unit is a printable Latin text character we keep in a run.
/// Excludes the C0/C1 control blocks (incl. 0x7F0x9F) so binary noise breaks
/// runs instead of joining them.
fn is_text_unit(u: u16) -> bool {
matches!(u, 0x20..=0x7e | 0xa0..=0xff | 0x100..=0x17f)
}
/// Parse `MM:SS.ss` or a `start-end` range into seconds.
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
let one = |p: &str| -> Option<f32> {
let (mm, ss) = p.trim().split_once(':')?;
let m: f32 = if mm.trim().is_empty() {
0.0
} else {
mm.trim().parse().ok()?
};
let sec: f32 = ss.trim().parse().ok()?;
Some(m * 60.0 + sec)
};
match s.split_once('-') {
Some((a, b)) => Some((one(a)?, Some(one(b)?))),
None => Some((one(s)?, None)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn track_key_matches_disc() {
// Verified against dat/movie/eng.pak.
assert_eq!(track_key("S00A"), 0x6F2D_9663);
assert_eq!(track_key("hokyu_DS_s02A"), 0x3662_B1F8);
assert_eq!(track_key("RT01C_1"), 0x756F_69FB);
}
#[test]
fn demo_and_text_keys() {
assert_eq!(demo_ref("MSG_DEMO_192"), Some(192));
assert_eq!(demo_ref("MSG_DEMO_604_000_00"), None);
assert_eq!(text_key("MSG_DEMO_604_000_01"), Some((604, 0, 1)));
assert_eq!(text_key("MSG_DEMO_192"), None);
}
#[test]
fn keeps_latin1_accents() {
// "müsste" must survive intact (umlaut + its neighbour), not collapse to
// "sste". Encode as UTF-16LE at an ODD start offset to exercise the
// alignment-independent scan.
let mut b = vec![0xAAu8]; // 1 junk byte → strings start at an odd offset
for s in ["müsste", "Français", "español"] {
for u in s.encode_utf16() {
b.extend_from_slice(&u.to_le_bytes());
}
b.extend_from_slice(&[0, 0]);
}
let toks = utf16le_tokens(&b);
assert!(toks.contains(&"müsste".to_string()), "got {toks:?}");
assert!(toks.contains(&"Français".to_string()), "got {toks:?}");
assert!(toks.contains(&"español".to_string()), "got {toks:?}");
}
/// Build a synthetic UTF-16LE IXUD and check the LE token walk + timing pair.
#[test]
fn parses_le_track() {
let mut b = b"IXUD".to_vec();
b.extend_from_slice(&[0, 1, 0, 0, 0x70, 0x3E, 0xC8, 0x6C]);
for t in ["SUBTITLE", "MSG_DEMO_5", "00:01.50", "Hello", "00:03.00"] {
for u in t.encode_utf16() {
b.extend_from_slice(&u.to_le_bytes());
}
b.extend_from_slice(&[0, 0]);
}
let raw = parse_track(&b);
assert_eq!(raw.len(), 2);
assert_eq!(raw[0], ("MSG_DEMO_5".to_string(), 1.5, None));
assert_eq!(raw[1], ("Hello".to_string(), 3.0, None));
}
/// Two text tokens before a single timing = one two-line caption; the first
/// line must NOT be dropped (the real S13A `Look at it father` regression).
#[test]
fn joins_multiline_caption() {
let mut b = b"IXUD".to_vec();
b.extend_from_slice(&[0, 1, 0, 0, 0x70, 0x3E, 0xC8, 0x6C]);
for t in [
"SUBTITLE",
"That is such magnificent power.",
"01:09.70-01:12.20",
"Look at it father",
"& beautiful isn't it",
"01:14.80-01:17.60",
] {
for u in t.encode_utf16() {
b.extend_from_slice(&u.to_le_bytes());
}
b.extend_from_slice(&[0, 0]);
}
let raw = parse_track(&b);
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].0, "That is such magnificent power.");
assert_eq!(
raw[1].0, "Look at it father\n& beautiful isn't it",
"both lines of the caption must be kept"
);
assert_eq!((raw[1].1, raw[1].2), (74.8, Some(77.6)));
}
}

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

@@ -178,6 +178,18 @@ impl PakArchive {
})
}
/// Parse **only** the TOC from a `.pak` index, without any segment data.
///
/// For huge archives (`sound.pak` is ~1 GB across `.p00..p04`) this lets a
/// caller locate one entry's `(offset, size)` and read just that byte range
/// from the segments, instead of loading the whole thing into memory. The
/// returned entries are sorted by `name_hash` (binary-searchable).
pub fn parse_toc(index: &[u8]) -> Result<Vec<PakEntry>, PakError> {
// Reuse from_parts' validation with an empty data blob; the entries are
// independent of the data.
Ok(Self::from_parts(index, Vec::new())?.entries)
}
/// All TOC entries, in stored order (ascending `name_hash`).
pub fn entries(&self) -> &[PakEntry] {
&self.entries

View File

@@ -0,0 +1,649 @@
//! 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;
};
if let Some(frame) =
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx)
{
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]
);
}
}
}
// Multi-instance coverage the capture couldn't see (vbase dedup).
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
// The mirrored starboard hull reflects (det < 0), the port one doesn't.
let det = |m: &[[f32; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
};
let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored");
assert!(det(&one("e106_bdy_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,694 @@
//! 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]>,
}
/// 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 flush = |vbase: u32,
vcount: u32,
pos: &mut Vec<[f32; 3]>,
consts: &[(usize, [f64; 4])],
out: &mut Vec<CapturedDraw>| {
let pos = std::mem::take(pos);
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 });
}
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") {
flush(vbase, vcount, &mut pos, &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 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, &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.)
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]) {
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos });
}
};
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, the starboard copy mirrored.
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

@@ -0,0 +1,370 @@
//! `.slb` XACT sound banks → a decodable XMA1 `RIFF`.
//!
//! `dat/sound.pak` (9519 entries across `sound.p00..p04`) is the game's audio
//! bank. Each entry is an XACT `.slb` wrapping **XMA1** (`fmt ` tag `0x0165`,
//! 48 kHz). Files are named `<lang>\Voice\…`, `<lang>\Movie\VOICE_<movie>.slb`,
//! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by
//! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous
//! `<eng|jpn>\Movie\VOICE_<movie>.slb` track meant to play from the video start.
//!
//! Two on-disc layouts, both reversed statically:
//! - **RIFF present** — a standard `RIFF/WAVE` sits inside the bank; its 32-byte
//! `fmt ` is the real `XMAWAVEFORMAT` and the XMA packets are everything after
//! that RIFF's `data` chunk header (the declared `data` size is unreliable, so
//! we take to end and let the caller clamp to the known media length).
//! - **Headerless** — no RIFF at all; a fixed **1392-byte** header precedes raw
//! XMA1 packets (48 kHz, 2 channels).
//!
//! [`to_xma_riff`] rebuilds a standalone `RIFF/WAVE` (XMA1) for either layout,
//! ready to hand to an XMA decoder (e.g. FFmpeg's `xma1`). The content is always
//! mono (some clips put it in the left channel only, others duplicate L=R), so
//! the decode step should downmix to mono (take the left channel).
/// Fixed offset of the raw XMA1 stream in a headerless `.slb` (no `RIFF`).
pub const HEADERLESS_DATA_OFFSET: usize = 1392;
/// Voice language for cutscene audio. Only English and Japanese voice exist on
/// the disc (subtitles cover more languages, voice does not).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceLang {
English,
Japanese,
}
impl VoiceLang {
fn code(self) -> &'static str {
match self {
VoiceLang::English => "eng",
VoiceLang::Japanese => "jpn",
}
}
/// `eng` / `jpn` — the `sound.pak` path prefix for this voice language.
pub fn code_pub(self) -> &'static str {
self.code()
}
}
/// The `sound.pak` entry name for a movie's continuous voice track, e.g.
/// `eng\Movie\VOICE_RT07A.slb`. Hash it with [`crate::hash::name_hash`] to get
/// the `sound.pak` TOC key.
pub fn movie_voice_name(movie_basename: &str, lang: VoiceLang) -> String {
format!("{}\\Movie\\VOICE_{}.slb", lang.code(), movie_basename)
}
/// One playable voice clip discovered in `sounds.tbl`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoiceClip {
/// Full `sound.pak` entry name, e.g. `eng\Voice\VOICE_ADAN_010.slb`.
pub name: String,
/// Speaker/category code, e.g. `ADAN`, `TCAF`, `A`, `RT07A`.
pub speaker: String,
/// Short UI label, e.g. `ADAN 010`.
pub display: String,
}
/// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the
/// IDXD in `tables.pak`). Extracts every `<lang>\{Voice,etc,Movie,Briefing}\…`
/// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`.
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
let prefix = format!("{}\\", lang.code());
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
// Scan for printable-ASCII runs; keep those that look like a voice path.
let mut i = 0;
while i < sounds_tbl.len() {
let start = i;
while i < sounds_tbl.len() && (0x20..=0x7e).contains(&sounds_tbl[i]) {
i += 1;
}
if i - start >= 6 {
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
// 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));
}
}
}
}
i += 1;
}
out
}
fn parse_voice_clip(name: &str) -> VoiceClip {
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`.
let stem = name
.rsplit('\\')
.next()
.unwrap_or(name)
.strip_suffix(".slb")
.unwrap_or(name);
let body = stem.strip_prefix("VOICE_").unwrap_or(stem);
let (speaker, display) = match body.rsplit_once('_') {
Some((spk, num)) if num.chars().all(|c| c.is_ascii_digit()) => {
(spk.to_string(), format!("{spk} {num}"))
}
_ => (body.to_string(), body.to_string()),
};
VoiceClip {
name: name.to_string(),
speaker,
display,
}
}
/// 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. 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
// `[seek][RIFF: fmt + Dmmy pad + data][declared_size XMA bytes]`. Take the
// FIRST sub-wave, bounded by its **declared `data` size** (which is
// honest per sub-wave). Decoding to end-of-file instead would append the
// later sub-waves — for multi-take story movies those are ALTERNATE takes,
// which is what made S10S16 play the wrong audio.
let fi = find(slb, b"fmt ", ri)?;
let fsz = le32(slb, fi + 4)? as usize;
let fmt_end = fi.checked_add(8)?.checked_add(fsz)?;
if fmt_end > slb.len() {
return None;
}
let fmt_chunk = &slb[fi..fmt_end];
let di = find(slb, b"data", fi)?;
let dsz = le32(slb, di + 4)? as usize;
let end = di.checked_add(8)?.checked_add(dsz)?.min(slb.len());
let data = slb.get(di + 8..end)?;
Some(build_riff(fmt_chunk, data))
} else {
// Headerless: 1392-byte header, then raw XMA1 (48 kHz, 2 channels).
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
if data.is_empty() {
return None;
}
Some(build_riff(&synth_xma1_fmt(2, 2, 48000), data))
}
}
/// 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);
fmt.extend_from_slice(b"fmt ");
fmt.extend_from_slice(&32u32.to_le_bytes());
// XMAWAVEFORMAT header
fmt.extend_from_slice(&0x0165u16.to_le_bytes()); // wFormatTag = XMA1
fmt.extend_from_slice(&16u16.to_le_bytes()); // BitsPerSample
fmt.extend_from_slice(&0u16.to_le_bytes()); // EncodeOptions
fmt.extend_from_slice(&0u16.to_le_bytes()); // LargestSkip
fmt.extend_from_slice(&1u16.to_le_bytes()); // NumStreams
fmt.push(0); // LoopCount
fmt.push(3); // Version
// XMASTREAMFORMAT[0]
fmt.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); // PsuedoBytesPerSec
fmt.extend_from_slice(&rate.to_le_bytes()); // SampleRate
fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopStart
fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopEnd
fmt.push(4); // SubframeData
fmt.push(channels); // Channels
fmt.extend_from_slice(&channel_mask.to_le_bytes()); // ChannelMask
fmt
}
fn build_riff(fmt_chunk: &[u8], data: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(4 + fmt_chunk.len() + 8 + data.len());
body.extend_from_slice(b"WAVE");
body.extend_from_slice(fmt_chunk);
body.extend_from_slice(b"data");
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
body.extend_from_slice(data);
let mut out = Vec::with_capacity(8 + body.len());
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if from >= hay.len() {
return None;
}
hay[from..]
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + from)
}
fn le32(b: &[u8], o: usize) -> Option<u32> {
let s = b.get(o..o + 4)?;
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash::name_hash;
#[test]
fn movie_voice_names_and_hashes() {
assert_eq!(
movie_voice_name("RT07A", VoiceLang::English),
"eng\\Movie\\VOICE_RT07A.slb"
);
// Verified present in dat/sound.pak (VOICE_S00A == TOC entry 0x2A4F97D3).
assert_eq!(name_hash("eng\\Movie\\VOICE_RT07A.slb"), 0xA44A_EA1C);
assert_eq!(name_hash("eng\\Movie\\VOICE_S00A.slb"), 0x2A4F_97D3);
}
#[test]
fn takes_only_first_subwave() {
// Bank of TWO sub-waves; only the FIRST must be extracted (bounded by its
// declared `data` size), not everything to end-of-file.
let fmt = synth_xma1_fmt(2, 2, 48000);
let mut slb = vec![0xAB; 16];
slb.extend_from_slice(b"RIFF");
slb.extend_from_slice(&999u32.to_le_bytes()); // riff size (ignored)
slb.extend_from_slice(b"WAVE");
slb.extend_from_slice(&fmt);
slb.extend_from_slice(b"data");
slb.extend_from_slice(&4u32.to_le_bytes()); // declared: 4 bytes
slb.extend_from_slice(&[1, 2, 3, 4]); // sub-wave 1 XMA
slb.extend_from_slice(&[9, 9, 9, 9]); // a second sub-wave's bytes — excluded
let riff = to_xma_riff(&slb).unwrap();
assert_eq!(&riff[0..4], b"RIFF");
let dpos = find(&riff, b"data", 0).unwrap();
assert_eq!(le32(&riff, dpos + 4), Some(4));
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];
slb.extend_from_slice(&[9, 8, 7, 6]);
let riff = to_xma_riff(&slb).unwrap();
let dpos = find(&riff, b"data", 0).unwrap();
assert_eq!(&riff[dpos + 8..], &[9, 8, 7, 6]);
// synthetic fmt advertises XMA1.
let fpos = find(&riff, b"fmt ", 0).unwrap();
assert_eq!(le32(&riff, fpos + 8).map(|v| v as u16), Some(0x0165));
}
}

View File

@@ -1,27 +1,25 @@
//! `T8aD` — the game's 2D UI/HUD texture format.
//!
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with
//! a small fixed-per-variant header:
//! 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).
//!
//! ```text
//! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32)
//! 0x18 4 height (BE u32)
//! 0x1c 4 type field → header size: 1→64 2→84 3→104 4→124 15→344
//! <header> w*h*4 bytes of A8R8G8B8 pixel data, row-major
//! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32)
//! 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
//! ```
//!
//! Static forensics over the disc showed ~85% of entries decode exactly with
//! this rule; the rest (unknown type field or a `w*h*4` that doesn't fit — most
//! likely DXT / palettized variants) return `None` rather than a wrong image.
//!
//! Keying the header size off the type field (not `len - w*h*4`) makes the
//! decoder **container-safe**: RATC/LSTA hand us a child slice that may have
//! trailing padding before the next child, so we must not infer the header from
//! the slice length.
//!
//! ⚠️ Colour correctness (channel order / endianness / sRGB) is unverified
//! against the running game — see the RE backlog.
//! 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
//! one tile it lands on the same pixel start. Wide textures were garbled because
//! the offset table + 16-byte per-tile headers weren't accounted for.
/// Magic at the start of every T8aD surface.
pub const T8AD_MAGIC: [u8; 4] = *b"T8aD";
@@ -39,54 +37,74 @@ pub fn is_t8ad(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC
}
/// Header size in bytes for a given type field (`@0x1c`), or `None` for an
/// unrecognized variant.
fn header_size(type_field: u32) -> Option<usize> {
match type_field {
1 => Some(64),
2 => Some(84),
3 => Some(104),
4 => Some(124),
15 => Some(344),
_ => None,
}
}
#[inline]
fn be32(b: &[u8], off: usize) -> u32 {
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
/// Side of the square storage tile, in texels, and the per-tile header size.
const TILE: usize = 256;
const TILE_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.
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !is_t8ad(bytes) || bytes.len() < 0x40 {
return None;
}
let width = be32(bytes, 0x14);
let height = be32(bytes, 0x18);
let width = be32(bytes, 0x14) as usize;
let height = be32(bytes, 0x18) as usize;
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
return None;
}
let header = header_size(be32(bytes, 0x1c))?;
let n = (width as usize) * (height as usize) * 4;
if bytes.len() < header + n {
return None; // DXT/palettized/short variant — defer, don't misdecode
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 {
return None;
}
const TABLE: usize = 0x2c;
if bytes.len() < TABLE + tiles * 4 {
return None;
}
// A8R8G8B8 → RGBA8.
let src = &bytes[header..header + n];
let mut rgba = vec![0u8; n];
for (px, out) in src.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
out[0] = r;
out[1] = g;
out[2] = b;
out[3] = a;
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;
}
}
}
}
Some(T8adImage {
width,
height,
width: width as u32,
height: height as u32,
rgba,
})
}
@@ -95,13 +113,17 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
mod tests {
use super::*;
/// Build a synthetic type-1 (header 64) T8aD with a known A8R8G8B8 pattern.
/// Build a synthetic single-tile T8aD (`w,h ≤ 256`) with a known A8R8G8B8
/// pattern: base header + 1-entry offset table + 16-byte tile header + pixels.
fn synth(w: u32, h: u32) -> Vec<u8> {
let mut b = vec![0u8; 64];
assert!(w <= 256 && h <= 256);
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()); // type 1 → header 64
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
for i in 0..(w * h) {
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
}
@@ -121,8 +143,31 @@ mod tests {
}
#[test]
fn rejects_unknown_variant_and_short() {
// type field 7 (unknown) → None
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.
let (w, h): (u32, u32) = (300, 1);
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(&2u32.to_be_bytes()); // 2 tiles
let off0 = 0x2c + 2 * 4; // after the 2-entry table
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]);
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
b.extend_from_slice(&[0u8; 16]);
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));
assert_eq!(&img.rgba[0..4], &[0xFF, 0, 0, 0xFF]); // tile 0 → red
assert_eq!(&img.rgba[256 * 4..256 * 4 + 4], &[0, 0, 0xFF, 0xFF]); // tile 1 → blue
}
#[test]
fn rejects_wrong_tilecount_and_short() {
// tile-count field that isn't ceil(w/256)*ceil(h/256) → None
let mut b = synth(2, 2);
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
assert!(parse(&b).is_none());

View File

@@ -7,7 +7,7 @@
//! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture`
use std::path::PathBuf;
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::mesh::{material_groups, node_transforms, submesh_albedos, Xbg7Model};
fn res3d_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_RES3D") {
@@ -21,6 +21,119 @@ fn res3d_dir() -> Option<PathBuf> {
default.is_dir().then_some(default)
}
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn hero_ship_submesh_material_graph() {
let Some(dir) = res3d_dir() else {
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
return;
};
// The XBG7 node/material graph tags each sub-mesh record with its own albedo
// (`rou_…_col` → TX2D name). DeltaSaber_T `f001`: the 7 detail parts use
// bdy_04/06/07 — NOT the body's bdy_01a — which is the per-part texturing the
// viewer needs so fins aren't painted with the hull map.
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
let mut map = std::collections::HashMap::new();
for (v, i, name) in submesh_albedos(&bytes, "f001") {
map.insert((v, i), name);
}
// (vtx, idx) → expected albedo (rou_ stripped = TX2D name).
assert_eq!(map.get(&(314, 645)).map(String::as_str), Some("f001_bdy_04_col"));
assert_eq!(map.get(&(48, 84)).map(String::as_str), Some("f001_bdy_04_col"));
assert_eq!(map.get(&(96, 180)).map(String::as_str), Some("f001_bdy_06_col"));
assert_eq!(map.get(&(60, 108)).map(String::as_str), Some("f001_bdy_07_col"));
// Names strip cleanly to real TX2D resources.
let tex = sylpheed_formats::texture::X360Texture::texture_names(&bytes);
for name in map.values() {
assert!(tex.iter().any(|t| t == name), "albedo {name} not a TX2D resource");
}
}
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn hero_ship_body_splits_by_material() {
let Some(dir) = res3d_dir() else {
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
return;
};
// The merged body (sub0 = 24561 indices / 8187 tris) is drawn as 9 groups
// sharing one index buffer, each with its own albedo (bdy_01a hull, bdy_01b,
// bdy_02/03, and the `daiza` hangar stand). `material_groups` recovers the
// cumulative-offset chain so the viewer can texture each slice.
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
let groups = material_groups(&bytes, "f001", 24561);
assert_eq!(groups.len(), 9, "body must split into 9 material groups");
// Offsets chain from 0 and cover the whole index buffer exactly.
let mut cursor = 0usize;
for g in &groups {
assert_eq!(g.idx_offset, cursor, "group offsets must be contiguous");
cursor += g.idx_count;
}
assert_eq!(cursor, 24561, "groups must cover all body indices");
// Expected per-group albedo (verified against the GPU draw log).
let albedos: Vec<&str> = groups.iter().map(|g| g.albedo.as_str()).collect();
assert_eq!(
albedos,
[
"f001_bdy_01a_col",
"f001_bdy_01a_col",
"f001_bdy_01a_col",
"f001_bdy_01b_col",
"f001_bdy_01a_col",
"f001_bdy_01b_col",
"f001_bdy_02_col",
"f001_bdy_03_col",
"f001_bdy_daiza_col",
]
);
// A single-material detail part (sub1 = 645 indices) → one group, bdy_04.
let fin = material_groups(&bytes, "f001", 645);
assert_eq!(fin.len(), 1);
assert_eq!(fin[0].albedo, "f001_bdy_04_col");
assert_eq!(fin[0].idx_offset, 0);
}
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn hero_ship_node_transforms_move_fins_to_tail() {
let Some(dir) = res3d_dir() else {
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
return;
};
// For the DeltaSaber, node_transforms returns the MEASURED runtime placements
// (Canary draw-log WVP; see mesh.rs::DELTASABER_MEASURED) — the fin assemblies
// are mounted OUT on the nacelles (world X ≈ 4.4..6.6), an offset the static
// graph omits. Body identity; each part a left/right mirrored pair. Vertex
// space: X right, Y up, Z fore/aft (engines at Z).
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
let places = node_transforms(&bytes, "f001");
assert!(!places.is_empty(), "expected node placements");
// Body (sub 0) identity, drawn once.
let body: Vec<_> = places.iter().filter(|p| p.sub_index == 0).collect();
assert_eq!(body.len(), 1, "body drawn once");
assert!(body[0].t.iter().all(|c| c.abs() < 0.1), "body must not translate");
assert!(!body[0].reflect, "body is not mirrored");
// Fin bdy_04 (sub 1): mirrored pair near the tail, mounted OUT on the nacelle
// (|X| ≈ 5.2, not the graph's inboard ≈1.1).
let fins: Vec<_> = places.iter().filter(|p| p.sub_index == 1).collect();
assert_eq!(fins.len(), 2, "V-tail is a mirrored pair");
assert!(fins.iter().all(|f| (f.t[2] + 9.72).abs() < 0.3), "fin fore/aft ≈ 9.7");
assert!(fins.iter().all(|f| f.t[0].abs() > 4.0), "fin mounted out on the nacelle");
assert!(fins[0].t[0] * fins[1].t[0] < 0.0, "the pair mirrors across X");
assert!(fins.iter().any(|f| f.reflect), "one of the pair is reflected");
// Winglet bdy_06 (sub 4): on the nacelle (|X| ≈ 6.6, Z ≈ 15.5).
let wings: Vec<_> = places.iter().filter(|p| p.sub_index == 4).collect();
assert_eq!(wings.len(), 2, "L/R winglet pair");
assert!(wings.iter().all(|p| p.t[0].abs() > 6.0 && (p.t[2] + 15.5).abs() < 0.3),
"winglets out on the nacelle");
assert!(wings[0].t[0] * wings[1].t[0] < 0.0, "winglets mirror across X");
// Small fin bdy_10 (sub 6): inboard nacelle stub (|X| ≈ 4.45, Z ≈ 17).
let sfins: Vec<_> = places.iter().filter(|p| p.sub_index == 6).collect();
assert_eq!(sfins.len(), 2, "L/R small-fin pair");
assert!(sfins.iter().all(|p| (p.t[0].abs() - 4.45).abs() < 0.3), "small fins on the stub");
assert!(sfins[0].t[0] * sfins[1].t[0] < 0.0, "small fins mirror across X");
}
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn weapon_model_decodes_to_expected_geometry() {
@@ -122,6 +235,85 @@ fn complex_body_mesh_is_declined_not_garbage() {
}
}
/// The hero ship's **grouped-pool** layout decodes fully: `DeltaSaber_T.xpr`'s
/// `f001` resource is one vertex+index pool shared by 8 sub-meshes (body + 7
/// detail parts). Reversed statically and cross-checked against a Canary GPU
/// draw-log capture — every sub-mesh's stored normals agree with its triangle
/// winding (0 degenerate, full vertex coverage). This is the layout the old
/// per-block adjacency anchor rendered as a spiky phantom.
#[test]
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
fn hero_ship_grouped_pool_decodes() {
let Some(dir) = res3d_dir() else {
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
return;
};
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
let models = Xbg7Model::stage_models(&bytes);
// The neutral pose `f001` (the mnv*/turn180 resources are animation poses).
let f001 = models.iter().find(|m| m.name == "f001").expect("f001 decoded");
assert_eq!(f001.meshes.len(), 8, "body + 7 detail sub-meshes");
let (v, t) = f001.totals();
assert_eq!(v, 11607, "vertex total across sub-meshes");
assert_eq!(t, 8650, "triangle total (body 8187 + 7 parts)");
// Sub-mesh 0 is the body: exactly the draw-log-verified geometry.
let body = &f001.meshes[0];
assert_eq!(body.positions.len(), 10891);
assert_eq!(body.indices.len(), 24561);
for (i, m) in f001.meshes.iter().enumerate() {
// Every index in range.
assert!(
m.indices.iter().all(|&ix| (ix as usize) < m.positions.len()),
"sub{i}: index out of range"
);
// Correct alignment ⇒ unit normals, and the winding agrees with them
// (the decisive correctness signal, ~1.0, not the ~0.5 of a mis-carve).
let n = m.normals.len();
assert_eq!(n, m.positions.len(), "sub{i}: a normal per vertex");
let mean_nlen: f32 = m
.normals
.iter()
.map(|nv| (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2]).sqrt())
.sum::<f32>()
/ n as f32;
assert!((mean_nlen - 1.0).abs() < 0.05, "sub{i}: mean |normal| {mean_nlen} ≠ 1");
let mut agree = 0usize;
let mut counted = 0usize;
for tri in m.indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
let (pa, pb, pc) = (m.positions[a], m.positions[b], m.positions[c]);
let u = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
let w = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
let f = [
u[1] * w[2] - u[2] * w[1],
u[2] * w[0] - u[0] * w[2],
u[0] * w[1] - u[1] * w[0],
];
if f[0] * f[0] + f[1] * f[1] + f[2] * f[2] < 1e-12 {
continue;
}
let sn = [
m.normals[a][0] + m.normals[b][0] + m.normals[c][0],
m.normals[a][1] + m.normals[b][1] + m.normals[c][1],
m.normals[a][2] + m.normals[b][2] + m.normals[c][2],
];
if f[0] * sn[0] + f[1] * sn[1] + f[2] * sn[2] > 0.0 {
agree += 1;
}
counted += 1;
}
let na = agree as f32 / counted.max(1) as f32;
assert!(
na.max(1.0 - na) > 0.95,
"sub{i}: winding-vs-normal agreement {na} — mis-carved (should be ~1.0 or ~0.0)"
);
}
}
/// Stage containers decode multiple enemy/prop sub-models via content anchoring.
/// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry.
#[test]

View File

@@ -0,0 +1,102 @@
//! Real-disc test for the movie manifest → voice binding. Skipped without
//! `SYLPHEED_DISC`.
use std::path::PathBuf;
use sylpheed_formats::movie_manifest;
use sylpheed_formats::slb::VoiceLang;
use sylpheed_formats::PakArchive;
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
/// Read the manifest + `eng\sounds.tbl` out of `tables.pak`.
fn load_manifest_and_sounds(root: &PathBuf) -> (Vec<u8>, Vec<u8>) {
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
let manifest = pak
.entries()
.iter()
.find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("movie manifest present in tables.pak");
let sounds = pak
.read_by_name("eng\\sounds.tbl")
.expect("sounds.tbl present")
.unwrap();
(manifest, sounds)
}
#[test]
fn binds_and_resolves_movie_voice() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let (manifest, sounds) = load_manifest_and_sounds(&root);
let entries = movie_manifest::parse(&manifest);
assert!(entries.len() > 90, "expected ~101 movies, got {}", entries.len());
// Standard story movie → VOICE_<movie> in <lang>\Movie\.
assert_eq!(
movie_manifest::voice_token(&manifest, "S13A").as_deref(),
Some("VOICE_S13A")
);
assert_eq!(
movie_manifest::resolve_voice_entry(&manifest, &sounds, "S13A", VoiceLang::English)
.as_deref(),
Some("eng\\Movie\\VOICE_S13A.slb")
);
// A resupply movie bound to an in-mission radio clip → VOICE_D_* in \etc\.
// (This is what a `VOICE_<movie>` guess would miss entirely.)
assert_eq!(
movie_manifest::voice_token(&manifest, "hokyu_LS_s02A").as_deref(),
Some("VOICE_D_450")
);
assert_eq!(
movie_manifest::resolve_voice_entry(
&manifest,
&sounds,
"hokyu_LS_s02A",
VoiceLang::English
)
.as_deref(),
Some("eng\\etc\\VOICE_D_450.slb")
);
// A resupply movie the game leaves unbound in the manifest → no DIRECT
// binding. (Extending to unbound movies by shared demo line was verified
// WRONG against the running game, so we do NOT resolve these.)
let e = entries
.iter()
.find(|e| e.movie == "hokyu_DS_s13A")
.expect("hokyu_DS_s13A present in manifest");
assert_eq!(e.voice_token, None, "hokyu_DS_s13A has no direct voice binding");
assert_eq!(
movie_manifest::resolve_voice_entry(&manifest, &sounds, "hokyu_DS_s13A", VoiceLang::English),
None
);
// Every resolved voice entry must actually exist in sound.pak.
let snd = std::fs::read(root.join("dat/sound.pak")).unwrap();
let keys: std::collections::HashSet<u32> = PakArchive::parse_toc(&snd)
.unwrap()
.iter()
.map(|e| e.name_hash)
.collect();
let mut resolved = 0;
let mut missing = Vec::new();
for e in &entries {
if let Some(full) =
movie_manifest::resolve_voice_entry(&manifest, &sounds, &e.movie, VoiceLang::English)
{
resolved += 1;
if !keys.contains(&sylpheed_formats::hash::name_hash(&full)) {
missing.push(full);
}
}
}
assert!(resolved > 80, "expected 80+ voiced movies, got {resolved}");
assert!(missing.is_empty(), "resolved but absent in sound.pak: {missing:?}");
}

View File

@@ -0,0 +1,63 @@
//! Real-disc test for the movie subtitle chain. Skipped when the extracted disc
//! is absent (set `SYLPHEED_DISC` to the extract root to enable).
use std::path::PathBuf;
use sylpheed_formats::movie_subtitle::{self, SubLang};
use sylpheed_formats::PakArchive;
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
#[test]
fn resolves_english_radio_subtitles() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let lang = SubLang::English;
let lang_pak = PakArchive::open(root.join(format!("dat/movie/{}.pak", lang.pak_code()))).unwrap();
let text_pak =
PakArchive::open(root.join(format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()))).unwrap();
// A radio movie: real timed English dialogue.
let cues = movie_subtitle::load("RT01C_1", &lang_pak, &text_pak);
assert!(!cues.is_empty(), "RT01C_1 should have timed cues");
assert!(cues[0].start >= 0.0 && cues[0].start < 5.0);
assert!(
cues.iter().any(|c| c.text.contains("Rhino Leader")),
"expected recognizable dialogue, got: {:?}",
cues.iter().map(|c| &c.text).collect::<Vec<_>>()
);
// No MSG_DEMO keys should leak into resolved caption text.
assert!(
cues.iter().all(|c| !c.text.contains("MSG_DEMO")),
"unresolved key leaked: {:?}",
cues.iter().map(|c| &c.text).collect::<Vec<_>>()
);
// A resupply movie: the canonical resupply line.
let hokyu = movie_subtitle::load("hokyu_DS_s02A", &lang_pak, &text_pak);
assert!(hokyu.iter().any(|c| c.text.contains("Resupply complete")));
// S00A carries inline narration text (not MSG_DEMO refs) with timecodes.
let story = movie_subtitle::load("S00A", &lang_pak, &text_pak);
assert!(!story.is_empty() && story.iter().any(|c| c.text.contains("27th century")));
// S13A stores a two-line caption as two consecutive text tokens sharing one
// timing; both lines must survive (the "Look at it father" regression — the
// first line used to be dropped).
let s13 = movie_subtitle::load("S13A", &lang_pak, &text_pak);
let multiline = s13
.iter()
.find(|c| c.text.contains("Look at it father"))
.expect("S13A should contain the 'Look at it father' caption");
assert_eq!(
multiline.text, "Look at it father\n& beautiful isn't it",
"both lines of the caption must be present"
);
assert!((multiline.start - 74.8).abs() < 0.1, "start {}", multiline.start);
}

View File

@@ -0,0 +1,104 @@
//! Real-disc test: read a movie voice `.slb` out of the segmented `sound.pak`
//! and rebuild a decodable XMA1 RIFF. Skipped without `SYLPHEED_DISC`.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::slb::{self, VoiceLang};
use sylpheed_formats::PakArchive;
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
/// Read `[off, off+size)` from `dat/sound.p00..` (segments concatenated).
fn read_range(root: &PathBuf, mut off: u64, size: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(size);
let mut need = size;
for i in 0..100u32 {
if need == 0 {
break;
}
let seg = root.join(format!("dat/sound.p{i:02}"));
let Ok(meta) = std::fs::metadata(&seg) else { break };
let len = meta.len();
if off >= len {
off -= len;
continue;
}
let mut f = File::open(&seg).unwrap();
f.seek(SeekFrom::Start(off)).unwrap();
let take = need.min((len - off) as usize);
let start = out.len();
out.resize(start + take, 0);
f.read_exact(&mut out[start..]).unwrap();
need -= take;
off = 0;
}
out
}
#[test]
fn reads_and_rebuilds_movie_voice_riff() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let toc = std::fs::read(root.join("dat/sound.pak")).unwrap();
let entries = PakArchive::parse_toc(&toc).unwrap();
assert_eq!(entries.len(), 9519);
for movie in ["S00A", "RT07A", "RT02D_1"] {
// RT02D_1 is a headerless variant; S00A/RT07A embed a RIFF.
let key = name_hash(&slb::movie_voice_name(movie, VoiceLang::English));
let idx = entries
.binary_search_by_key(&key, |e| e.name_hash)
.unwrap_or_else(|_| panic!("{movie} voice missing"));
let e = &entries[idx];
let bank = read_range(&root, e.offset as u64, e.comp_size as usize);
let riff = slb::to_xma_riff(&bank).expect("rebuild RIFF");
assert_eq!(&riff[0..4], b"RIFF", "{movie}");
assert_eq!(&riff[8..12], b"WAVE", "{movie}");
// fmt chunk advertises XMA1 (tag 0x0165).
let fpos = riff.windows(4).position(|w| w == b"fmt ").unwrap();
let tag = u16::from_le_bytes([riff[fpos + 8], riff[fpos + 9]]);
assert_eq!(tag, 0x0165, "{movie} should be XMA1");
// data chunk carries the XMA payload.
let dpos = riff.windows(4).position(|w| w == b"data").unwrap();
let dsz = u32::from_le_bytes([
riff[dpos + 4],
riff[dpos + 5],
riff[dpos + 6],
riff[dpos + 7],
]) as usize;
assert!(dsz > 10_000, "{movie} data too small: {dsz}");
assert_eq!(riff.len(), dpos + 8 + dsz, "{movie} data length consistent");
}
}
#[test]
fn enumerates_voice_clips_from_sounds_tbl() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
let tbl = pak.read_by_name("eng\\sounds.tbl").unwrap().unwrap();
let clips = slb::list_voice_clips(&tbl, VoiceLang::English);
assert!(clips.len() > 1000, "expected many voice clips, got {}", clips.len());
// Every clip is an eng voice path present in sound.pak.
let sound = std::fs::read(root.join("dat/sound.pak")).unwrap();
let keys: std::collections::HashSet<u32> =
PakArchive::parse_toc(&sound).unwrap().iter().map(|e| e.name_hash).collect();
let present = clips.iter().filter(|c| keys.contains(&name_hash(&c.name))).count();
assert!(
present as f32 / clips.len() as f32 > 0.95,
"{present}/{} clips resolve in sound.pak",
clips.len()
);
assert!(clips.iter().any(|c| c.speaker == "ADAN"));
}

View File

@@ -8,6 +8,10 @@
use bevy::prelude::*;
use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{
Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension,
};
pub struct OrbitCameraPlugin;
@@ -54,10 +58,17 @@ impl Default for OrbitCamera {
}
}
fn spawn_camera(mut commands: Commands) {
fn spawn_camera(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
let orbit = OrbitCamera::default();
let transform = orbit_transform(&orbit);
// The game's ship shader reflects a shared HDR environment cubemap off the
// hull (three cube samples — see the shader RE). Reproduce that "look" with a
// procedural sky cube feeding Bevy's image-based lighting, so metallic
// surfaces reflect an environment instead of reading flat. Procedural (not a
// bundled KTX2) so it also works on WASM with no extra assets.
let env = make_env_cubemap(&mut images);
commands.spawn((
Camera3d::default(),
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
@@ -69,9 +80,78 @@ fn spawn_camera(mut commands: Commands) {
}),
transform,
orbit,
EnvironmentMapLight {
diffuse_map: env.clone(),
specular_map: env,
intensity: 900.0,
..default()
},
));
}
/// Build a small procedural sky cubemap (6 faces) for image-based lighting.
///
/// A vertical gradient (bright zenith → warm horizon → dark ground) plus a warm
/// "sun" highlight roughly where the game's key light sits — enough for a
/// metallic hull to read as reflective rather than flat. Stored as an sRGB cube
/// (filterable, no half-float encoding needed); a single mip means reflections
/// stay sharp (fine for a shiny ship).
fn make_env_cubemap(images: &mut Assets<Image>) -> Handle<Image> {
let size: i32 = 64;
// Approx key-light direction from the RE (PS c32 ≈ (0,-0.76,-0.65), i.e. light
// arriving from top-front); place the bright spot there.
let sun = Vec3::new(0.0, 0.85, 0.5).normalize();
let zenith = Vec3::new(0.70, 0.80, 0.95);
let horizon = Vec3::new(0.42, 0.45, 0.50);
let ground = Vec3::new(0.09, 0.09, 0.11);
let srgb = |c: f32| (c.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
let mut data: Vec<u8> = Vec::with_capacity((size * size * 6 * 4) as usize);
for face in 0..6 {
for y in 0..size {
for x in 0..size {
let u = (x as f32 + 0.5) / size as f32 * 2.0 - 1.0;
let v = (y as f32 + 0.5) / size as f32 * 2.0 - 1.0;
// Standard cube-face → direction mapping.
let d = match face {
0 => Vec3::new(1.0, -v, -u),
1 => Vec3::new(-1.0, -v, u),
2 => Vec3::new(u, 1.0, v),
3 => Vec3::new(u, -1.0, -v),
4 => Vec3::new(u, -v, 1.0),
_ => Vec3::new(-u, -v, -1.0),
}
.normalize();
let mut col = if d.y >= 0.0 {
horizon.lerp(zenith, (d.y).powf(0.6))
} else {
horizon.lerp(ground, (-d.y).powf(0.5))
};
let s = d.dot(sun).max(0.0).powf(60.0);
col += Vec3::new(1.0, 0.85, 0.6) * s * 1.5;
data.extend_from_slice(&[srgb(col.x), srgb(col.y), srgb(col.z), 255]);
}
}
}
let mut image = Image::new(
Extent3d {
width: size as u32,
height: size as u32,
depth_or_array_layers: 6,
},
TextureDimension::D2,
data,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::RENDER_WORLD,
);
image.texture_view_descriptor = Some(TextureViewDescriptor {
dimension: Some(TextureViewDimension::Cube),
..default()
});
images.add(image)
}
fn orbit_camera(
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
mouse_buttons: Res<ButtonInput<MouseButton>>,

File diff suppressed because it is too large Load Diff

View File

@@ -72,9 +72,28 @@ pub fn run() {
}
fn setup_scene(mut commands: Commands) {
commands.spawn(DirectionalLight {
illuminance: 10_000.0,
shadows_enabled: true,
..default()
// Bright ambient so surfaces facing away from every light aren't pure black.
commands.insert_resource(AmbientLight {
color: Color::srgb(0.9, 0.93, 1.0),
brightness: 900.0,
});
// A multi-directional rig (key + fills + rim + underside) so an orbiting
// camera always has some light on the visible side — the single front light
// left the backside unreadable.
let lights = [
(Vec3::new(1.0, 2.0, 1.5), 10_000.0, true), // key: front-top-right
(Vec3::new(-2.0, 1.0, 0.5), 4_500.0, false), // fill: left
(Vec3::new(0.5, 0.8, -2.0), 6_000.0, false), // rim: behind
(Vec3::new(0.0, -1.5, 0.5), 2_500.0, false), // underside fill
];
for (from, lux, shadows) in lights {
commands.spawn((
DirectionalLight {
illuminance: lux,
shadows_enabled: shadows,
..default()
},
Transform::from_translation(from).looking_at(Vec3::ZERO, Vec3::Y),
));
}
}

View File

@@ -10,10 +10,14 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, PakContent, PakView, RequestOpenDir,
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, 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;
pub struct ViewerUiPlugin;
@@ -22,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));
}
}
@@ -123,6 +129,20 @@ fn render_dir(
}
}
/// Bundled event writers for the UI, to keep `draw_viewer_ui` under Bevy's
/// 16-parameter system limit.
#[derive(bevy::ecs::system::SystemParam)]
struct UiEvents<'w> {
open_iso: EventWriter<'w, RequestOpenIso>,
open_dir: EventWriter<'w, RequestOpenDir>,
file_selected: EventWriter<'w, FileSelected>,
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(
mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>,
@@ -135,9 +155,11 @@ fn draw_viewer_ui(
model: Res<ModelPreview>,
mut video: ResMut<VideoPreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>,
mut file_selected_events: EventWriter<FileSelected>,
mut subtitles: ResMut<MovieSubtitles>,
mut movie_voice: ResMut<MovieVoice>,
mut audio: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>,
mut events: UiEvents,
) {
let ctx = contexts.ctx_mut();
@@ -148,11 +170,11 @@ fn draw_viewer_ui(
#[cfg(not(target_arch = "wasm32"))]
{
if ui.button("Open ISO disc image…").clicked() {
open_iso_events.send_default();
events.open_iso.send_default();
ui.close_menu();
}
if ui.button("Open extracted folder…").clicked() {
open_dir_events.send_default();
events.open_dir.send_default();
ui.close_menu();
}
ui.separator();
@@ -167,6 +189,29 @@ fn draw_viewer_ui(
);
});
ui.menu_button("View", |ui| {
if ui.button("🎙 Voice Lines…").clicked() {
voice_lib.open = true;
if !voice_lib.loaded && !voice_lib.loading {
voice_lib.loading = true;
events.voice_lib.send_default();
}
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| {
if ui.button("Controls…").clicked() {
// TODO: controls popup
@@ -178,6 +223,101 @@ fn draw_viewer_ui(
});
});
// ── Standalone voice-line browser (floating window) ───────────────────
if voice_lib.open {
let mut open = true;
egui::Window::new("🎙 Voice Lines")
.default_width(360.0)
.default_height(480.0)
.open(&mut open)
.show(ctx, |ui| {
if voice_lib.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading sounds.tbl…");
});
ctx.request_repaint();
} else if !voice_lib.loaded {
ui.label("Open a game source first.");
} else {
ui.horizontal(|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!("{shown} / {} clips", voice_lib.clips.len()))
.weak()
.small(),
);
ui.separator();
let filtering = !f.is_empty();
egui::ScrollArea::vertical().show(ui, |ui| {
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);
});
}
});
}
});
}
});
}
});
voice_lib.open = open;
}
// ── Left panel: file browser ──────────────────────────────────────────
egui::SidePanel::left("file_browser")
.resizable(true)
@@ -226,7 +366,7 @@ fn draw_viewer_ui(
force_open,
selected,
loading,
&mut file_selected_events,
&mut events.file_selected,
&files,
);
}
@@ -252,7 +392,13 @@ fn draw_viewer_ui(
});
// ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() && model.active && !browser.loading {
if audio.active {
// Standalone audio player takes over the central panel.
egui::CentralPanel::default().show(ctx, |ui| {
ctx.request_repaint();
draw_audio_player(ui, &mut audio);
});
} else if browser.selected.is_some() && model.active && !browser.loading {
// 3D model: draw the central panel TRANSPARENT so the real Bevy scene
// (mesh + orbit camera) shows through, with just an info overlay on top.
egui::CentralPanel::default()
@@ -280,6 +426,9 @@ fn draw_viewer_ui(
if browser.loading {
// A file was just clicked — show immediate feedback while the
// background read + decode runs, instead of the previous item.
// Keep repainting so the spinner animates even if the app is in a
// reactive (idle) winit mode while the worker thread churns.
ctx.request_repaint();
let name = browser
.selected
.and_then(|i| browser.files.get(i))
@@ -292,7 +441,43 @@ fn draw_viewer_ui(
});
});
} else if video.active {
draw_video_player(ui, &mut video);
let mut lang_changed = false;
let mut play_voice_solo = false;
draw_video_player(
ui,
&mut video,
&mut subtitles,
&mut movie_voice,
&mut lang_changed,
&mut play_voice_solo,
);
if play_voice_solo {
if let Some(movie) = movie_voice.movie.clone() {
video.playing = false; // pause the video; audio takes over
audio.generation = audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true; // show the panel immediately (spinner)
audio.name = format!("VOICE_{movie}");
events.audio.send(RequestAudio {
clip: String::new(),
display: format!("VOICE_{movie}"),
movie: Some((movie.clone(), movie_voice.lang)),
generation: audio.generation,
});
}
}
if lang_changed {
if let Some(movie) = subtitles.movie.clone() {
subtitles.generation = subtitles.generation.wrapping_add(1);
subtitles.cues.clear();
subtitles.loading = true;
events.subtitles.send(RequestSubtitles {
movie,
lang: subtitles.lang,
generation: subtitles.generation,
});
}
}
} else if !skybox.faces.is_empty() {
draw_skybox_grid(ui, &skybox);
} else if pak_view.loaded {
@@ -830,7 +1015,14 @@ fn fmt_time(secs: f32) -> String {
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
/// `advance_video_playback`.
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
fn draw_video_player(
ui: &mut egui::Ui,
video: &mut VideoPreview,
subs: &mut MovieSubtitles,
voice: &mut MovieVoice,
lang_changed: &mut bool,
play_voice_solo: &mut bool,
) {
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
let (mut toggle_play, mut skip) = (false, 0.0_f32);
ui.input_mut(|i| {
@@ -862,6 +1054,47 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "no audio");
}
// Subtitle + voice controls (right-aligned): language, CC, and Voice.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let before = subs.lang;
egui::ComboBox::from_id_source("subtitle_lang")
.selected_text(subs.lang.label())
.show_ui(ui, |ui| {
for lang in SubLang::ALL {
ui.selectable_value(&mut subs.lang, lang, lang.label());
}
});
if subs.lang != before {
*lang_changed = true;
}
ui.toggle_value(&mut subs.enabled, "CC")
.on_hover_text("Show subtitles");
if subs.loading {
ui.spinner();
} else if subs.enabled && subs.cues.is_empty() {
ui.weak("(no subtitles)");
}
ui.separator();
// Voice-over track: the movie's own stream carries only music+SFX, so
// this layers in the localized cutscene voice.
ui.toggle_value(&mut voice.enabled, "🗣 Voice")
.on_hover_text("Play the cutscene voice track over the movie");
if voice.loading {
ui.spinner();
} else if !voice.available {
ui.weak("(no voice)");
}
if voice.available
&& ui
.button("🎧")
.on_hover_text("Listen to the voice track on its own")
.clicked()
{
*play_voice_solo = true;
}
});
});
ui.separator();
@@ -873,6 +1106,7 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
let scale = (avail.x / w).min(frame_h / h);
let (disp_w, disp_h) = (w * scale, h * scale);
let mut frame_rect = None;
if let Some(egui_id) = video.egui_id {
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
ui.vertical_centered(|ui| {
@@ -884,10 +1118,26 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
if resp.clicked() {
video.playing = !video.playing;
}
frame_rect = Some(resp.rect);
});
});
}
// Caption overlay: every cue active at the current position, stacked over
// the lower third of the frame (overlapping spans show together, newest —
// last in start order — lowest).
if let Some(rect) = frame_rect {
let active = subs.active_cues(video.position);
if !active.is_empty() {
let joined = active
.iter()
.map(|c| c.text.as_str())
.collect::<Vec<_>>()
.join("\n");
paint_caption(ui, rect, &joined);
}
}
// ── Transport bar ──
ui.horizontal(|ui| {
if ui.button(if video.playing { "" } else { "" }).clicked() {
@@ -931,6 +1181,86 @@ fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
});
}
/// Draw a subtitle caption centered along the bottom of `rect`, with a
/// semi-transparent backing box so it stays legible over any frame.
fn paint_caption(ui: &egui::Ui, rect: egui::Rect, text: &str) {
let painter = ui.painter_at(rect);
// Font scales with the frame; clamped so it's readable but not huge.
let size = (rect.height() * 0.045).clamp(13.0, 30.0);
let font = egui::FontId::proportional(size);
let wrap = rect.width() * 0.9;
let galley = painter.layout(
text.to_string(),
font,
egui::Color32::WHITE,
wrap,
);
let margin = egui::vec2(10.0, 6.0);
let box_size = galley.size() + margin * 2.0;
let top_left = egui::pos2(
rect.center().x - box_size.x / 2.0,
rect.bottom() - box_size.y - rect.height() * 0.04,
);
let bg = egui::Rect::from_min_size(top_left, box_size);
painter.rect_filled(bg, 4.0, egui::Color32::from_black_alpha(160));
painter.galley(top_left + margin, galley, egui::Color32::WHITE);
}
/// Standalone audio player: a transport bar for a decoded voice/sound track with
/// no video. Playback state lives in `AudioPreview`; `advance_audio_playback`
/// reacts to it.
fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
ui.horizontal(|ui| {
ui.heading(&audio.name);
ui.separator();
if audio.loading {
ui.spinner();
ui.label("decoding…");
} else {
ui.label("voice track");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() {
audio.active = false;
}
});
});
ui.separator();
ui.add_space(ui.available_height() * 0.4);
// Big centered play/pause + a speaker glyph, since there's nothing to show.
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("🔊").size(64.0));
});
ui.add_space(12.0);
// Transport bar.
ui.horizontal(|ui| {
if ui
.button(if audio.playing { "" } else { "" })
.clicked()
{
audio.playing = !audio.playing;
}
ui.label(fmt_time(audio.position));
let tl_width = (ui.available_width() - 170.0).max(80.0);
ui.spacing_mut().slider_width = tl_width;
let mut pos = audio.position;
let resp = ui.add(
egui::Slider::new(&mut pos, 0.0..=audio.duration.max(0.1)).show_value(false),
);
if resp.changed() {
audio.position = pos;
audio.seek_request = Some(pos);
}
ui.label(fmt_time(audio.duration));
ui.separator();
ui.label("🔊");
ui.spacing_mut().slider_width = 90.0;
ui.add(egui::Slider::new(&mut audio.volume, 0.0..=1.0).show_value(false));
});
}
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
@@ -967,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;
}

View File

@@ -0,0 +1,95 @@
# Handoff — movie subtitles, voice, and the movie manifest (2026-07-19)
Branch `feature/ipfb-idxd-parser`. This commit flushes several sessions of local
WIP; the **new, finished** work is the movie subtitle + voice + audio pipeline
and the movie manifest. One item is deliberately **left open** (see §4).
## 1. What's DONE and verified (static RE + tests)
### Movie subtitles (`crates/sylpheed-formats/src/movie_subtitle.rs`)
- Full movie→track→text chain (see `docs/re/structures/movie-subtitles.md`).
- **Multi-line caption fix**: a caption stored as several consecutive text tokens
sharing one timing (S13A: `"Look at it father"` + `"& beautiful isn't it"` @
`01:14.80`) — `parse_track` now joins them with `\n`. Previously all but the
last line were dropped. Disc test asserts both lines survive.
- **Overlap rendering**: `MovieSubtitles::active_cues(t)` returns every cue active
at `t`; the viewer stacks them (was: only the first cue shown).
- Umlauts / Latin-1 accents preserved (`utf16le_tokens`); Japanese label romanized
(CJK font still a known gap — see §5).
### Voice decode (`crates/sylpheed-formats/src/slb.rs`)
- `sound.pak` entries are XACT `.slb` banks wrapping **XMA1** (fmt tag `0x0165`,
48 kHz, mono content). `to_xma_riff` rebuilds a decodable RIFF; FFmpeg `xma1`
decodes it. Downmix mono via `-af pan=mono|c0=c0`.
- **Multi-subwave fix**: take the FIRST sub-wave bounded by its declared `data`
size — NOT `data..EOF` (which appended later takes = the S10S16 garble).
Verified: S13A→83.78s == video.
- `list_voice_clips` enumerates the voice library from `sounds.tbl`.
### Movie manifest (`crates/sylpheed-formats/src/movie_manifest.rs`) — the index
- `tables.pak` entry `ADVERTISE_MOVIE` (IDXD, hash `0x5B983A08`) is the
authoritative **movie → subtitle → voice** map. Located by shape (`is_manifest`),
parsed by grouping the string pool on `.wmv`.
- **Authoritative voice binding** replaces the old `VOICE_<movie>` guess:
101 movies, 83 with a `VOICE_` token, 18 without. The token is NOT always
`VOICE_<movie>` — 5 `hokyu_*` movies bind to `VOICE_D_450..454` in `\etc\`,
which a guess would miss. Token's subdir varies (Movie/etc/Voice) → resolve via
`sounds.tbl`. Disc test: all 83 resolved entries exist in `sound.pak`.
### Viewer wiring (`iso_loader.rs`, `ui.rs`)
- `resolve_movie_voice_clip` reads the manifest + `sounds.tbl` (DIRECT bindings
only) → `handle_voice_request` (movie player 🗣 toggle) and the 🎧 solo button
(`RequestAudio.movie`) use it. Unbound movies correctly post no audio.
- Standalone **View → 🎙 Voice Lines** browser (filter + ▶ play any `sound.pak`
clip) via `VoiceLibrary` + `AudioPreview`.
Tests: 46 formats-lib + 11 viewer-lib + disc (`movie_manifest_disc`,
`movie_subtitle_disc`, `slb_disc`) all green. Full `cargo test --workspace` green.
## 2. Also bundled in this commit (prior local WIP, not this session's focus)
- Viewer **async stage/model loading** (off-thread `prepare_xpr`, cancellable) —
`iso_loader.rs`/`ui.rs`/`camera.rs`. See memory `reborn-viewer-async-loading`.
- **Grouped-pool XBG7 / hero-ship** decode refinements in `mesh.rs`, `cli/main.rs`,
`mesh_disc.rs`. See memory `reborn-ship-drawlog` / `reborn-saber-texture-investigation`.
- `tools/analyze_drawlog_wvp.py`, `tools/extract_movie_subtitles.py`.
These build and test green but were not re-verified for behaviour this session.
## 3. USER TO VERIFY IN GUI (couldn't be tested from CLI)
1. S13A cutscene shows BOTH lines of the "Look at it father" caption.
2. Story-movie voice (S13A etc.) lines up with the video after the subwave fix.
3. The 5 directly-bound `hokyu_*` movies play voice; other hokyu are silent (see §4).
4. View → 🎙 Voice Lines browser lists and plays clips.
## 4. OPEN PROBLEM — unbound-hokyu resupply voice (do NOT re-guess)
The resupply cutscenes clearly SHARE voice recordings (video-only varies per
mission), but the correct join key for the **13 unbound `hokyu_*`** movies is
**unknown**:
- Manifest DIRECTLY binds only 5: `LS_s02A→450, LS_s09A→451, DS_s02A→452,
LS_s02H→453, DS_s07H→454` (grouping verified against the raw layout).
- Keying unbound movies by subtitle **demo id** (600→450…604→454, so
`hokyu_DS_s13A` demo602→`VOICE_D_452`) was implemented, tested against the
running game by the user, and is **WRONG** (wrong line). It has been **reverted**
— unbound hokyu now stay unvoiced rather than play wrong audio.
- Red flag: only `VOICE_D_450..454` exist; decoded durations `450=2.8s 451=1.6s
452=2.2s` but `453=0.14s 454=0.43s` — far too short for the spoken line, so
these `.slb` are likely **multi-subwave / not cleanly sliced** (same class as
the deferred B/C banks below).
**Next-box options:** (a) get the real join key from mission-event data (mission
scripts/IDXD tables that trigger resupply), or (b) build a proper multi-subwave
`.slb` decoder and verify audio by ear. Needs a concrete calibration clue from the
user (e.g. "s13A should sound like <movie X>" or the actual spoken words).
## 5. Other deferred items (unchanged)
- ~49 "layout-B/C" movie/voice banks (most RT + S07B/S11A/S12B/S13B) + some
individual clips need an XMA1 packet/seek-table reassembler.
- CJK/Japanese text rendering in the viewer (needs a CJK TTF via egui FontDefinitions).
- Texture colours (channel order / sRGB) still on the dynamic-RE backlog.
## 6. Resume checklist (next box)
1. `git pull` on `feature/ipfb-idxd-parser`; set `SYLPHEED_DISC=<extract root>`
(had `.../sylph_extract`).
2. Build guardrail: `CARGO_BUILD_JOBS=4` always (15 GB box; full `-j` OOM'd before).
3. `cargo test --workspace` (with `SYLPHEED_DISC`) should be green.
4. Pick up §4 (unbound-hokyu voice) — the only open thread from this session.

View File

@@ -0,0 +1,71 @@
# Handoff — capital-ship placement: static assembly EXACT (2026-07-26)
Branch `feature/ipfb-idxd-parser`, commits `c6ca5ce..ea32e77`. Canary fork
branch `capture-ship-placement`, tip `877163792`.
## Outcome
**Capital ships assemble exactly from the ISO alone — no captures needed.**
Runtime captures served as the oracle that cracked the static encoding and now
remain as verification tooling only.
## The two format discoveries (docs/re/ship-placement-runtime-capture.md)
1. **Node-TRS off-by-one** (`mesh.rs::read_trs9`): an XBG7 scene-graph node's
joint table 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)`, channel 0 (TX) = `f64@(ptr[0]0x48)`. The old read never
saw TX (why hulls stacked on the centreline) and misassigned TY/RY.
2. **Euler order** (`mesh.rs::node_rotation`): `Ry(ch3)·Rx(ch4)·Rz(ch5)`,
angles direct — pinned by decomposing the captured engine-nacelle rotation
`Rx(15°)·Rz(30°)` element-for-element.
## assemble_ship (ship.rs) — fully static, capture-validated
- Every composite-node instance (alias duplicates collapsed), including
cross-id turret mounts (`rou_e303_wep_01_root` ×2 on e106; e108 places 7).
- Engine cluster rig `e_rou_<id>_eng` mounts at `GN_Engine_01`
(two mirrored nacelles + centre engine).
- `brg`/`sld` at their exact GN frames (frames were always exact).
- Shared-geometry ±TX twins: the instance whose offset opposes the geometry's
dominant side draws X-reflected (viewer reverses winding on det<0).
- `exhaust_frames()`: the `GN_Jet`/`GN_SJet` frames — the game renders its
engine-exhaust FX there; the viewer draws marker cones at them (the engine
GEOMETRY is recessed in the hull by design; without the FX ships read
engine-less — that was the "engines inside the hull" report).
## Validation chain (all green)
- `ship::tests::static_assembly_matches_runtime_capture` (SYLPHEED_ISO): static
== the baked e106 capture, all 8 parts, T<1.0 R<0.02, nacelles ×2, turrets
×2, starboard mirror.
- `examples/capture_verify.rs` over the user's 5 multi-angle snapshots
(43 e106 instances): dominant clusters match static to ~1 unit on every part
incl. both nacelles. Run:
`cargo run --release --example capture_verify -- <Stage.xpr> e106 <logs…>`
- Offline audit (`examples/ship_audit.rs`) across all stages: no outliers among
composite ships; composite-less families (fighter morph sets, turret/prop
parts) are filtered from the viewer's Ships catalog.
- 81 formats-lib + all disc tests; viewer builds.
## Canary instrumentation (branch `capture-ship-placement`)
F10 → `xenia_ship_capture_NN.log` per press (multi-angle in one run), de-duped
by (vertex-buffer, c0..c2 WVP hash) so every instance of every part is
recorded. Launch:
```
cd xenia-canary/build/bin/Linux/Release
./xenia_canary "<…/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso>"
```
## Open / next
- Verify remaining capital ships against the existing 5 snapshots (they contain
every on-screen ship): e105 (its engine block assembles dorsally — the one
placement to confirm), f105/f106/f101, e102. Same capture_verify invocation
with the other ship id.
- AA-gun models at `GN_AAGun*` frames need the Vessel recipe binding.
- SJet (maneuvering-vernier) marker orientation is approximate; main aft plumes
verified.
- `_mov` node animation keys (hull-opening sequences) — rest pose rendered now.
- Viewer GUI verification by the user (exhaust cones, catalog cleanup).

46
docs/re/BACKLOG.md Normal file
View File

@@ -0,0 +1,46 @@
# RE backlog
Open items that are *not* being worked right now. Each entry says what is wrong or
unknown, what evidence exists, and what the first step would be. Move an item into
`INDEX.md` (with a `structures/…md` or a parser + test) once it is actually settled.
---
## Capital ships assemble wrong in the viewer
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
The reborn viewer builds capital ships from the split XBG7 parts via
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
wrong place / wrong orientation.
**Why this is a real finding and not a known limitation:** the RE write-up
[`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md) declares
static assembly ✅ **exact** as of 2026-07-26 — 9-channel joint tables
`[TX TY TZ RY RX RZ SX SY SZ]`, Euler `Ry·Rx·Rz`, with
`ship::tests::static_assembly_matches_runtime_capture` asserting static == runtime
capture (T < 1.0, R < 0.02). So either the viewer is not using that path, or the
claim generalises worse than the test suggests.
**The likely gap:** that test is **one ship** — the `e106` destroyer, 8 parts plus
two nacelles, two turrets and the hull mirror. Nothing pins the other classes.
Rules that were derived from `e106` and could easily be `e106`-specific:
- the engine cluster rig mounted at `GN_Engine_01` (two mirrored nacelles + centre);
- "X-reflect the shared-geometry twin whose lateral offset opposes the geometry's
dominant side" — a heuristic, not a decoded flag;
- cross-id turret instancing (×2).
**First step (the oracle already exists):** re-run the runtime capture on a *different*
capital ship and diff static vs captured, exactly as `e106` was done — F10 in the
`capture-ship-placement` build of `xenia-canary-native` dumps the ship shader's
`c0..c2` WorldViewProjection rows per part; `WV_ref⁻¹ · WV_p` is the ship-space rigid
transform, which is ground truth. Pick a class whose rig differs from `e106`
(different engine count, a ship with no `sld`, a carrier). Then extend
`static_assembly_matches_runtime_capture` into a per-ship table so a regression in one
class cannot hide behind `e106` passing.
**Also worth ruling out first, cheaply:** that the viewer's own transform stack (scale,
handedness, node-instance recursion) is not re-breaking a correct assembly — compare
the viewer's placement against `assemble_ship`'s output directly before blaming the
format layer.

View File

@@ -20,6 +20,21 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — [values](captures/unit-runtime-fields.csv) |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
## Runtime / dynamic-capture technique
| Technique | Conf. | Spec | Notes |
|-----------|-------|------|-------|
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
| Mission / escort state, every entity's hull | ✅ | [`tools/re-capture/mission_state.py`](../../tools/re-capture/mission_state.py) · [escort state](mission-escort-state.md) | `hull = position + 0x154` is a property of the **entity class**, not of the player object: at t=0 it 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**), falls under fire (780 damage events in 240 s), goes negative at death, and the object then leaves the heap. So an escort objective is scoreable live — `UN_f101_TCAF_Acropolis` measured at 25000 → 23038 over 240 s, attack starting only at t≈170 s. `REMAINING OB` counts objectives, not hostiles (012 on the HUD vs 118 live ADAN); its address is still ❔ |
| In-flight control mapping | ✅/🟡 | [`tools/re-capture/fire_probe.sh`](../../tools/re-capture/fire_probe.sh) · [controls](flight-controls-runtime.md) | Measured by holding each pad input and photographing the HUD ammo counters: **`RB` = nose gun** (6000→5956 in 4 s, ~11 rounds/s, HEAT rises), **`Y` = main mount** (missiles, 300→299), d-pad = **tactical map** overlay, nothing else moves a counter. No target-cycle input exists — the `TARGET` marker is present with nothing pressed, so targeting is automatic and a missile lock is **time-on-target**. That, not target choice or ballistics, is what caps lethality at 2 kills per 98 missiles |
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
## Functions / code paths

View File

@@ -0,0 +1,299 @@
# Memory-driven autopilot — build log and current state
**Status: 🟢 IT FLIES, KILLS AND SURVIVES — but it loses the mission anyway.**
Updated 2026-07-30. `pilot.py` flew Stage 02 for **300 s with the hull untouched
at 1500/1500** and scored the first confirmed autopilot kill (`YOU KILLED
WARPLANES 0001` on the HUD, screenshots `shots/pilot1-*.png`); the scene's
hostile count fell from 134 to 111 over the run. The day before, every run was
dead inside 35 s. What is still missing is the *end* of a mission: the objective
counter (`REMAINING OB`) rises as new waves spawn, and nothing yet tracks which
targets actually close it out — and the second run proved the point the hard
way: `GAME OVER` with the hull at 1500/1500, because Stage 02 is an **escort**
and the ACROPOLIS was sunk while the pilot chased fighters two kilometres away.
## 2026-07-30 — the numbers survival needs
Three things the loop was missing were measured this session, each by
consequence rather than by reading a field and hoping.
### Hull is `position + 0x154` ✅ CONFIRMED
The unit definition already had `HP` solved at `+0x054`
([unit-struct-runtime](structures/unit-struct-runtime.md)); the Delta Saber's is
**1500**. A craft that has taken no damage must therefore *contain that number*,
which turns "find the HP field" into a two-float lookup rather than a value scan
(`own_state.py`). It appears once in the entity object, at `pos+0x154`, and the
trace across a death settles it (`ctrl_probe.py` capture, `binq.py trace`):
```
t phase hull
0.00 base 1500.00 <- == definition HP
23.25 rest_A 1380.00 <- first hit, -120
26.68 … 27.18 B 1320 … 930 <- seven hits in 0.5 s
31.93 X 150.00
35.21 Y -30.00 <- goes negative
35.26 Y -180.00 -> GAME OVER on screen
```
Damage arrives in 30/60/90-point steps and the field goes *negative* at death,
so it is the raw hull counter, not a clamped display value. **1500 hull lost in
12 s** of sitting in a turret's line of fire is the whole reason every earlier
run died.
### Shield is `position + 0x430` 🟡 PROBABLE — not yet confirmed live
Same anchor trick: the definition's shield `MaxValue` is **400** and
`ChargeSpeed` **25**, and the entity object holds `400.0` at `+0x430`, `+0x434`
and `+0x438`, with `25.0` at `+0x448`. Which of the three is the *current* value
is unproven — the capture that spanned the death used a ±0x400 window and
cropped them out. `ctrl_probe.py` now samples ±0x800.
### `RT` accelerates, `LT` brakes, and the throttle is a *setting* ✅ CONFIRMED
`ctrl_probe.py` holds each input in turn and measures the craft's own speed as
displacement per second from the position triple, so no speed field is needed.
Distance flown / phase duration, one 3 s hold each, sticks neutral:
| phase | speed (units/s) | | phase | speed (units/s) |
|---|---|---|---|---|
| base (no input) | 488 | | A | 287 |
| **RT** | **1510** | | B | 139 |
| rest after RT | 1056 | | X | 125 |
| **LT** | **174** | | Y | (dying) |
| rest after LT | 428 | | LB / LS / RS / RY / RX / dpad | no effect |
RT triples the speed, LT cuts it to a third, and **the braked state persists**:
after the LT phase the craft sat at 125140 units/s with the sticks and triggers
neutral for the remaining 40 s, and nothing but RT brought it back. So these are
a throttle setting, not a momentary boost — which also means a control loop must
send only the *changes*.
This **corrects** the earlier note in this file ("`RT` is *not* the throttle,
and no button tested is"). That conclusion came from `findspeed.py`, which
assumed the control and went looking for a *field* that rose; measuring the
speed directly reverses it.
### Two method corrections
* **Pick the attitude block by the flight path, not by address order.** The
player object contains **20** orthonormal 3×3 blocks (identity frames, bone
or camera frames), and `pos-0x70` and `pos-0x30` hold the *same* matrix.
Taking `found[0]` wrote a config with `rot_delta = -0x764` and a nonsense
forward axis; `entities2.py self` now scores every block against the measured
direction of travel and picks the best (`cos = +1.000`, row 2, sign +1).
* **The entity-heap scan has to be numpy.** A per-word Python loop over the
16 MB entity region costs seconds per scan, which is the whole budget of a
10 Hz control loop; `np.isin` over a `>u4` view is milliseconds.
### Stage 02 as an autopilot testbed (from the in-flight HUD)
`OBJECTIVE: shoot down all invading enemy fighters while watching out for
attacks on the ACROPOLIS` · `DEFEAT: your fighter is shot down, or the ACROPOLIS
is sunk` · `HINT: you can resupply at the ACROPOLIS`. The HUD shows
**`REMAINING OB 004`** — only four objective targets — so this mission is
winnable by an autopilot that survives. It also shows separate **SHIELD** and
**ARMOR** bars (matching a 400-point shield over 1500 hull), `A/B 7,635`
afterburner, and `NOSE BM 06000` / `MAIN MPM 00300` ammo.
## What the loop did before that, observed
```
[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0
[ 85.7] tgt=e007_ADAN_Turret d=2797 yaw= +1.9 pit=+27.2 stick=(+0.20,-0.59) fire=0
[117.3] tgt=e007_ADAN_Turret d=4211 yaw= +5.7 pit= +9.0 stick=(+0.25,-0.40) fire=1
```
Distance closes monotonically, yaw error is driven from 8° to ~0, and once both
errors are inside the firing cone it holds RB and the ammo counter falls. A
rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.
**The chain that made it work** — each link checked, not assumed:
1. **Entity typing.** A live entity's definition pointer sits at
**position + 0x130**. One heap scan then yields every craft *with its unit
type*, which is what separates 20-odd real combatants from ~30 000 moving
particles. Verified by the result being coherent: wingmen, enemy turrets and
attackers, friendly capital ships, and exactly one `…_Player`.
2. **Orientation.** A 3×3 rotation at **position 0x70**, stored with a
**16-byte row stride** (a 4×4 transform whose translation row *is* the
position). An earlier search for nine *contiguous* floats structurally could
not find this, which is why the first pass concluded "no transform". The
binding is confirmed independently: its row 2 matches the craft's measured
direction of travel with **cos = +1.000**.
3. **The fire button is RB** — established by consequence, not by guessing:
of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo
counter in RAM fall (5958 → 5940). (This entry also claimed `RT` is *not* the
throttle — **wrong**, see the 2026-07-30 measurement above.)
4. **Control.** PD on the aiming error with the derivative taken from the
craft's own body angular velocity (from two consecutive rotation matrices),
and target selection weighted by off-boresight angle
(`score = d·(1 + 3·(θ/π)²)`) rather than pure nearest — closing on a target
90° off the nose only raises the bearing rate, which is what held the first
run outside its firing cone at a steady ~27° pitch error.
## Goal
Fly and fight a mission by reading the game's own world state out of guest RAM
and driving the pad from it — the RE payoff being an oracle for the
reimplementation's flight model and AI, and a way to reach missions the save
cannot otherwise reach (unit coverage for
[unit-struct-runtime](structures/unit-struct-runtime.md) is capped at 21/110
because definitions load **per stage**).
## What works (verified)
| Piece | Tool | Evidence |
|---|---|---|
| Live guest-RAM reads at loop rate | `gworld.py` | `/dev/shm` file opened once, `pread` per tick; a whole-RAM scan is ~6 s, a targeted read is microseconds |
| Whole-RAM float scanning | numpy over `SEEK_DATA` extents | 1 270 orthonormal 3×3 blocks located in 6.2 s |
| Entity enumeration by unit type | `gworld.py entities` | 116 live instances in Stage 02, typed by unit ID, incl. exactly one `…_Player` |
| Pad control at loop rate | `flight_probe.Pad` | writes command lines straight into the vgamepad FIFO; the `vgamepad` 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 | `launch_mission.sh` | title → LOAD GAME → slot 01 → READY ROOM → TAKE OFF → in flight, repeatable |
| Hangar loadout | `launch_mission.sh --hangar` | the "Recommended" control is **AUTO SELECT — "Mount most suitable weapons"**, already the default cursor position. At 5 % progress it is a no-op: only two weapons are developed, and they are already mounted |
| Finding moving objects | `findplayer.py` | position triples recovered from motion alone — straight-line, constant-speed filter over K whole-RAM samples |
## What is NOT solved
**Survival, and therefore mission completion.** The loop has no evasion, no
shield/armour awareness and no throttle control, so it flies a straight pursuit
into defended space and is eventually shot down — every long run so far has
ended in GAME OVER. Completing a mission needs, at least: reading own
shield/armour, breaking off when hit, and prioritising the mission's actual
objective targets over the nearest turret.
### Superseded (kept because the reasoning still matters)
The notes below were written before the chain above worked. They remain true as
statements about `0x820af030`, which is *not* the live entity —
1. **The `0x820af030` class is not the live entity.** It has one object per
spawned thing and carries the unit-ID string, so it looked like the entity
list — but over a 29 s in-flight capture **all 384 words of it are
constant** (`whatchanges.py`). It is a static spawn record. The earlier
claim in `unit-struct-runtime.md` that this class is "the spawned entity
instance — live state" is **wrong in the second half**: it is per-spawn, but
it is not live state. The parts of that document that depend on the
*definition* class `0x820af844` are unaffected.
2. **No transform in or one hop from that object**: no orthonormal 3×3, and no
unit quaternion, within `0x2000` of it or behind any of its 121 pointers.
3. **Input correlation finds *a* self-object, but not obviously the craft.**
Holding hard-left then hard-right yaw and looking for a position whose turn
axis reverses (`findself.py`, `selfstate.py`) gives clean hits
(`cos ≈ 0.99`), but they cluster at `0x40009xxx` in what looks like an
8-corner box with ±45 000 coordinates — a camera/skybox volume that follows
the player, not the craft. Its speed (≈359/s) is suspiciously close to the
HUD's 350, which supports "follows the player" but is not proof of identity.
4. **Entity typing is unavailable**: no definition pointer within ±0x800 of the
self-position, so the trick of learning one object's layout and applying it
to all the others has nothing to anchor on. Without typing, the 33 418
moving triples in a firefight cannot be separated into enemies, friendlies
and bullets, so there is nothing to aim at.
## Dead ends, recorded so they are not re-run
* **Speed-scan for the player object** (`findspeed.py`, the classic two-state
value scan: coast → boost → coast). Sound method, but **`RT` is not the
throttle** — 5 864 floats matched the cruise speed and none rose. The control
actually bound to acceleration was never established, and the run that would
have established it ended in GAME OVER.
* **Comparing orientation matrices 2 s apart.** At a real turn rate that is far
outside the small-angle regime, so the skew part of `A·Bᵀ` is not the rotation
vector and the "angular velocity" comes out as ~30 000. Sample incrementally
(6 Hz) and re-check orthonormality on every read — blocks found by a scan get
overwritten between the scan and the read.
## The second run lost the mission **without being hit** (2026-07-30)
A second 240 s flight, with the two fixes above, ended on the `GAME OVER`
screen — while the hull read **1500/1500 on the last live tick**. Nothing shot
us down. The other defeat condition fired: *the ACROPOLIS is sunk*. The HUD had
been showing a red `WARNING` banner for a while, and the pilot spent the whole
run pursuing an `e010_ADAN_Attacker_S` two kilometres away.
So surviving is necessary and not sufficient, and "nearest hostile fighter" is
the wrong objective function for this stage. **The mission is an escort.** What
follows:
* **Prioritise hostiles by their distance to the protected asset, not to us.**
The attackers worth killing are the ones closing on the ACROPOLIS.
* **The protected asset's health is readable with the same anchor as ours** —
hull at `position + 0x154`, its maximum being its own definition's `HP`. That
gives a live "are we winning" signal for the escort, and it should drive the
target choice directly.
* A frozen tail in the log (identical position, speed and target for the last
five seconds) is what mission-end looks like from the outside, **not** an
emulator wedge. Worth knowing before diagnosing the wrong thing.
* Practical: do **not** pipe a long run's log through `tail` — that discards
everything but the end, and the interesting part of this run is gone.
## After survival, the blocker is lethality (2026-07-30)
The 300 s run took **no damage at all** and killed **one** warplane, spending
~800 rounds of nose ammo (`06000``05193`) to do it, while `REMAINING OB` rose
from `004` to `011` as fresh waves spawned. So attrition at this rate never
finishes the mission, and the ranking of open problems has changed:
1. **Hit rate.** It opens fire at 25 km with a 9° cone and a crude lead
(`p + v·d/speed`, no projectile speed). The `Shell` records in
[weapon-struct-runtime](structures/weapon-struct-runtime.md) carry the real
projectile speed and `MaximumRange` per weapon — the lead and the firing
range should come from *those*, not from constants.
2. **Which targets count.** `REMAINING OB` is the mission's own objective
counter and it is on screen, so it is in RAM; finding it turns "shoot
whatever is nearest" into "shoot what closes the mission". Objective-marked
entities also draw an `OB` badge in the HUD, so the flag is likely a word in
the entity object.
3. **Confirming the shield word** — needs a run that actually takes damage; the
pilot is now good enough at avoiding that to make it awkward, so drive
straight at a turret on purpose with `--dry` steering disabled.
4. **Does the ACROPOLIS repair?** RETIRE mode has never triggered (the hull
never fell), so the resupply hint is still untested.
## The next step that unblocks the most (superseded — kept for the reasoning)
**Update 2026-07-30: this is no longer the blocker.** Entity typing via the
definition pointer already solved target selection, so the game's own target
pointer is now a convenience rather than a prerequisite. It would still be the
cheapest route to problem 2 above (objective targets), because whatever the HUD
locks on to is what the game itself considers a target.
**Find the game's own target pointer instead of typing entities ourselves.**
The HUD has a lock-on system (a `TARGET` marker and a target-cycle button), so
a global almost certainly holds a pointer to the currently-targeted entity.
Reading that gives an enemy's live object address directly — which yields both
target selection *and* the entity layout (position offset within it), i.e. it
collapses problems 3 and 4 into one. It is also cheap to find: cycle the target
with the pad and watch which pointer-shaped global changes in step.
## Operational notes
* An unattended craft **dies** — the ship flies straight into a firefight, and
two long scans were invalidated by a GAME OVER mid-run. Any scan longer than
~30 s needs either a survivable holding pattern or a fresh mission.
* `Xvfb` and the emulator die on their own every few minutes here, cleanly
(exit 0), cause unidentified. Everything that must not be interrupted is run
as **one background task** that starts the display, the emulator, the
navigation and the measurement together, so nothing has to survive between
tool calls.
* `pgrep` cannot be used for liveness in this container: PID 1 is
`sleep infinity` and never reaps, so dead processes linger as `<defunct>` and
still match by name. Use `ps -o stat=` and skip `Z`, or `xdpyinfo` for X.
* numpy is not installed system-wide; `pip install --break-system-packages
numpy` puts it in `/sylph-home/.local`, which is only on `sys.path` when
`HOME=/sylph-home`. Scripts run with `HOME=/sylph-home/re` need
`PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages`.
## Files
`pilot.py` (**the survival loop**) · `ctrl_probe.py` (input → speed calibration,
plus a per-tick window of the player object) · `binq.py` (query that capture) ·
`own_state.py` (definition-anchored hull/shield lookup) · `fly_session.sh`
(boot → mission → bind → fly, one task) · `wait_flight.sh` (wait for the real
HUD instead of a fixed sleep) · `navigator.py` (drift-aware steering + CPA
avoidance, reused by the pilot) ·
`gworld.py` (live reader + entity list) · `flight_probe.py` (scripted inputs +
sampling, and the `Pad` FIFO client) · `flight_analyze.py` · `whatchanges.py`
(encoding-agnostic "which words are live") · `findplayer.py` · `findself.py` ·
`findrot_global.py` · `findspeed.py` · `liveents.py` · `selfstate.py` (the
whole chain → JSON) · `autopilot2.py` (PD controller; **untested — it has never
had a valid config to run against**) · `launch_mission.sh`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
# In-flight control mapping — measured, not assumed
**Status:** ✅ for the weapon bindings (ammo counters move), 🟡 for the rest (HUD
observation only). Probes: `tools/re-capture/fire_probe.sh` (hold each input, photograph
the ammo counters) and `lock_probe.sh` (tap each, watch the reticle). Stage 02, in flight.
Evidence: [`captures/fire-probe-ammo-counters.png`](captures/fire-probe-ammo-counters.png).
| input | effect | confidence |
|---|---|---|
| **`RB`** | **Nose gun.** `NOSE BM` 06000 → 05956 in a 4 s hold ≈ **11 rounds/s**; `HEAT` bar rises | ✅ |
| **`Y`** | **Main mount** (missiles). `MAIN MPM` 00300 → 00299 per tap | ✅ |
| **`RT` / `LT`** | Throttle up / brake, a *persistent* setting (488 → 1510 → 174 u/s) | ✅ (earlier session) |
| **d-pad** | **Tactical map** overlay (grid with contact blips) — not target cycling | 🟡 |
| `LB`, `X`, `B`, `A`, `LS`, `RS` | No change to either ammo counter | ✅ (as "not a weapon") |
## ~~Targeting appears to be automatic~~ — WRONG, corrected below
> **Superseded.** This section concluded targeting was automatic because no input
> cycled a target. It is wrong: the HUD tutorial states target select is **Ⓐ pressed
> twice**, and every sweep here tapped once. Kept because the reasoning is a useful
> warning — a probe that never performs the action will "prove" the action does not
> exist. The rest of the section's measurements stand.
No *single* press cycled a target. The green `TARGET` marker is already present in
idle frames with nothing pressed, which I read as the game selecting for us.
That fits the measurements end to end:
- the guns fire fine (11 rounds/s) but the kill counters read `0000` after five gun-only
runs → **we shoot and miss**;
- guided missiles (`Missile_P`, Power 200, `GuidanceType` 5) got the first kills,
`WARPLANES 0002`, but only **2 per 98 launches**;
- the pilot's own log shows aim error wandering between ~10° and ~40° for most of a
pass.
At the time I concluded the bottleneck was aim dwell. Partly right — target
**commitment** did take kills 2 → 9 — but the larger cause was simply that no target was
ever selected, so the guided missiles had nothing to guide to.
## The game's own action list (from the OPTIONS key-config screen)
Decoded from `dat/GP_OPTIONS.pak` (`po_keys_btn*` sprites) — this is the authoritative
set of bindable in-flight actions, straight off the disc, no probing required:
| # | Action | Our mapping |
|---|---|---|
| 1 | Aircraft Control | LX/LY ✅ |
| 2 | View Point Control | RX/RY (unused by the pilot) |
| 3 / 4 | Left / Right Yaw Control | — (separate from pitch/roll!) |
| 5 / 6 | Accelerate / Decelerate | `RT` / `LT` ✅ |
| 7 | **Use Main Weapon** | `Y` ✅ |
| 8 | **Use Nose Weapon** | `RB` ✅ |
| 9 | Special Move | ❔ |
| 10 | Maneuver | ❔ |
| 11 | Resupply | ❔ |
| 12 | **Change Target** | ❔ — **this is the target-select the loop needs** |
| 13 | Change Main Weapon | ❔ (would reach `ASMissile`, Power 5000) |
| 14 | **Padlock Mode Toggle** | ❔ — **the aim-dwell mechanism** |
| 15 | Radar Map Toggle | d-pad 🟡 (matches the observed map overlay) |
Two entries change the plan outright:
- **`Change Target` exists**, so target selection *is* an input after all. The earlier
probe swept `LB/X/B/A/LS/RS` and found no ammo change — consistent with those being
exactly these non-weapon actions. The probe simply watched the wrong indicator.
- **`Padlock Mode Toggle`** is a view/aim lock onto the selected target. That is the
aim-dwell problem solved *by a game mechanic* rather than by tuning a PD controller —
and it is why a human player can hold a contact long enough to lock a missile.
Also note `CONTROL SETTINGS` carries a **`Control Type`** preset plus **Yaw / Pitch /
Roll Sensitivity** and a separate **`Throttle`** option: the mapping is not fixed, and
the craft's response to a given stick deflection is configurable. Any calibration done
against one profile (e.g. the `ctrl_probe.py` throttle numbers) is only valid for the
save's current settings.
## What the tutorials state outright
`tutorial_capture.sh <index> <secs> <tag>` plays one lesson and photographs it. Captions
use a typewriter effect, so crop `900x125+160+40` from many frames to read a full
sentence. Lessons that require the player to *do* something stall (BASIC CONTROLS sits
on "Go to the box on your screen" forever with nobody flying); the expository ones run
on their own.
- **HEADS-UP DISPLAY (index 1):** *"Enemies are displayed with **red markers** and allies
with **blue markers**." · "Targeting an enemy displays an Armor Gauge…" ·* **"Press Ⓐ
twice to target the enemy closest to the center of the screen."**
- **ADVANCED CONTROLS (index 5):** `B`+`LS` = Side Roll / 180 Degree Turn / Level Off ·
`B`+`A` together = face the target · `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."*
**`Change Target` is Ⓐ pressed TWICE** — a double tap. That is why every button sweep in
this document found nothing and why I wrongly concluded targeting was automatic: each
sweep tapped once. It also explains the missiles — `GuidanceType 5` needs the *game's*
selection, and the loop had never made one, so 98 launches guided to nothing.
## Notes for the reimplementation
- Two independent weapons with separate ammo pools and separate HUD counters:
`NOSE BM` (gun, 6000) and `MAIN MPM` (missiles, 300).
- The gun has a **HEAT** bar that fills while firing — a sustained-fire limit the
reimplementation needs; its cap and cool-down rate are not measured yet.
- The tactical map is a full-screen overlay bound to the d-pad and does not pause flight
(the craft kept taking fire with it open).

View File

@@ -0,0 +1,225 @@
# Escort / mission state from guest RAM — every entity's hull
**Status:** ✅ CONFIRMED (2026-07-30). Capture: `tools/re-capture/mission_state.py`,
session `tools/re-capture/escort_session.sh`, Stage 02 from save slot 01, 240 s of
flight, 240 samples at 1 Hz → [`captures/mission-state-stage02.jsonl`](captures/mission-state-stage02.jsonl).
Screenshot evidence: [`captures/escort-stage02-hud.png`](captures/escort-stage02-hud.png).
## The question
`own_state.py` found the **player's** hull by anchoring on a solved definition
field — an undamaged craft carries its definition's `HP` (+0x054), so the live
counter is the copy of that number that falls. Result: `hull = position + 0x154`
([autopilot](autopilot-memory-driven.md)).
Stage 02 is an **escort**, and it is lost when the ACROPOLIS sinks, not when the
player dies: a 240 s run hit `GAME OVER` with our own hull at 1500/1500. Scoring
that objective needs *someone else's* hull. So: is `+0x154` a property of the
**entity class**, or of the player object?
## Finding — it is class-wide
At the first sample of the run, before this session's fighting had touched them,
`pos+0x154` equals the entity's own definition `HP` across **seven classes and
five distinct HP values**:
| Class | radius | definition `HP` | `pos+0x154` at t=0 |
|---|---|---|---|
| `UN_e007_ADAN_Turret` | 22 | 100 | 100.0 (all 60 instances) |
| `UN_e010_ADAN_Attacker_S` | 100 | 500 | 500.0 (all 19) |
| `UN_f106_TCAF_Destroyer` | 2000 | 10000 | 10000.0 |
| `UN_e106_ADAN_Destroyer` | 2100 | 10000 | 10000.0 |
| `UN_f105_TCAF_Cruiser` | 3800 | 30000 | 30000.0 |
| `UN_e105_ADAN_Cruiser` | 3800 | 30000 | 30000.0 |
| **`UN_f101_TCAF_Acropolis`** | 1400 | **25000** | **25000.0** |
Measured directly by `mission_state.py scan` at the start of three separate runs:
**146/150, 147/150 and 147/150 entities** hold exactly their definition's `HP` at
`pos+0x154`. The handful that do not sit *slightly below* it (9800/10000,
29933.3/30000, 9725/10000, …) — the battle is already in progress when the player
launches, so those ships have already been shot at. **Nothing read above its `HP`,
and nothing read an unrelated number**, which is what a coincidental offset would
produce.
The value behaves like a live counter, not a copy of the definition:
- it **falls under fire** — 780 distinct damage events were logged across the run;
- it **goes negative at death** and the entity then disappears from the heap
(`UN_f106_TCAF_Destroyer``-30.0` of 10000, another → `-0.0`, a third GONE);
- the drops match what the HUD draws — the screenshot shows the ACROPOLIS and the
destroyer *CHARON* each with their own health bar, CHARON's already red.
So **`hull = position + 0x154` for every entity**, and the escort objective is
directly scoreable: read the protected ship's hull, normalise by its definition's
`HP`, done. No new anchor, no value scan.
## The escort asset, measured
`UN_f101_TCAF_Acropolis`, one instance, `HP` 25000, collision radius 1400.
Its hull over the 240 s run (pilot chasing the nearest hostile fighter, the
current `pilot.py` behaviour):
```
t= 0..150s 25000.0 untouched
t= 180.1s 24779.5
t= 210.1s 24149.5
t= 239.1s 23038.2 -1961.8 total, ≈ -600 HP/min once it starts
```
**⚠️ Onset is NOT a fixed schedule — corrected by a later run.** From this run alone
it looked like the asset is safe for the first ~170 s. A second run put the first
damage at **t = 70 s**, and its hostile population *grew* (134 → 166 ADAN) where this
one's shrank (147 → 118). So the stage is not replaying identically, and "the asset
is untouched early" is a property of one run, not of Stage 02. What survives the
second run is the weaker, still useful claim: **the loss is slow** — a few hundred to
~1400 HP/min against 25000, so tens of minutes to sink. The earlier `GAME OVER`
therefore was not a fast loss; it was an undefended one.
## Also captured
- Hostile population fell 147 → 118 over the run (the pilot fired on 435 of 1913
engage frames; most of the remainder it was manoeuvring with the target outside
the 9° firing cone).
- Two friendly destroyers were lost while the pilot was elsewhere.
- The HUD's `REMAINING OB` read **012** at t≈240 s while 118 ADAN entities were
alive, so that counter is **objectives, not hostiles** — its RAM address is still
unknown (❔ open).
## Escort-weighted targeting — implemented, and what it did NOT fix
`pilot.py` gained a **DEFEND** mode (2026-07-30): while the asset is losing hull,
target the hostiles pressing *it* — ranked by distance to the asset minus credit for
closing on it — instead of the ones nearest to us. Trigger and ranking both read the
live hull, so nothing is inferred.
It works mechanically: DEFEND engaged **1.9 s after the asset's first hit** in one run
(t=167.0), and held for 54 % of a 330 s run. **But it did not measurably save the
asset.** Over the window the two policies share, they are the same to within noise:
| t (s) | nearest-fighter | escort-weighted |
|---|---|---|
| 120 | 25000.0 | 24910.0 |
| 180 | 24779.5 | 24460.0 |
| 239 | 23038.2 | 23218.0 |
Two honest reasons it cannot yet be scored better than "no worse":
1. **The runs are not comparable past that window** — different spawn timing and, in
the escort-weighted run, a hostile population that *grew* 134 → 166 while the
baseline's fell 147 → 118.
2. **Lethality is the real bottleneck, not target choice.** The guns are on for only
**12 % of combat frames** (320 of 2630); the rest of the time the target is outside
the 9° firing cone while the loop manoeuvres. Choosing a better target does little
when most passes do not shoot.
**One bug found and fixed by the first escort run** (worth keeping as a pattern): the
new mode flies *at* the asset, which sits inside the friendly formation, and the run
ended `hull 1500 -> DEAD` in a single tick at 2026 units/s, 0.6 s from a friendly
destroyer the avoidance expected to clear by 365 units — against a hull of radius
2000. Keep-out had been applied only to hostile turrets. Every entity above
`BIG_RADIUS` now gets a physical keep-out of **its own radius + 800**, with braking
inside it, whatever its faction; the next run survived its full 330 s untouched.
## Ballistics from the disc data — and the measurement that invalidates the metric
The solved `Shell` records give the player's guns exactly
(`Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P`, all ✅ CONFIRMED):
**`Velocity` 8000**, **`LifeTime` 0.5 s**, **`MaximumRange` 4000** — self-consistent,
since 8000 × 0.5 = 4000 — plus shell `Radius` 2030 and `Power` 15/30/40.
Two things in `pilot.py` were plainly wrong against those numbers, and both are fixed:
- **Lead used our own speed as the shell speed.** Flight time was `d / max(our_speed,
300)`, i.e. 4002000 u/s instead of 8000 — every shot led **416× too far ahead**.
- **`FIRE_RANGE` was 5000**, past the range at which the shells expire.
**But the outcome metric says none of this has been shown to help.** The HUD's own
counters — `YOU KILLED: WARSHIPS` / `WARPLANES` — read **0000 / 0000 at the end of
every run**, including the nearest-fighter baseline. The pilot is not killing
anything in any configuration, so "fraction of frames with the guns on" (12 % → 5 % →
1 frame in 2639 as the firing gate was varied) was never measuring lethality. The
corrections above are right on the physics and fix demonstrably wrong code; **they are
not evidence of improvement**, and none is claimed.
The firing gate itself produced one clean result worth keeping: gating on the target's
angular half-size **alone** (2.7° at 2584 units for a fighter) is far tighter than the
steering loop can hold the nose, and firing collapsed to 1 frame in 2639. Angular size
belongs in the gate as a **floor** that opens it up close, never as a cap.
### Why nothing died — settled by probe, then fixed
`fire_probe.sh` holds each pad input in turn in flight and photographs the HUD ammo
counters. Result:
| input | `NOSE BM` | `MAIN MPM` |
|---|---|---|
| idle | 06000 | 00300 |
| **RB** | **05956** (44 in 4 s, HEAT rises) | 00300 |
| **Y** | 05951 | **00299** (1) |
| LB / X / B / A / RT / LT | no change | no change |
So **`RB` is the nose gun (~11 rounds/s) and `Y` is the main mount** — measured, not
assumed — and the "we never shoot" hypothesis is dead: **we shoot and miss.**
Which is what the disc data says to stop doing. `Shell_TCAF_DeltaSaber_Missile_P` is
**Power 200, `GuidanceType` 5 (guided), `MaximumRange` 5000**, against the nose gun's
**Power 15, unguided**. One missile is worth ~14 gun hits on a 500 HP fighter *and it
steers itself* — the accuracy problem solved rather than tuned. (`ASMissile_P` is
Power **5000**, the anti-ship option.)
Adding missile launches to the pilot (press `Y`, release a tick later, ≥2 s apart)
produced **the first kills of the whole series: `YOU KILLED: WARPLANES 0002`**, versus
`0000` in all five gun-only runs, with hostiles down 134 → 104 (the largest fall yet).
**Still poor, and stated as such: 98 missiles for 2 kills (~2 %).** The likely cause is
that the game expects a *lock* — holding the target in the reticle before launch — and
an unlocked launch is wasted. Reading the lock state (or the lock timer) out of RAM is
the next step, and it is the same anchoring trick as everything else here.
## Target commitment — the change that actually moved kills
The pilot re-scored every contact every tick, so the nose chased whichever fighter was
momentarily best-scoring and the aim error wandered 1040° through a pass. Since a
missile lock is time-on-target, constant switching is the one thing guaranteed to
prevent a kill. **Commitment**: stay on the chosen contact until it dies, gets beyond
6000, sits >90° off the nose for 2.5 s, or 14 s elapse.
Nothing else changed — same guns, same ballistics, same escort weighting, same missile
cadence:
| run | kills (`WARPLANES`) | missiles | hostiles |
|---|---|---|---|
| gun-only × 5 | **0000** | 0 | 147→118 … 134→166 |
| + guided missiles | **0002** | 98 | 134→104 |
| + **target commitment** | **0009** | 101 | **134→97** |
4.5× the kills for the same ammunition, and the largest fall in hostile population of
any run. Our own hull finished untouched at 1500/1500.
**The escort is still not saved** — the ACROPOLIS finished at 76.6 % — so this improves
lethality, not the mission outcome, and the two should not be conflated.
### Negative result: the selected target is not a raw entity pointer
Worth recording so it is not re-attempted. `target_probe.py` looked for the selection
three ways: (1) every word in a ±0x1400 window of the player object that points at a
live entity — **none**; (2) every word in *all* of RAM holding an entity pointer, tapped
through each button — only thread-stack slots (`0x70xx_xxxx`) churned, which is frame
noise, not selection; (3) a delta tally over all 150 entities looking for a repeated
offset holding a pointer to *another* entity, the same trick that found the definition
pointer at `+0x130` — **zero candidates**.
So neither the player nor the AI ships keep a raw pointer to their target near their
transform. The selection is a handle, an index, or lives in a targeting subsystem
outside the entity object.
## Reimplementation notes
- Defeat conditions for an escort stage are readable as: protected-asset
`hull ≤ 0`, or player `hull ≤ 0`.
- Every unit's effective HP is the definition's `HP`, confirmed live for 7 classes —
the same field the [unit struct](structures/unit-struct-runtime.md) already solves
statically, so disc data and runtime agree.
- Entity removal on death is observable (the object leaves the heap), which gives a
clean lifetime signal for anything modelling spawn/despawn.

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