Compare commits

..

16 Commits

Author SHA1 Message Date
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
25 changed files with 3478 additions and 29 deletions

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,207 @@
//! Recover exact capital-ship part placement from a Canary `xenia_ship_capture.log`
//! (F10 snapshot: per-draw guest vertex-buffer + up to 48 vertex-shader float
//! constants).
//!
//! FINDING (2026-07-24): the captured vertex BUFFER holds LOCAL coordinates —
//! byte-identical to the `.xpr` — so capital-ship parts are placed entirely in the
//! **vertex shader**. The per-part transform is in the VS constants: `c0..c2` are
//! the **WorldViewProjection** matrix rows. Their norms are `(sx, sy, 1)` = the
//! projection x/y scales (sy/sx = 16:9 aspect); dividing each row by its norm
//! yields the rigid **WorldView** (verified: rows orthonormal, det +1). The camera
//! View cancels when we express every part relative to a reference part:
//! rel_p = WV_ref⁻¹ · WV_p (a pure ship-space rigid transform)
//! Applying `rel_p` to part `p`'s local geometry assembles the ship exactly, in the
//! reference part's frame — ground truth, no static guessing.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr]
//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04`
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::xiso::open_iso;
use std::collections::HashSet;
use std::path::Path;
type M3 = [[f64; 3]; 3];
struct Draw {
vbase: u32,
vcount: u32,
/// Rigid WorldView: R (rows) + T (view-space translation).
r: M3,
t: [f64; 3],
ok: bool,
}
fn parse(text: &str) -> Vec<Draw> {
let mut out = Vec::new();
let mut vbase = 0u32;
let mut vcount = 0u32;
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut flush = |vbase: u32, vcount: u32, consts: &[(usize, [f64; 4])], out: &mut Vec<Draw>| {
if vbase == 0 {
return;
}
// c0..c2 = WVP rows; normalise each to unit → rigid WorldView.
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 {
out.push(Draw { vbase, vcount, r: [[0.0; 3]; 3], t: [0.0; 3], ok: false });
return;
};
let rows = [c0, c1, c2];
let mut r = [[0.0; 3]; 3];
let mut t = [0.0; 3];
let mut ok = true;
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 {
ok = false;
break;
}
r[i] = [row[0] / n, row[1] / n, row[2] / n];
t[i] = row[3] / n;
}
out.push(Draw { vbase, vcount, r, t, ok });
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") {
flush(vbase, vcount, &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("vsconst") {
for cap in l.split("c").skip(1) {
// "<i>=(x,y,z,w) ..."
if let Some((idx, rest)) = cap.split_once('=') {
if let Ok(i) = idx.trim().parse::<usize>() {
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, &consts, &mut out);
out
}
fn mt_apply(r: &M3, t: &[f64; 3], v: [f64; 3]) -> [f64; 3] {
[
r[0][0] * v[0] + r[0][1] * v[1] + r[0][2] * v[2] + t[0],
r[1][0] * v[0] + r[1][1] * v[1] + r[1][2] * v[2] + t[1],
r[2][0] * v[0] + r[2][1] * v[1] + r[2][2] * v[2] + t[2],
]
}
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 main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 4 {
eprintln!("usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr]");
std::process::exit(2);
}
let (log, stage, id) = (&args[1], &args[2], &args[3]);
let ref_sub = args.get(4).map(|s| s.as_str()).unwrap_or("bdy_04");
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let draws = parse(&std::fs::read_to_string(log).expect("read log"));
eprintln!("parsed {} draws ({} with WorldView)", draws.len(), draws.iter().filter(|d| d.ok).count());
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 parts: Vec<String> =
names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())).cloned().collect();
let want: HashSet<String> = parts.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
// Match each part to a captured draw by vertex count; keep its WorldView.
struct Placed {
part: String,
r: M3,
t: [f64; 3],
local: Vec<[f64; 3]>,
}
let mut placed: Vec<Placed> = Vec::new();
let mut used: HashSet<u32> = HashSet::new();
for part in &parts {
let Some(m) = models.iter().find(|m| &m.name == part) else { continue };
let local: Vec<[f64; 3]> =
m.meshes.iter().flat_map(|s| s.positions.iter()).map(|p| [p[0] as f64, p[1] as f64, p[2] as f64]).collect();
let vc = local.len() as u32;
if let Some(d) = draws.iter().find(|d| d.ok && d.vcount == vc && !used.contains(&d.vbase)) {
used.insert(d.vbase);
placed.push(Placed { part: part.clone(), r: d.r, t: d.t, local });
} else {
println!("{part:20} (no matching captured draw — occluded/culled?)");
}
}
// Reference part → ship frame. rel_p = WV_ref⁻¹ · WV_p (camera cancels).
let Some(rf) = placed.iter().find(|p| p.part.contains(ref_sub)).or(placed.first()) else {
eprintln!("no parts placed");
return;
};
let rt_ref = transpose(&rf.r);
let ref_t = rf.t;
println!("\nreference = {} → ship-relative placement (M rows, T):", rf.part);
let mut lo = [f64::MAX; 3];
let mut hi = [f64::MIN; 3];
for p in &placed {
// rel R = Rᵀ_ref · R_p ; rel T = Rᵀ_ref · (T_p T_ref)
let rel_r = mmul(&rt_ref, &p.r);
let dt = [p.t[0] - ref_t[0], p.t[1] - ref_t[1], p.t[2] - ref_t[2]];
let rel_t = mt_apply(&rt_ref, &[0.0; 3], dt);
// Assembled centroid (validation) + overall extent.
let mut c = [0.0; 3];
for v in &p.local {
let w = mt_apply(&rel_r, &rel_t, *v);
for k in 0..3 {
c[k] += w[k];
lo[k] = lo[k].min(w[k]);
hi[k] = hi[k].max(w[k]);
}
}
let n = p.local.len().max(1) as f64;
println!(
" {:18} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
p.part, rel_t[0], rel_t[1], rel_t[2], c[0] / n, c[1] / n, c[2] / n
);
}
println!(
"\nassembled extent = [{:.0} {:.0} {:.0}] ({} parts placed)",
hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2], placed.len()
);
}

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,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,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,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,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,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

@@ -61,6 +61,13 @@ 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;
/// Re-export the most commonly used types at the crate root.
pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject};

View File

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

View File

@@ -151,6 +151,37 @@ pub fn count_xbg7(bytes: &[u8]) -> usize {
n
}
/// List every XBG7 resource name in an XPR2 container, in directory order.
///
/// Cheap: walks only the resource directory (no geometry decode). Used to
/// discover which container holds a named hull / part model when assembling a
/// multi-part ship from a [`crate::game_data::Vessel`] recipe.
pub fn xbg7_resource_names(bytes: &[u8]) -> Vec<String> {
let mut out = Vec::new();
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return out;
}
let mut cur = Cursor::new(bytes);
let header = match Xpr2Header::read(&mut cur) {
Ok(h) => h,
Err(_) => return out,
};
const DIR_BASE: usize = 0x10;
for _ in 0..header.num_resources {
let e = match Xpr2ResourceEntry::read(&mut cur) {
Ok(e) => e,
Err(_) => break,
};
if &e.type_tag != b"XBG7" {
continue;
}
if let Some(n) = read_cstr(bytes, e.name_offset as usize + DIR_BASE) {
out.push(n);
}
}
out
}
impl Xbg7Model {
/// Total vertex / triangle counts across all sub-meshes.
pub fn totals(&self) -> (usize, usize) {
@@ -402,6 +433,18 @@ impl Xbg7Model {
Self::anchor_models_cancellable(bytes, min_consistency, &|| false)
}
/// Decode only the named XBG7 resources from a container (content-anchored,
/// abortable). Used to assemble a whole ship from just its part family
/// (`e106_bdy_*`, `e106_brg_*`, …) instead of decoding the whole ~300-resource
/// stage. Order follows the container's directory. See [`crate::ship`].
pub fn models_named(
bytes: &[u8],
wanted: &std::collections::HashSet<String>,
should_cancel: &(dyn Fn() -> bool + Sync),
) -> Vec<Xbg7Model> {
Self::anchor_models_filtered(bytes, 0.0, should_cancel, Some(wanted))
}
/// [`Xbg7Model::anchor_models`] with a cancellation poll checked between
/// resources (a large stage container holds hundreds). See
/// [`Xbg7Model::stage_models_cancellable`].
@@ -409,6 +452,15 @@ impl Xbg7Model {
bytes: &[u8],
min_consistency: f32,
should_cancel: &(dyn Fn() -> bool + Sync),
) -> Vec<Xbg7Model> {
Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None)
}
fn anchor_models_filtered(
bytes: &[u8],
min_consistency: f32,
should_cancel: &(dyn Fn() -> bool + Sync),
wanted: Option<&std::collections::HashSet<String>>,
) -> Vec<Xbg7Model> {
let mut out = Vec::new();
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
@@ -464,6 +516,11 @@ impl Xbg7Model {
}
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
if let Some(w) = wanted {
if !w.contains(&name) {
continue;
}
}
resources.push(Res {
name,
markers,
@@ -1783,6 +1840,145 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
out
}
/// A geometry resource placed by a composite scene graph, with its world affine.
#[derive(Debug, Clone)]
pub struct ScenePart {
/// The geometry resource to draw (e.g. `e106_bdy_01`).
pub resource: String,
/// World rotation (row-major 3×3).
pub m: [[f32; 3]; 3],
/// World translation, `(X, up→Y, fore/aft→Z)`.
pub t: [f32; 3],
/// Per-axis local scale (a shield generator ships at 0.5, a jet FX at 2.4).
pub s: [f32; 3],
}
impl ScenePart {
/// Apply the world transform to a local vertex: `R·(S·v) + T` (scale is a
/// leaf-local factor; the composite's structural nodes are all unit-scale).
pub fn apply(&self, v: [f32; 3]) -> [f32; 3] {
let sv = [v[0] * self.s[0], v[1] * self.s[1], v[2] * self.s[2]];
[
self.m[0][0] * sv[0] + self.m[0][1] * sv[1] + self.m[0][2] * sv[2] + self.t[0],
self.m[1][0] * sv[0] + self.m[1][1] * sv[1] + self.m[1][2] * sv[2] + self.t[1],
self.m[2][0] * sv[0] + self.m[2][1] * sv[1] + self.m[2][2] * sv[2] + self.t[2],
]
}
}
/// Walk a composite scene graph (`e_rou_eNNN`) and return **every** node with its
/// composed world transform (parent∘child down the first-child/next-sibling tree).
///
/// A capital ship's parts are authored around the origin in their own resources;
/// the composite's node tree carries the world placement — `rou_<id>_*` nodes name
/// the hull geometry resources, and `GN_*` nodes are the Vessel hardpoint frames
/// (bridge / engine / shield-generator / turret mounts). The [`crate::ship`]
/// assembler resolves those names to resources and places the parts. Same node
/// record layout + TRS convention as [`node_transforms`], but cross-resource.
pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
let Some((desc, desc_end)) = xbg7_descriptor(bytes, composite_name) else {
return Vec::new();
};
let d = &bytes[desc..desc_end];
let head_end = d.len();
let read_trs = |t44: usize| -> [f64; 8] {
let mut v = [0.0f64; 8];
for (k, slot) in v.iter_mut().enumerate() {
let p = be32(d, t44 + k * 4) as usize;
if p != 0 && p + 16 <= d.len() {
*slot = be_f64(d, p + 8);
}
}
v
};
// A node record begins with a name starting `rou_` or `GN_`.
let node_name_at = |i: usize| -> Option<(String, usize)> {
let starts = i + 4 <= d.len() && &d[i..i + 4] == b"rou_"
|| i + 3 <= d.len() && &d[i..i + 3] == b"GN_";
if !starts {
return None;
}
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]).ok()?.to_string();
if name.ends_with("_col")
|| name.ends_with("_spc")
|| name.ends_with("_gls")
|| name.ends_with("_lum")
{
return None;
}
Some((name, j))
};
struct Rec {
name_start: usize,
name: String,
local_m: M3,
local_t: [f32; 3],
local_s: [f32; 3],
sib_end: Option<usize>,
}
let mut recs: Vec<Rec> = Vec::new();
let mut i = 0usize;
while i + 3 <= head_end {
let Some((name, j)) = node_name_at(i) else {
i += 1;
continue;
};
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 sib_ptr = be32(d, ff - 0x04) as usize;
let trs = if t44 != 0 && t44 + 32 <= d.len() {
read_trs(t44)
} else {
[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
};
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
// Slots 57 are per-axis scale (a hardpoint part ships at e.g. 0.5).
let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 };
let local_s = [sc(trs[5]), sc(trs[6]), sc(trs[7])];
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
let sib_end = sib_ptr.checked_sub(4).filter(|&s| {
s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_")
});
recs.push(Rec { name_start: i, name, local_m, local_t, local_s, sib_end });
i = j.max(i + 1);
}
// Compose transforms down the first-child / next-sibling tree.
let mut out = Vec::new();
let mut stack: Vec<(usize, M3, [f32; 3])> = Vec::new();
for rec in &recs {
while stack.last().is_some_and(|s| s.0 <= rec.name_start) {
stack.pop();
}
let (pm, pt) = stack.last().map(|s| (s.1, s.2)).unwrap_or((M3_ID, [0.0; 3]));
let wm = m3_mul(pm, rec.local_m);
let r = m3_vec(pm, rec.local_t);
let wt = [r[0] + pt[0], r[1] + pt[1], r[2] + pt[2]];
out.push(ScenePart { resource: rec.name.clone(), m: wm, t: wt, s: rec.local_s });
let end = rec
.sib_end
.unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end));
stack.push((end, wm, wt));
}
out
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]

View File

@@ -0,0 +1,394 @@
//! 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>,
}
/// 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());
ShipModel { id: id.clone(), faction: Faction::from_id(&id), parts: Vec::new() }
});
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);
if resources.contains(stem) {
return Some(stem.to_string());
}
for suf in ["_root", "_mov"] {
if let Some(s) = stem.strip_suffix(suf) {
if resources.contains(s) {
return Some(s.to_string());
}
}
}
// 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()) {
let s = &stem[..pos];
if resources.contains(s) {
return Some(s.to_string());
}
}
}
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.
///
/// Two tiers, matching how the game splits a ship:
/// 1. **Hull** — `rou_<id>_*` nodes in the primary composite scene graph name the
/// hull body resources and carry their world transform. This tier is exact: the
/// bodies (and any engine posed by a `rou_` node) land where the game puts them.
/// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each
/// unplaced bridge / shield-generator / engine part (`<id>_brg_*`, `_sld_*`,
/// `_eng_*`) is placed at its matching frame by category + index. This tier is
/// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline),
/// while the part's outboard/rotational offset lives in a detail sub-rig or in
/// runtime code that this static pass does not recover — so external parts land
/// near, not exactly, where they belong. Gated by `include_external`.
///
/// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*`
/// frames) needs the per-mount Vessel recipe and is not resolved at all.
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 real ship-space layout (hull bodies +
// every `GN_*` hardpoint frame). Sibling composites (`e_rou_<id>_eng`,
// `_wep_*`) are LOCAL detail rigs — their nodes are in their own frame, not
// ship-space, so folding them in floats parts mid-ship. Pick the composite
// with the most nodes; that is the assembled hull.
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
.into_iter()
.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();
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) {
// Only the ship's own parts. A composite also mounts shared, cross-id
// turret models (`e303_wep_*` on an `e106` hull); those need the
// per-mount Vessel recipe and are placed separately.
if ship_id_of(&res) != Some(id) {
continue;
}
if placed_res.insert(res.clone()) {
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
}
}
}
// Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE —
// see the doc comment). Skipped for a clean, exact hull-only render.
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());
}
}
// 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
}
#[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: 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

@@ -503,6 +503,149 @@ pub struct VoiceLibrary {
#[derive(Event, Default)]
pub struct RequestVoiceLibrary;
// ── Game-data browser (decoded IDXD tables) ────────────────────────────────────
/// Which decoded table the Game Data browser is showing.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum GameCategory {
#[default]
Weapons,
Craft,
Vessels,
Characters,
Missions,
Arsenal,
Flights,
}
impl GameCategory {
pub const ALL: [GameCategory; 7] = [
GameCategory::Weapons,
GameCategory::Craft,
GameCategory::Vessels,
GameCategory::Characters,
GameCategory::Missions,
GameCategory::Arsenal,
GameCategory::Flights,
];
pub fn label(self) -> &'static str {
match self {
GameCategory::Weapons => "Weapons",
GameCategory::Craft => "Craft",
GameCategory::Vessels => "Capital Ships",
GameCategory::Characters => "Characters",
GameCategory::Missions => "Missions",
GameCategory::Arsenal => "Arsenal",
GameCategory::Flights => "Flights",
}
}
}
/// A character row, name resolved via localization.
pub struct CharRow {
pub name: String,
pub faction: String,
pub faces: usize,
}
/// A mission row: stage + its resolved briefing.
pub struct MissionRow {
pub id: String,
pub location: String,
pub phases: u32,
pub objectives: Vec<String>,
pub lose: Vec<String>,
pub enemies: Vec<String>,
}
/// Everything the Game Data browser shows, decoded off-thread from the paks.
#[derive(Default)]
pub struct GameSnapshot {
pub weapons: Vec<sylpheed_formats::game_data::Weapon>,
pub craft: Vec<sylpheed_formats::game_data::CraftUnit>,
pub vessels: Vec<sylpheed_formats::game_data::Vessel>,
pub characters: Vec<CharRow>,
pub missions: Vec<MissionRow>,
pub arsenal: sylpheed_formats::game_data::Arsenal,
/// Distinct flight line-ups: `(callsign, pilot)` rows.
pub flights: Vec<Vec<(String, String)>>,
}
/// The Game Data browser state (decoded combat / mission / cast tables).
#[derive(Resource, Default)]
pub struct GameData {
pub open: bool,
pub loading: bool,
pub loaded: bool,
pub category: GameCategory,
pub filter: String,
pub snapshot: GameSnapshot,
}
/// Ask the loader to decode the game-data tables.
#[derive(Event, Default)]
pub struct RequestGameData;
/// One assemblable ship in the Ships browser: an XBG7 part family reconstructed
/// into a whole model, with any linked [`sylpheed_formats::game_data::Vessel`]
/// stats.
#[derive(Clone)]
pub struct ShipRow {
/// Resource-family id, e.g. `e106`.
pub id: String,
/// `ADAN` / `TCAF` / `Neutral` / `Other`.
pub faction: String,
/// Display name (from the linked Vessel, else the id).
pub name: String,
/// The stage container to render from, e.g. `hidden/resource3d/Stage_S02.xpr`.
pub stage_file: String,
/// Stage labels the ship appears in (`S02`, `S04`).
pub stages: Vec<String>,
/// Base part resource names to draw together.
pub parts: Vec<String>,
/// True when the id matched a `Vessel` recipe (a real capital ship).
pub has_vessel: bool,
pub hp: Option<f32>,
pub size: Option<(f32, f32, f32)>,
pub turrets: Option<i64>,
pub bridges: Option<i64>,
pub shield_gens: Option<i64>,
}
/// The Ships browser state — capital ships assembled from XBG7 part families.
#[derive(Resource, Default)]
pub struct ShipBrowser {
pub open: bool,
pub loading: bool,
pub loaded: bool,
pub filter: String,
/// Faction filter: "" = all, else "ADAN"/"TCAF"/"Neutral".
pub faction: String,
pub rows: Vec<ShipRow>,
/// Family id of the ship currently rendered (for row highlight).
pub selected: Option<String>,
/// Also place the approximate external parts (bridge / shield gen / engines at
/// their `GN_*` hardpoint frames). Off by default → the exact hull only.
pub show_external: bool,
}
/// Ask the loader to scan the stage containers and build the ship catalog.
#[derive(Event, Default)]
pub struct RequestShipCatalog;
/// Ask the loader to assemble + render one ship's part family.
#[derive(Event)]
pub struct RequestShipRender {
/// Stage container path to read the parts from.
pub file: String,
/// Ship family id (`e106`) — drives the scene-graph assembly.
pub id: String,
/// Also place the approximate external hardpoint parts.
pub external: bool,
/// Display label for the model info line.
pub label: String,
}
/// The currently-open IPFB data pack, shown as a master-detail browser.
#[derive(Resource, Default)]
pub struct PakView {
@@ -593,6 +736,10 @@ enum IsoLoaderMsg {
VoiceLibraryLoaded {
clips: Vec<sylpheed_formats::slb::VoiceClip>,
},
/// The assembled-ship catalog for the Ships browser.
ShipCatalogLoaded(Vec<ShipRow>),
/// The decoded game-data tables for the browser.
GameDataLoaded(Box<GameSnapshot>),
/// User dismissed the file dialog — not an error.
Cancelled,
Error(String),
@@ -810,10 +957,15 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<MovieVoice>()
.init_resource::<AudioPreview>()
.init_resource::<VoiceLibrary>()
.init_resource::<GameData>()
.init_resource::<ShipBrowser>()
.add_event::<RequestSubtitles>()
.add_event::<RequestVoice>()
.add_event::<RequestAudio>()
.add_event::<RequestVoiceLibrary>();
.add_event::<RequestVoiceLibrary>()
.add_event::<RequestGameData>()
.add_event::<RequestShipCatalog>()
.add_event::<RequestShipRender>();
#[cfg(not(target_arch = "wasm32"))]
{
@@ -842,6 +994,9 @@ impl Plugin for IsoLoaderPlugin {
handle_audio_request,
advance_audio_playback,
handle_voice_library_request,
handle_game_data_request,
handle_ship_catalog_request,
handle_ship_render_request,
)
.chain()
.in_set(IsoLoaderSystemSet),
@@ -1681,6 +1836,8 @@ fn poll_loader_channel(
mut mvoice: ResMut<MovieVoice>,
mut audio_preview: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>,
mut game_data: ResMut<GameData>,
mut ships: ResMut<ShipBrowser>,
) {
let receiver = channels.receiver.lock().unwrap();
loop {
@@ -1793,6 +1950,23 @@ fn poll_loader_channel(
voice_lib.loaded = true;
}
}
Ok(IsoLoaderMsg::GameDataLoaded(snap)) => {
game_data.loading = false;
// An empty snapshot means the decode failed (no game source, or a
// bare pak) — leave `loaded=false` so re-opening retries.
let empty = snap.weapons.is_empty() && snap.craft.is_empty() && snap.missions.is_empty();
if empty {
game_data.loaded = false;
} else {
game_data.snapshot = *snap;
game_data.loaded = true;
}
}
Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => {
ships.loading = false;
ships.loaded = !rows.is_empty();
ships.rows = rows;
}
Ok(IsoLoaderMsg::Cancelled) => {
iso_state.loading = false;
browser.loading = false;
@@ -2220,6 +2394,25 @@ fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option<I
#[cfg(not(target_arch = "wasm32"))]
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
prepare_models_impl(bytes, models, false)
}
/// Assemble several XBG7 resources into ONE whole model in a shared coordinate
/// frame — the capital-ship case, where each part (`e106_bdy_01`, `e106_brg_01`,
/// …) already carries its ship-local position. Unlike the stage path, the parts
/// are NOT laid out in a grid and are recentred by a single shared bbox so their
/// relative placement is preserved. See [`sylpheed_formats::ship`].
#[cfg(not(target_arch = "wasm32"))]
fn prepare_ship(bytes: &[u8], parts: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
prepare_models_impl(bytes, parts, true)
}
#[cfg(not(target_arch = "wasm32"))]
fn prepare_models_impl(
bytes: &[u8],
models: &[sylpheed_formats::mesh::Xbg7Model],
assemble: bool,
) -> PreparedXpr {
use bevy::render::mesh::{Indices, PrimitiveTopology};
use sylpheed_formats::texture::X360Texture;
@@ -2227,7 +2420,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
const GAP: f32 = 4.0;
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
let pitch = CELL + GAP;
let is_stage = models.len() > 1;
// Grid layout only for a true multi-model stage browse — an assembled ship
// keeps its parts in one shared space.
let is_stage = models.len() > 1 && !assemble;
// Keep models with real, bounded geometry.
let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
@@ -2324,6 +2519,25 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
let mut total_v = 0usize;
let mut total_t = 0usize;
// Assembled ship: one shared recentre across ALL parts so each part keeps its
// ship-local offset (bridge fore, engines aft). Computed from the raw parts —
// ship parts carry no node transform, so raw == placed.
let shared_center = if assemble {
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for m in &drawn {
for sub in &m.meshes {
for p in &sub.positions {
lo = lo.min(Vec3::from_array(*p));
hi = hi.max(Vec3::from_array(*p));
}
}
}
(lo.x <= hi.x).then(|| (lo + hi) * 0.5)
} else {
None
};
for (i, model) in drawn.iter().enumerate() {
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
let mut lo = Vec3::splat(f32::MAX);
@@ -2337,7 +2551,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
if lo.x > hi.x {
continue;
}
let center = (lo + hi) * 0.5;
// An assembled ship uses the shared centre (preserve relative placement);
// otherwise recentre each model on its own bbox.
let center = shared_center.unwrap_or((lo + hi) * 0.5);
let (scale, cell) = if is_stage {
let extent = (hi - lo).max_element().max(1e-3);
let col = i % cols;
@@ -2500,7 +2716,17 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
} else {
CELL
};
let (name, info) = if is_stage {
let (name, info) = if assemble {
(
"ship".to_string(),
format!(
"Assembled ship · {} parts · {} verts · {} tris",
drawn.len(),
total_v,
total_t
),
)
} else if is_stage {
(
"stage".to_string(),
format!(
@@ -3213,29 +3439,40 @@ fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<
}
}
/// Categorical fallback voice token for a hokyu (resupply) cutscene the manifest
/// leaves unbound. Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`;
/// the game reuses those 5 generic recordings across stages, keyed by
/// ship (`LS`/`DS`) × resupply source (carrier `…A` / tanker `…H`). The four
/// category→recording pairs are read off the bound examples; `LS`/carrier has two
/// takes (450 from s02A, 451 from s09A) — we default unbound LS/carrier to 450.
/// Returns `None` for non-hokyu movies (they resolve via the manifest only).
fn hokyu_fallback_token(movie: &str) -> Option<String> {
/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound.
///
/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse
/// those 5 recordings. The selector is the cutscene's **demo id** (from its
/// subtitle track), NOT the ship/source category: `hokyu_LS_s02A` and
/// `hokyu_LS_s11A` are both LS/carrier but use demos 600 vs 601 → `VOICE_D_450`
/// vs `_451` (their lines differ: "Rhino 3 has landed" vs "Rhino Leader has
/// landed"). We derive the demo→token map from the 5 BOUND hokyu (each has both a
/// subtitle demo id and a VOICETRACK), then look up the target movie's demo id.
/// Returns `None` for hokyu with no voice cue (`hokyu_LS_s24A`/`s27A` — correctly
/// silent) and for non-hokyu movies (they resolve via the manifest only).
#[cfg(not(target_arch = "wasm32"))]
fn hokyu_voice_token(
source: &SourceKind,
movie: &str,
lang: sylpheed_formats::slb::VoiceLang,
manifest: &[u8],
) -> Option<String> {
use sylpheed_formats::{movie_manifest, movie_subtitle as ms};
if !movie.starts_with("hokyu_") {
return None;
}
let ls = movie.contains("_LS_");
let ds = movie.contains("_DS_");
let carrier = movie.ends_with('A');
let tanker = movie.ends_with('H');
let token = match (ls, ds, carrier, tanker) {
(true, _, true, _) => "VOICE_D_450", // light ship, carrier resupply
(true, _, _, true) => "VOICE_D_453", // light ship, tanker resupply
(_, true, true, _) => "VOICE_D_452", // Delta Saber, carrier resupply
(_, true, _, true) => "VOICE_D_454", // Delta Saber, tanker resupply
_ => return None,
};
Some(token.to_string())
let lang_pak =
read_pak_archive_blocking(source, &format!("dat/movie/{}.pak", lang.code_pub())).ok()?;
// The target cutscene's demo id (its subtitle's single voice cue).
let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?;
// Match it against a bound hokyu carrying the same demo id → that VOICETRACK.
movie_manifest::parse(manifest).into_iter().find_map(|e| {
let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?;
ms::track_voice_cues(&lang_pak, &e.movie)
.iter()
.any(|&(d, _)| d == want)
.then_some(tok)
})
}
/// Resolve a movie's cutscene voice to a **continuous byte region** of the
@@ -3264,7 +3501,7 @@ fn resolve_movie_voice_region(
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
let token = movie_manifest::voice_token(&manifest, movie)
.or_else(|| hokyu_fallback_token(movie))?;
.or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?;
// token → sound-id via the master registry (the large per-language IDXD entry
// carrying the `<lang>\Movie\VOICE_*.slb` paths).
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
@@ -3451,6 +3688,344 @@ fn handle_audio_request(
});
}
/// Handles a [`RequestGameData`]: decodes the combat / mission / cast tables
/// off-thread and posts the snapshot back for the browser.
#[cfg(not(target_arch = "wasm32"))]
fn handle_game_data_request(
mut events: EventReader<RequestGameData>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
if events.read().next().is_none() {
return;
}
let source = iso_state.source_kind.clone();
let sender = channels.sender.clone();
std::thread::spawn(move || {
let snap = build_game_snapshot(&source).unwrap_or_default();
let _ = sender.send(IsoLoaderMsg::GameDataLoaded(Box::new(snap)));
});
}
/// Decode the game-data tables into a browser snapshot (English pak + hangar pak).
#[cfg(not(target_arch = "wasm32"))]
fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
use sylpheed_formats::{game_data as gd, localization::TextIndex};
let main = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak").ok()?;
let text = TextIndex::build(&main);
let mut weapons = gd::load_weapons(&main);
weapons.sort_by(|a, b| a.id.cmp(&b.id));
let mut craft = gd::load_units(&main);
craft.sort_by(|a, b| a.id.cmp(&b.id));
let mut vessels = gd::load_vessels(&main);
vessels.sort_by(|a, b| a.id.cmp(&b.id));
let mut characters: Vec<CharRow> = gd::load_characters(&main)
.into_iter()
.filter_map(|c| {
let id = c.id?;
let short = id.trim_start_matches("Character").to_string();
let name = text.character_name(&short).unwrap_or(&short).to_string();
Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() })
})
.collect();
characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone())));
// Combat rosters, keyed by stage where the table self-identifies.
let rosters = gd::load_unit_rosters(&main);
let stat_hp = |id: &str| -> Option<f32> {
craft.iter().find(|u| u.id.as_deref() == Some(id)).and_then(|u| u.hp)
.or_else(|| vessels.iter().find(|v| v.id.as_deref() == Some(id)).and_then(|v| v.hp))
};
let mut missions: Vec<MissionRow> = Vec::new();
for s in gd::load_stages(&main) {
// main + extra story stages only (skip the Test stage)
if s.id.len() != 3 || !s.id.starts_with('S') || s.id[1..].parse::<u32>().is_err() {
continue;
}
let objectives: Vec<String> =
(1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect();
let lose: Vec<String> =
(1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect();
let enemies: Vec<String> = rosters
.iter()
.find(|r| r.stage.as_deref() == Some(s.id.as_str()))
.map(|r| {
r.units.iter().filter(|u| u.contains("ADAN")).map(|u| {
let name = u.trim_start_matches("UN_").splitn(3, '_').nth(2).unwrap_or(u).replace('_', " ");
match stat_hp(u) {
Some(h) => format!("{name} ({h:.0} HP)"),
None => name,
}
}).collect()
})
.unwrap_or_default();
missions.push(MissionRow {
id: s.id.clone(),
location: s.location.unwrap_or_default().replace('_', " "),
phases: s.phases,
objectives,
lose,
enemies,
});
}
missions.sort_by(|a, b| a.id.cmp(&b.id));
// Arsenal + flights from the hangar pak (optional).
let (arsenal, flights) = read_pak_archive_blocking(source, "dat/GP_HANGAR_ARSENAL.pak")
.map(|h| {
let arsenal = gd::load_arsenal(&h);
let mut seen = std::collections::BTreeSet::new();
let mut flights = Vec::new();
for r in gd::load_pilot_rosters(&h) {
let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::<Vec<_>>().join(",");
if seen.insert(key) {
flights.push(r.pilots);
}
}
(arsenal, flights)
})
.unwrap_or_default();
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
}
/// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs
/// the assemblable ship catalog off-thread.
#[cfg(not(target_arch = "wasm32"))]
fn handle_ship_catalog_request(
mut events: EventReader<RequestShipCatalog>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
mut ships: ResMut<ShipBrowser>,
) {
if events.read().next().is_none() {
return;
}
if ships.loaded || ships.loading {
return; // already built / building
}
ships.loading = true;
let source = iso_state.source_kind.clone();
let sender = channels.sender.clone();
std::thread::spawn(move || {
let rows = build_ship_catalog(&source);
let _ = sender.send(IsoLoaderMsg::ShipCatalogLoaded(rows));
});
}
/// Scan every `Stage_SNN.xpr` container, group XBG7 resources into whole ships,
/// and join each to its [`sylpheed_formats::game_data::Vessel`] stats.
#[cfg(not(target_arch = "wasm32"))]
fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
use sylpheed_formats::game_data::{self as gd, Vessel};
use sylpheed_formats::ship::ships_in_container;
use std::collections::BTreeMap;
// Vessel stats keyed by family id (`rou_e105` → `e105`).
let vessels: Vec<Vessel> = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak")
.map(|p| gd::load_vessels(&p))
.unwrap_or_default();
let vmap: BTreeMap<String, &Vessel> = vessels
.iter()
.filter_map(|v| {
let id = v.model.as_deref()?.trim_start_matches("rou_").to_string();
Some((id, v))
})
.collect();
// A capital-ship-ish display name from the Vessel id (`UN_e105_ADAN_Cruiser`
// → `Cruiser`), else the family id.
let pretty = |id: &str, v: Option<&&Vessel>| -> String {
if let Some(v) = v {
if let Some(vid) = &v.id {
// UN_<id>_<FACTION>_<Class…>
if let Some(rest) = vid.strip_prefix("UN_") {
let parts: Vec<&str> = rest.splitn(3, '_').collect();
if parts.len() == 3 {
return parts[2].replace('_', " ");
}
}
}
}
id.to_string()
};
let mut agg: BTreeMap<String, ShipRow> = BTreeMap::new();
for n in 1..=30u32 {
let file = format!("hidden/resource3d/Stage_S{n:02}.xpr");
let Ok(bytes) = read_source_file(source, &file) else {
continue;
};
let label = format!("S{n:02}");
for sm in ships_in_container(&bytes) {
// Skip single-piece props / debris — not "whole ships".
if sm.parts.len() < 2 {
continue;
}
let v = vmap.get(&sm.id);
let entry = agg.entry(sm.id.clone()).or_insert_with(|| ShipRow {
id: sm.id.clone(),
faction: sm.faction.label().to_string(),
name: pretty(&sm.id, v),
stage_file: file.clone(),
stages: Vec::new(),
parts: sm.parts.clone(),
has_vessel: v.is_some(),
hp: v.and_then(|v| v.hp),
size: v.and_then(|v| Some((v.size_x?, v.size_y?, v.size_z?))),
turrets: v.and_then(|v| v.turret_count),
bridges: v.and_then(|v| v.bridge_count),
shield_gens: v.and_then(|v| v.shield_generator_count),
});
if !entry.stages.contains(&label) {
entry.stages.push(label.clone());
}
}
}
let mut rows: Vec<ShipRow> = agg.into_values().collect();
// Real capital ships (Vessel-matched) first, then by faction, then id.
rows.sort_by(|a, b| {
b.has_vessel
.cmp(&a.has_vessel)
.then(a.faction.cmp(&b.faction))
.then(a.id.cmp(&b.id))
});
rows
}
/// Handles a [`RequestShipRender`]: assembles the ship's part family into one
/// world-space model off-thread and posts it through the shared XPR render path.
#[cfg(not(target_arch = "wasm32"))]
fn handle_ship_render_request(
mut events: EventReader<RequestShipRender>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
mut xpr_load: ResMut<XprLoadState>,
mut browser: ResMut<FileBrowserState>,
) {
let Some(req) = events.read().last() else {
return;
};
let (file, id, external, label) =
(req.file.clone(), req.id.clone(), req.external, req.label.clone());
// Supersede any in-flight XPR/stage decode, exactly like a file selection.
xpr_load.generation = xpr_load.generation.wrapping_add(1);
if let Some(prev) = xpr_load.cancel.take() {
prev.store(true, Ordering::Relaxed);
}
let cancel = Arc::new(AtomicBool::new(false));
xpr_load.cancel = Some(cancel.clone());
let generation = xpr_load.generation;
browser.loading = true;
let source = iso_state.source_kind.clone();
let sender = channels.sender.clone();
std::thread::spawn(move || {
let prepared = build_ship_model(&source, &file, &id, external, &label, &cancel);
let _ = sender.send(IsoLoaderMsg::XprPrepared {
generation,
prepared: Box::new(prepared),
});
});
}
/// Read a stage container, resolve the ship's scene-graph placement, decode only
/// the referenced parts, transform each into its ship-local pose, and assemble
/// them into a single world-space model (reusing the XPR model prepare path).
#[cfg(not(target_arch = "wasm32"))]
fn build_ship_model(
source: &SourceKind,
file: &str,
id: &str,
external: bool,
label: &str,
cancel: &Arc<AtomicBool>,
) -> PreparedXpr {
use sylpheed_formats::mesh::Xbg7Model;
let bytes = match read_source_file(source, file) {
Ok(b) => b,
Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")),
};
let should_cancel = || cancel.load(Ordering::Relaxed);
// Scene-graph placement: which resources to draw and where (bridge fore,
// engines aft, hardpoints on their frames).
let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external);
if placed.is_empty() {
return PreparedXpr::Info(format!("No parts found for {label}."));
}
// Decode each distinct referenced resource once.
let wanted: std::collections::HashSet<String> =
placed.iter().map(|p| p.resource.clone()).collect();
let base = Xbg7Model::models_named(&bytes, &wanted, &should_cancel);
if should_cancel() {
return PreparedXpr::Cancelled;
}
if base.is_empty() {
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
}
// One transformed model per placement: bake the part's world pose into its
// vertices (positions by the full affine, normals by the rotation) so the
// shared prepare path can draw it in place. A resource placed at several
// hardpoints yields several posed copies. The name is kept so `material_groups`
// still finds the part's textures.
let rot = |m: &[[f32; 3]; 3], n: &[f32; 3]| {
[
m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2],
m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2],
m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2],
]
};
let mut models: Vec<Xbg7Model> = Vec::with_capacity(placed.len());
for p in &placed {
let Some(src) = base.iter().find(|m| m.name == p.resource) else {
continue;
};
let mut m = src.clone();
for sub in &mut m.meshes {
for v in &mut sub.positions {
*v = p.apply(*v);
}
for nrm in &mut sub.normals {
*nrm = rot(&p.m, nrm);
}
}
models.push(m);
}
if models.is_empty() {
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
}
// Assemble, then relabel with the ship's display name.
match prepare_ship(&bytes, &models) {
PreparedXpr::Model {
meshes,
materials,
focus,
radius,
min_radius,
max_radius,
info,
..
} => PreparedXpr::Model {
meshes,
materials,
focus,
radius,
min_radius,
max_radius,
name: label.to_string(),
info,
},
other => other,
}
}
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
/// enumerates the voice clips off-thread.
#[cfg(not(target_arch = "wasm32"))]

View File

@@ -10,10 +10,11 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
IsoLoaderSystemSet,
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
};
use crate::ViewerState;
use sylpheed_formats::SubLang;
@@ -25,6 +26,8 @@ impl Plugin for ViewerUiPlugin {
app.insert_resource(FileBrowserState::default());
// Run after the iso_loader chain so we see the frame's final state.
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
}
}
@@ -136,6 +139,8 @@ struct UiEvents<'w> {
subtitles: EventWriter<'w, RequestSubtitles>,
audio: EventWriter<'w, RequestAudio>,
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>,
}
fn draw_viewer_ui(
@@ -193,6 +198,18 @@ fn draw_viewer_ui(
}
ui.close_menu();
}
if ui.button("🗃 Game Data…").clicked() {
// The Game Data window (its own system) reads this event to
// open + lazily decode the tables.
events.game_data.send_default();
ui.close_menu();
}
if ui.button("🚀 Ships…").clicked() {
// The Ships window (its own system) reads this event to open +
// lazily scan the stage containers for assemblable ships.
events.ships.send_default();
ui.close_menu();
}
});
ui.menu_button("Help", |ui| {
@@ -1280,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, "Show external parts");
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

@@ -20,6 +20,7 @@ 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 |
## Functions / code paths

View File

@@ -0,0 +1,105 @@
# Capital-ship part placement — runtime capture (ground truth)
**Status:** pipeline working end-to-end for one ship (`e106` ADAN Destroyer);
correlator recovers exact ship-relative transforms. Not yet baked into the viewer.
## Problem
Capital ships ship as **split XBG7 parts** (hull bodies, bridge, engines, turrets)
in `hidden/resource3d/Stage_SNN.xpr`. Reconstructing the whole ship needs each
part's placement. Static reverse-engineering of the composite scene graph
(`e_rou_<id>`, `GN_*` frames) got the **hull right but external parts only
approximately** — a `GN_*` frame is the mount *pivot*, and the real outboard /
rotational offset lives in a detail sub-rig or in runtime code not recoverable
statically. See `sylpheed-formats::ship::assemble_ship` (the static best-effort).
## Finding
The **guest vertex buffer holds LOCAL coordinates** — byte-identical to the
`.xpr` (verified: `e106_bdy_04`'s first vertex `(-20.0000,153.1539,45.9997)`
matches the decode exactly). So parts are placed **entirely in the vertex
shader**; the buffer carries no placement. (Contrast: static *stage* geometry is
pre-transformed into world space and byte-matches the `.xpr` at file offsets —
that's a different case.)
The per-part transform is in the **vertex-shader float constants**. For the ship
shader (`vs=0xC7F781F4C1D58054`), constants `c0..c2` are the **WorldViewProjection**
matrix rows:
- Their norms are `(sx, sy, 1)` = the projection x/y scales; `sy/sx ≈ 1.78 = 16:9`
aspect. Dividing each row by its norm gives the rigid **WorldView** (verified:
normalized rows orthonormal to 1e-4, `det = +1`).
- The camera View cancels when expressing every part 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. Applying `rel_p` to part `p`'s local geometry
assembles the ship exactly, in the reference part's frame.
(`c4..c14` also vary per part — likely a normal matrix / a second WVP variant /
WorldView split; not needed, `c0..c2` suffice.)
## Capture instrumentation
Branch **`capture-ship-placement`** in the `xenia-canary-native` worktree (off the
`instrument-current`-descended native branch, which already has GPU `log_draws`).
- **F10** in the emulator window → `xe::gpu::RequestShipCaptureFrame()`.
- Dumps the next ≤8000 draws (de-duped by vertex-buffer address) to
**`xenia_ship_capture.log`** in the binary's dir
(`build/bin/Linux/Release/`). Per draw:
```
DRAW vbase=0x… stride=… vcount=… indices=… prim=… vs=0x…
pos: (x,y,z) … up to 64 LOCAL vertex positions (== .xpr)
vsconst base=N: c0=(x,y,z,w) c1=… … first 48 VS float4 constants
```
- Files: `src/xenia/gpu/command_processor.{cc,h}` (`CaptureShipDrawForRE`,
`RequestShipCaptureFrame`), `src/xenia/app/emulator_window.cc` (F10 key).
- Run: `run-canary-native.sh` (HW Vulkan, interactive). Play into the mission,
frame the ship side-on, press F10.
## Correlator
`sylpheed-formats/examples/correlate_capture.rs`:
```
SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \
<capture.log> <Stage_SNN> <ship_id> [ref_part_substr]
```
Parses the log, decodes the ship's base parts, matches each part to a draw by
**vertex count** (unique per part), recovers WorldView from `c0..c2`, and prints
each part's ship-relative transform (reference-part frame) + assembled extent.
## Validated result — `e106` ADAN Destroyer (Mission 1 / Stage_S01, side view)
Vertex-count → part map (all matched exactly): bdy_01/02 = 1261, bdy_03 = 815,
bdy_04 = 1633, eng_01 = 583, eng_02 = 491, wep_02_01 = 1002, brg_01 = 202.
Ship-relative placement (reference = `bdy_04`, aft hull):
| part | T (ship-relative) | note |
|---|---|---|
| bdy_04 | (0, 0, 0) | reference, aft hull |
| eng_01 | (131, 133, 132) | **aft** (Z), starboard |
| eng_02 | (0, 49, 140) | aft, centre |
| bdy_01 | (**264**, 150, 1165) | **port** hull, forward |
| bdy_02 | (**+264**, 150, 1165) | **starboard** hull, forward |
| bdy_03 | (0, 35, 1076) | forward keel/spine |
| wep_02_01 | (0, 49, 1009) | forward weapon |
Assembled extent **872 × 670 × 2181** (a coherent ~2181-long destroyer).
**Key wins vs static:** `bdy_01`/`bdy_02` are a **port/starboard pair** (X = ∓264),
which static had overlapping at one point; engines land correctly aft; the ship is
not the 1894-tall tower the static Y put out.
**Missing:** `brg_01` (bridge, vcount 202) was **culled/occluded** at that camera
angle (0 draws) — needs a second capture framing the superstructure.
## Next steps
1. **Bridge:** re-capture with the bridge visible → fills `brg_01`.
2. **Bake:** promote the correlator to a module; emit a per-ship placement table
(`ship_id → [(part, R, T)]`) as a checked-in data file; have the viewer's
`build_ship_model` prefer the captured table over `assemble_ship` when present.
3. **Other ships:** same capture for the cruiser (`e105`, Stage_S02), ACROPOLIS
(`f101`), TCAF cruiser/destroyer (`f105`/`f106`). One side-on F10 each.
4. **Turrets:** the shared `e3NN`/`e4NN` gun models appear as their own draws in the
capture too — correlate them to place turrets (static never resolved these).