Compare commits
184 Commits
95de29f290
...
auto/re-ca
| Author | SHA1 | Date | |
|---|---|---|---|
| eac92c3e44 | |||
| 8b10c0451f | |||
| ae5f322c03 | |||
| 0cb81b6200 | |||
| f6974ff3f0 | |||
| 6ebbbeff65 | |||
| 4dbd4f6cef | |||
| fb1405b13f | |||
| 86fcfcc8b0 | |||
| 1efc3f567d | |||
| 6d0bdf0179 | |||
| 0221217968 | |||
| bf6825e278 | |||
| 14d5ed54d1 | |||
| d61baddaf0 | |||
| d1b50dc1e6 | |||
| 3b35c48fce | |||
| 4cdbbc2d48 | |||
| 27577538cb | |||
| 92d36ec10f | |||
| 9fd5dcd37d | |||
| 0cf9220d24 | |||
| 0776beb6f4 | |||
| 33dc12c948 | |||
| 41b59faf5f | |||
| 6d92e6c114 | |||
| 1b618f500f | |||
| 7cd9fb8e0b | |||
| 19b338d9d8 | |||
| d674cddad4 | |||
| 0434afa6a6 | |||
| 2ed475868b | |||
| be42e998bc | |||
| 71adafe7f7 | |||
| a3868fab5b | |||
| bab2f9e6ca | |||
| f4f3bcb20a | |||
| 892f1e3979 | |||
| 3322202732 | |||
| a836f8e9ea | |||
| 4d8c1b9457 | |||
| 33a7bf836b | |||
| c79c0f6ccf | |||
| e8434541f2 | |||
| 1d8b783d26 | |||
| cdaeac8e1b | |||
| 0625976ed7 | |||
| 095816834a | |||
| 27fd9f186f | |||
| 85ad540a7e | |||
| f340815db6 | |||
| 9d33345560 | |||
| 2f0ce19f68 | |||
| f835705b48 | |||
| ee2b98341d | |||
| 6634d79c2e | |||
| c9c98cf942 | |||
| db8104ab99 | |||
| bb0c94c0ce | |||
| fd34d350f2 | |||
| 55e1fe3783 | |||
| 7f1ac41563 | |||
| d83bdd3b68 | |||
| 1815490d01 | |||
| b58f42e46d | |||
| df7a10749a | |||
| 845b63d7dd | |||
| dde52c02e7 | |||
| 51723af59a | |||
| 76a433f75c | |||
| 26f1bdf6fd | |||
| 3245aca8cb | |||
| aa82d293a9 | |||
| 6d96446735 | |||
| f655c90b0c | |||
| aedad811bc | |||
| 64d372c7e8 | |||
| 0f9c95c52e | |||
| 343a26ecc0 | |||
| 27a0701e0d | |||
| f18d5919f7 | |||
| d660705c47 | |||
| 7c514e3a57 | |||
| b5a4764567 | |||
| f34280476d | |||
| 140856b3f9 | |||
| 4097e8503f | |||
| c9916bcb41 | |||
| c5a5065389 | |||
| 5b8b5e0ab1 | |||
| d9c69c566c | |||
| d9ffc223bc | |||
| ac10d3bd19 | |||
| 9228255bfa | |||
| 35e2d1067f | |||
| de357ffdc9 | |||
| f65387143e | |||
| dd8770826b | |||
| 8814df843b | |||
| 8b7c9da067 | |||
| a7905d0f6b | |||
| 671891f9bf | |||
| 38f58ea559 | |||
| bb9e75fe6a | |||
| df45a1986b | |||
| 98b3cf56ac | |||
| bc42c0970e | |||
| 2da9b8a9b8 | |||
| eac3435cc7 | |||
| 6e0fbe52a6 | |||
| 841156a95d | |||
| dc97dc215d | |||
| f5f95be0a1 | |||
| f88f4f6847 | |||
| 6ebf2ba00a | |||
| f0ceda1c6f | |||
| 5cc995e864 | |||
| 882dbc2ae2 | |||
| 398e8ae0af | |||
| 5c3e3dfe47 | |||
| f4d59c5783 | |||
| 530555de9f | |||
| 69b4a2e569 | |||
| ca500c171e | |||
| 3d9f21f030 | |||
| 1d4b35df0f | |||
|
|
bbfeb1c387 | ||
|
|
695351dfc4 | ||
|
|
4821ba7fea | ||
|
|
58f421d896 | ||
|
|
9f41fe08e9 | ||
|
|
95ac545b2b | ||
|
|
ab8f5307ff | ||
|
|
76f463b611 | ||
|
|
3f6efadf9e | ||
|
|
402985adbf | ||
|
|
c277e42c92 | ||
|
|
8ee3bfbcef | ||
|
|
91fb022caf | ||
|
|
93d6534efe | ||
| 41c34dc3ca | |||
| 2e9903d0fc | |||
| 4623387f6c | |||
| 72365b217a | |||
| 22d2518847 | |||
| 1ee2d5e406 | |||
| 2196ac112d | |||
| 2fa4c33d1a | |||
| 6a41525c41 | |||
| 9daac6f1f0 | |||
| e3f3d55d29 | |||
| 9a86a09f8d | |||
| aaa4ea60b3 | |||
| 621b9dd6e9 | |||
| 07eff85819 | |||
| c3b5f3f401 | |||
| ba33c533da | |||
|
|
c067761e56 | ||
|
|
ea32e77e45 | ||
|
|
31f2637e6c | ||
|
|
737b61242e | ||
|
|
015e49ac8a | ||
|
|
d9442a2106 | ||
|
|
4efc0278b1 | ||
|
|
c6ca5ce9e7 | ||
|
|
a68405216f | ||
|
|
79c78cc7e1 | ||
|
|
8c8e2dbeb4 | ||
|
|
a8b5691d1c | ||
|
|
720a5fec27 | ||
|
|
cacb576ecd | ||
|
|
df724b6bcb | ||
|
|
d41b065813 | ||
|
|
f2991b61ab | ||
|
|
f5182f9649 | ||
|
|
551291f869 | ||
|
|
6e704291be | ||
|
|
2930770060 | ||
|
|
d73601fe45 | ||
|
|
99f2ef8871 | ||
|
|
161359e151 | ||
|
|
053aa81953 | ||
|
|
ed5c2f24c3 | ||
|
|
eee1607e1b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ Thumbs.db
|
||||
|
||||
# Trunk build output
|
||||
dist/
|
||||
__pycache__/
|
||||
|
||||
26
crates/sylpheed-formats/data/ship_placements.txt
Normal file
26
crates/sylpheed-formats/data/ship_placements.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
# Capital-ship part placements — runtime-captured ground truth (see
|
||||
# docs/re/ship-placement-runtime-capture.md and src/ship_capture.rs).
|
||||
#
|
||||
# Populate with:
|
||||
# SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
||||
# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit
|
||||
# then paste the emitted `ship …` block(s) below. The viewer prefers a ship's
|
||||
# entry here over the static assembler when present.
|
||||
#
|
||||
# Per placement line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>
|
||||
#
|
||||
|
||||
ship e106 ref=e106_bdy_04
|
||||
e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667
|
||||
# 2026-08-12: this rotation was diag(-1,1,1) - the X-flip `correlate` bakes in
|
||||
# when a draw's buffer is the mirror of the decoded one. With distinct anchor
|
||||
# assignment the twins now decode to their OWN buffers (the container carries
|
||||
# both halves), so the mirror lives in the data and the placement is plain
|
||||
# identity. Re-emitting from the capture agrees: `1 0 0 0 1 0 0 0 1 264.04343`.
|
||||
e106_bdy_02 1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
|
||||
e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878
|
||||
e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0
|
||||
e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748
|
||||
e106_eng_01 0.8659965 -0.50002277 0 0.4829627 0.83651507 0.25885975 -0.12941553 -0.22417325 0.96592265 131.2386 -133.06625 -132.06075
|
||||
e106_eng_02 1 0 0 0 1 0 0 0 1 0.0013684053 -49.088192 -139.47827
|
||||
e106_wep_02_01 1 0 0 0 1 0 0 0 1 0.000040663195 49.126797 1009.06744
|
||||
177
crates/sylpheed-formats/data/unit_definition_layout.txt
Normal file
177
crates/sylpheed-formats/data/unit_definition_layout.txt
Normal file
@@ -0,0 +1,177 @@
|
||||
# Unit / vessel definition object layout — offset, field, kind.
|
||||
#
|
||||
# Read out of the title's own loader, not inferred: `sub_82341A20` builds every
|
||||
# key as `addi r4, r30, -N` with `r30 = 0x82088f94`, so the field NAME for each
|
||||
# store is a string in the executable image; pairing each key with the first
|
||||
# store after its accessor call gives the offset. The accessor for floats is
|
||||
# `sub_822FC5A8`, which returns 0.0 when the pool has no value for the key.
|
||||
#
|
||||
# Verified against live objects dumped from a running Stage 02: 406 field values
|
||||
# agree with the disc records and 0 disagree, over 11 objects spanning both the
|
||||
# UNIT and VESSEL schemas (see docs/re/live-unit-definitions.md).
|
||||
#
|
||||
# Two conventions matter for a reimplementation:
|
||||
# * angle fields are DEGREES on disc and RADIANS in this object;
|
||||
# * a field the disc record omits is left at the accessor's miss value (0.0 for
|
||||
# floats) unless something later derives it — `Size_Y` takes `Size_X`.
|
||||
#
|
||||
# offset kind field
|
||||
24 str Model
|
||||
28 str CollisionModel
|
||||
48 f32 Size_X
|
||||
52 f32 Size_Y
|
||||
56 f32 Size_Z
|
||||
64 f32 Color_R
|
||||
68 f32 Color_G
|
||||
72 f32 Color_B
|
||||
80 f32 Size_Radius
|
||||
84 f32 HP
|
||||
88 f32 HQRatio
|
||||
92 f32 ShieldRatio
|
||||
96 f32 ThrusterRatio
|
||||
100 word IsDestructible
|
||||
104 str NamePlate
|
||||
112 word MountedShieldGenerator
|
||||
116 f32 ResistanceToOptics
|
||||
120 f32 ResistanceToShell
|
||||
124 f32 ResistanceToExplosion
|
||||
128 f32 ResistanceToPlayer
|
||||
132 f32 ResistanceParalyze
|
||||
136 str HUDMarkerID
|
||||
156 f32 MinimumVelocity
|
||||
160 f32 MaximumVelocity
|
||||
164 f32 CruisingVelocity
|
||||
168 f32 Acceleration
|
||||
172 f32 Deceleration
|
||||
176 f32 AV_PitchPlus_Max
|
||||
180 f32 AV_PitchPlus_Min
|
||||
184 f32 AA_PitchPlus_Max
|
||||
188 f32 AA_PitchPlus_Min
|
||||
192 f32 AV_PitchMinus_Max
|
||||
196 f32 AV_PitchMinus_Min
|
||||
200 f32 AA_PitchMinus_Max
|
||||
204 f32 AA_PitchMinus_Min
|
||||
208 f32 AV_Yaw_Max
|
||||
212 f32 AV_Yaw_Min
|
||||
216 f32 AA_Yaw_Max
|
||||
220 f32 AA_Yaw_Min
|
||||
224 f32 AV_Roll_Max
|
||||
228 f32 AV_Roll_Min
|
||||
232 f32 AA_Roll_Max
|
||||
236 f32 AA_Roll_Min
|
||||
248 f32 SideThrustVelocity_Max
|
||||
252 f32 SideThrustAcceleration
|
||||
256 f32 MaximumBank_Normal
|
||||
260 f32 YawDragFactor
|
||||
264 f32 PitchDragFactor
|
||||
268 f32 RollDragFactor
|
||||
272 f32 DragFactorThreshold
|
||||
276 f32 ArterBurner_Vc
|
||||
280 f32 ReverseThrust_Vc
|
||||
284 f32 ArterBurner_Acc
|
||||
288 f32 ReverseThrust_Acc
|
||||
292 f32 AccPitchFactor
|
||||
296 f32 DecPitchFactor
|
||||
300 f32 AV_AxisMode_Max
|
||||
304 f32 AV_AxisMode_Min
|
||||
308 f32 AA_AxisMode_Max
|
||||
312 f32 AA_AxisMode_Min
|
||||
316 f32 PowerCutConsumeShield
|
||||
320 f32 PowerCutDeceleration
|
||||
324 f32 AB_ConsumeShield_Begin
|
||||
328 f32 AB_ConsumeShield
|
||||
332 f32 AB_AV_PitchPlus
|
||||
336 f32 AB_AA_PitchPlus
|
||||
340 f32 AB_AV_PitchMinus
|
||||
344 f32 AB_AA_PitchMinus
|
||||
348 f32 AB_AV_Yaw
|
||||
352 f32 AB_AA_Yaw
|
||||
356 f32 AB_AV_Roll
|
||||
360 f32 AB_AA_Roll
|
||||
364 word SideRoll
|
||||
368 f32 SideRoll_Time
|
||||
372 f32 SideRoll_Length
|
||||
376 word BarrelRoll
|
||||
380 f32 BarrelRoll_CountMinimum
|
||||
384 f32 BarrelRoll_CountMaximum
|
||||
388 f32 BarrelRoll_Time
|
||||
392 f32 BarrelRoll_Radius
|
||||
396 word TurnAttack
|
||||
400 f32 TurnAttack_CutoffRatio
|
||||
404 f32 TurnAttack_DoubleRatio
|
||||
408 f32 CutoffTimeMin
|
||||
412 f32 CutoffTimeMax
|
||||
416 f32 TurnAttack_DoubleTimeMin
|
||||
420 f32 TurnAttack_DoubleTimeMax
|
||||
424 word TurnAway
|
||||
428 f32 Turn_AngularVelocity
|
||||
432 f32 TurnAway_Time_Minimum
|
||||
436 f32 TurnAway_Time_Maximum
|
||||
440 word BoostAway
|
||||
444 f32 BoostAway_Time_Minimum
|
||||
448 f32 BoostAway_Time_Maximum
|
||||
452 word HoldPosition
|
||||
456 f32 HoldPosition_LengthMin
|
||||
460 f32 HoldPosition_LengthMax
|
||||
464 f32 HoldPosition_MinimumTime
|
||||
468 f32 HoldPosition_MaximumTime
|
||||
472 f32 HoldPosition_SideRatio
|
||||
476 f32 HoldPosition_BackRatio
|
||||
480 f32 HoldPosition_CutoffRatio
|
||||
484 f32 HoldPosition_CancelTime
|
||||
488 word Slalom
|
||||
492 f32 Slalom_CutoffRatio
|
||||
496 f32 Slalom_TurnCount_Min
|
||||
500 f32 Slalom_TurnCount_Max
|
||||
504 word Through
|
||||
508 f32 Through_CutoffRatio
|
||||
512 f32 Through_AngleMinimum
|
||||
516 f32 Through_AngleMaximum
|
||||
520 f32 Through_Time1Max
|
||||
524 f32 Through_Time1Min
|
||||
528 f32 Through_Time2Max
|
||||
532 f32 Through_Time2Min
|
||||
536 f32 Through_LengthMinimum
|
||||
540 f32 Through_LengthMaximum
|
||||
544 word SolidCutoff
|
||||
548 f32 SolidCutoff_Ratio
|
||||
552 f32 SolidCutoff_LengthMin
|
||||
556 f32 SolidCutoff_LengthMax
|
||||
560 f32 HomingResistAdjustment
|
||||
564 f32 UsingChaffRatio
|
||||
568 f32 MaxValue
|
||||
572 f32 ChargeDelay
|
||||
576 f32 ChargeDelay_Break
|
||||
580 f32 ChargeSpeed
|
||||
584 f32 Delay
|
||||
588 f32 DelayAdjustment
|
||||
596 str LowerHPFxModel
|
||||
604 str ExplosionFxModel
|
||||
612 str DestroyMotionName
|
||||
620 str ExplosionMotionName
|
||||
624 f32 DestroyMotionTime
|
||||
628 f32 DryMass
|
||||
632 f32 GrossMass
|
||||
636 word Thruster
|
||||
640 word SideThruster
|
||||
644 word ExplosionSE
|
||||
648 word JumpIn
|
||||
652 word JumpOut
|
||||
656 word ShipEnvironmentSE
|
||||
660 word LowerHPSE
|
||||
664 f32 LowerHPThresholdRatio
|
||||
668 f32 SELength
|
||||
672 f32 RadarRange
|
||||
676 f32 FCSRange
|
||||
680 f32 FiringRange
|
||||
684 word MountedFCS
|
||||
692 f32 AttackVesselPoint
|
||||
696 f32 AttackCraftPoint
|
||||
700 f32 DefencePoint
|
||||
736 str ShieldRecoverEffectName
|
||||
740 str ShieldHitEffectName
|
||||
744 str JumpIn
|
||||
748 str JumpOut
|
||||
752 str FadeIn
|
||||
756 str FadeOut
|
||||
760 str Effect_Paralyze
|
||||
9
crates/sylpheed-formats/examples/arsenal.rs
Normal file
9
crates/sylpheed-formats/examples/arsenal.rs
Normal 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<_>>());
|
||||
}
|
||||
}
|
||||
19
crates/sylpheed-formats/examples/battle.rs
Normal file
19
crates/sylpheed-formats/examples/battle.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
22
crates/sylpheed-formats/examples/bench_ships.rs
Normal file
22
crates/sylpheed-formats/examples/bench_ships.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Time repeated per-ship decodes of one container, as the viewer does.
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let names = sylpheed_formats::mesh::xbg7_resource_names(&bytes);
|
||||
let ids: Vec<String> = {
|
||||
let mut v: Vec<String> = names.iter().filter(|n| is_base_part(n))
|
||||
.filter_map(|n| ship_id_of(n).map(|s| s.to_string())).collect();
|
||||
v.sort(); v.dedup(); v.truncate(5); v
|
||||
};
|
||||
for id in &ids {
|
||||
let want: HashSet<String> = names.iter()
|
||||
.filter(|n| ship_id_of(n) == Some(id.as_str())).cloned().collect();
|
||||
let t = Instant::now();
|
||||
let got = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
println!("{id}: {} models in {:?}", got.len(), t.elapsed());
|
||||
}
|
||||
}
|
||||
123
crates/sylpheed-formats/examples/better_home.rs
Normal file
123
crates/sylpheed-formats/examples/better_home.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Does a resource have a CLEANER home in its container than the one we picked?
|
||||
//!
|
||||
//! After the pad-scoring fix, only two resources on the disc still decode to an
|
||||
//! index run with degenerate triangles — `e201_bdy_03_m` (2 containers) and
|
||||
//! `_rou_f402_dead` (9). Degeneracy says the run does not fit the pool, so either
|
||||
//! the vertex block is wrong or the candidate list never offered the right one.
|
||||
//! This walks every candidate vertex-run start for the resource's declaration and
|
||||
//! scores each `(start, pad)` the way the anchor now does — degenerate triangles
|
||||
//! first, then winding, plus coverage — so the answer is one of:
|
||||
//! * a strictly cleaner candidate exists (the selection is at fault),
|
||||
//! * several are equally clean (genuinely ambiguous), or
|
||||
//! * nothing is clean (the block is not in the candidate list at all).
|
||||
//!
|
||||
//! Usage: better_home <container.xpr> <resource-name>
|
||||
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts, Xbg7Model};
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> u32 {
|
||||
((b[at] as u32) << 8) | b[at + 1] as u32
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let name = &a[2];
|
||||
|
||||
let Some((markers, stride)) = debug_resource_params(&bytes, name) else {
|
||||
eprintln!("no such XBG7 resource: {name}");
|
||||
std::process::exit(1);
|
||||
};
|
||||
let (vc, ic) = markers[0];
|
||||
println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len());
|
||||
|
||||
// Where did the decoder put it?
|
||||
let ours = Xbg7Model::stage_models(&bytes)
|
||||
.into_iter()
|
||||
.find(|m| m.name == *name)
|
||||
.and_then(|m| m.meshes.first().and_then(|s| s.vbuf_offset));
|
||||
println!("our anchor: {ours:?}");
|
||||
|
||||
let starts = debug_vertex_run_starts(&bytes, stride);
|
||||
println!("{} candidate vertex-run starts for stride {stride}", starts.len());
|
||||
|
||||
// Score every (start, pad): degenerate triangles, winding against the stored
|
||||
// normals, and whether the run covers the pool exactly.
|
||||
let mut rows: Vec<(usize, f32, usize, usize, usize, bool)> = Vec::new(); // degen, wind, start, pad, max_idx, covered
|
||||
for &vb in &starts {
|
||||
for pad in 0..=3usize {
|
||||
if vb < ic * 2 + pad {
|
||||
continue;
|
||||
}
|
||||
let ib = vb - ic * 2 - pad;
|
||||
if ib + ic * 2 > bytes.len() || vb + vc * stride > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
let idx: Vec<u32> = (0..ic).map(|k| be16(&bytes, ib + k * 2)).collect();
|
||||
let max_idx = *idx.iter().max().unwrap_or(&0) as usize;
|
||||
if max_idx >= vc {
|
||||
continue; // out of range — not a candidate at all
|
||||
}
|
||||
let pos: Vec<[f32; 3]> = (0..vc)
|
||||
.map(|v| {
|
||||
let at = vb + v * stride;
|
||||
[
|
||||
f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()),
|
||||
f32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()),
|
||||
f32::from_be_bytes(bytes[at + 8..at + 12].try_into().unwrap()),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) {
|
||||
continue;
|
||||
}
|
||||
let mut degen = 0usize;
|
||||
let (mut agree, mut counted) = (0usize, 0usize);
|
||||
for t in idx.chunks_exact(3) {
|
||||
let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
if x == y || y == z || x == z {
|
||||
degen += 1;
|
||||
continue;
|
||||
}
|
||||
// Winding needs normals; use the geometric centroid normal as a
|
||||
// stand-in so this stays declaration-agnostic: a consistent mesh
|
||||
// has all faces pointing away from the centroid on a convex-ish
|
||||
// hull. Weak, so degeneracy leads the sort.
|
||||
let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]];
|
||||
let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]];
|
||||
let f = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
let cx: [f32; 3] = {
|
||||
let mut c = [0.0f32; 3];
|
||||
for p in &pos {
|
||||
for k in 0..3 {
|
||||
c[k] += p[k] / pos.len() as f32;
|
||||
}
|
||||
}
|
||||
c
|
||||
};
|
||||
let out = [pos[x][0] - cx[0], pos[x][1] - cx[1], pos[x][2] - cx[2]];
|
||||
counted += 1;
|
||||
if f[0] * out[0] + f[1] * out[1] + f[2] * out[2] > 0.0 {
|
||||
agree += 1;
|
||||
}
|
||||
}
|
||||
let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
|
||||
rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc));
|
||||
}
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1)));
|
||||
println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len());
|
||||
for (d, w, vb, pad, mx, cov) in rows.iter().take(12) {
|
||||
let mark = if Some(*vb) == ours { " <-- ours" } else { "" };
|
||||
println!(
|
||||
" vb 0x{vb:07X} pad {pad} degen {d:>4} wind {w:.3} max_idx {mx}/{} {}{mark}",
|
||||
vc - 1,
|
||||
if *cov { "covers" } else { "SHORT" }
|
||||
);
|
||||
}
|
||||
let clean = rows.iter().filter(|r| r.0 == 0 && r.5).count();
|
||||
println!("\n{clean} candidates are degenerate-free AND cover the pool exactly");
|
||||
}
|
||||
53
crates/sylpheed-formats/examples/bounds_in_descriptor.rs
Normal file
53
crates/sylpheed-formats/examples/bounds_in_descriptor.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Does a resource's DESCRIPTOR carry its bounding box?
|
||||
//!
|
||||
//! The last cross-container disagreements are 24-vertex bound boxes swapping
|
||||
//! identities; no anchoring rule can pin them (see docs). If the descriptor
|
||||
//! states the box, that is the missing information. This decodes the resource,
|
||||
//! takes the box its geometry actually spans, and searches the descriptor for
|
||||
//! those float values.
|
||||
//!
|
||||
//! Usage: bounds_in_descriptor <container.xpr> <resource>...
|
||||
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let want: HashSet<String> = a[2..].iter().cloned().collect();
|
||||
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for s in &m.meshes {
|
||||
for q in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(q[k]);
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { continue };
|
||||
println!(
|
||||
"{} descriptor 0x{d0:x}..0x{d1:x} ({} bytes), box lo{:?} hi{:?}",
|
||||
m.name,
|
||||
d1 - d0,
|
||||
lo.map(|v| v.round()),
|
||||
hi.map(|v| v.round())
|
||||
);
|
||||
// Where in the descriptor does each bound value appear (±0.01)?
|
||||
let targets: Vec<(&str, f32)> = vec![
|
||||
("lo.x", lo[0]), ("lo.y", lo[1]), ("lo.z", lo[2]),
|
||||
("hi.x", hi[0]), ("hi.y", hi[1]), ("hi.z", hi[2]),
|
||||
];
|
||||
for (label, v) in targets {
|
||||
let mut at: Vec<usize> = Vec::new();
|
||||
let mut o = d0;
|
||||
while o + 4 <= d1 {
|
||||
let f = f32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||
if (f - v).abs() <= 0.01 * (1.0 + v.abs()) {
|
||||
at.push(o - d0);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
println!(" {label:5} {v:10.3} at descriptor offsets {:x?}", &at[..at.len().min(6)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
crates/sylpheed-formats/examples/campaign.rs
Normal file
24
crates/sylpheed-formats/examples/campaign.rs
Normal 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 (S01–S16) ═══");
|
||||
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"));
|
||||
}
|
||||
}
|
||||
222
crates/sylpheed-formats/examples/capture_ib_truth.rs
Normal file
222
crates/sylpheed-formats/examples/capture_ib_truth.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! Where does a drawn block's INDEX buffer really live?
|
||||
//!
|
||||
//! Our XBG7 anchor scan only ever *assumes* the layout `[index buffer][vertex
|
||||
//! buffer]` with a pad of at most 3 bytes between them (`anchor_pool_mesh`:
|
||||
//! `ib = vb - idx_count*2 - pad`). Nothing on disc states it, and it is the gate
|
||||
//! that rejected the capture-proven `e106_eng_02_l` block
|
||||
//! (docs/re/captures/…): the block's index data was not where the decoder
|
||||
//! looked. The F10 ship capture was extended on 2026-08-13 to log each draw's
|
||||
//! index-buffer base, count and min/max index value, so the assumption is now
|
||||
//! directly checkable:
|
||||
//!
|
||||
//! * `vbase - ibase` is the real gap in guest memory, and a stage container is
|
||||
//! uploaded contiguously (see `shared_vbase_check`), so the same difference
|
||||
//! holds in the file;
|
||||
//! * `max index vs vcount` says whether a draw covers its whole vertex pool —
|
||||
//! the "buffer not covered" miss class is a *sub-range draw* if it does not.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
|
||||
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn q(v: f32) -> i64 {
|
||||
(v as f64 * 1e4).round() as i64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: capture_ib_truth <container.xpr> <capture.log>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
|
||||
// One entry per (log, vbase): the capture already de-dups per placement, and
|
||||
// a buffer drawn at several transforms has the same index buffer each time.
|
||||
let mut draws: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for log in &a[2..] {
|
||||
let text = std::fs::read_to_string(log).expect("log");
|
||||
for d in parse_capture(&text) {
|
||||
// One entry per (log, vbase, index range): the engine issues SEVERAL
|
||||
// draws over one vertex buffer, each with its own index sub-range, and
|
||||
// it is their UNION that describes the block. (Captures taken before
|
||||
// 2026-08-13 de-dup by (vbase, transform) and so hold only the first
|
||||
// batch — such a log reads as a mysteriously short draw.)
|
||||
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
|
||||
if d.ib.is_some() && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
|
||||
draws.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{} drawn buffers with an index buffer", draws.len());
|
||||
|
||||
// ── Place the drawn buffers in the file: POSITION is f32×3 big-endian at
|
||||
// vertex offset 0, so the dumped positions are a literal byte pattern.
|
||||
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let mut o = 0usize;
|
||||
while o + 12 <= bytes.len() {
|
||||
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
|
||||
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
|
||||
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
let mut deltas: HashMap<i64, usize> = HashMap::new();
|
||||
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new(); // (delta, file offset, draw)
|
||||
for d in &draws {
|
||||
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
|
||||
for dx in -1..=1i64 {
|
||||
for dy in -1..=1i64 {
|
||||
for dz in -1..=1i64 {
|
||||
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
|
||||
for &off in cands {
|
||||
for stride in (12..=64).step_by(4) {
|
||||
let ok = (1..4).all(|j| {
|
||||
let at = off as usize + j * stride;
|
||||
at + 12 <= bytes.len()
|
||||
&& (0..3).all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4)
|
||||
});
|
||||
if ok {
|
||||
let delta = d.vbase as i64 - off as i64;
|
||||
*deltas.entry(delta).or_default() += 1;
|
||||
hits.push((delta, off as usize, d));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((&base_delta, &n)) = deltas.iter().max_by_key(|(_, n)| **n) else {
|
||||
eprintln!("no draw could be placed in this container");
|
||||
std::process::exit(1);
|
||||
};
|
||||
println!("container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)");
|
||||
|
||||
// ── Our decoder's view of the same container.
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
let mut by_off: HashMap<usize, Vec<(String, usize, usize)>> = HashMap::new();
|
||||
for m in &models {
|
||||
for sm in &m.meshes {
|
||||
if let Some(off) = sm.vbuf_offset {
|
||||
by_off
|
||||
.entry(off)
|
||||
.or_default()
|
||||
.push((m.name.clone(), sm.positions.len(), sm.indices.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.len());
|
||||
|
||||
// Declared-but-not-decoded resources, indexed by their first marker's
|
||||
// (vertex, index) counts. A drawn buffer our decoder cannot name is the one
|
||||
// thing a capture can give the residual misses: ground truth for where the
|
||||
// block actually is. Matching on counts is enough to propose an identity —
|
||||
// then `debug_try_anchor` at that offset says which gate rejects it.
|
||||
let decoded_names: std::collections::HashSet<String> =
|
||||
models.iter().map(|m| m.name.clone()).collect();
|
||||
let mut undecoded_by_counts: HashMap<(usize, usize), Vec<String>> = HashMap::new();
|
||||
for n in xbg7_resource_names(&bytes) {
|
||||
if decoded_names.contains(&n) {
|
||||
continue;
|
||||
}
|
||||
if let Some((markers, _)) = debug_resource_params(&bytes, &n) {
|
||||
if let Some(&(v, i)) = markers.first() {
|
||||
undecoded_by_counts.entry((v, i)).or_default().push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The report: one row per drawn BUFFER, aggregating its index batches.
|
||||
let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
|
||||
HashMap::new();
|
||||
for (delta, voff, d) in &hits {
|
||||
if *delta != base_delta {
|
||||
continue;
|
||||
}
|
||||
let e = per_buf.entry(d.vbase).or_insert((*voff, Vec::new(), d.vcount));
|
||||
let ib = d.ib.unwrap();
|
||||
if !e.1.contains(&ib) {
|
||||
e.1.push(ib);
|
||||
}
|
||||
}
|
||||
|
||||
let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize);
|
||||
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut rows: Vec<(usize, String)> = Vec::new();
|
||||
for (_, (voff, ibs, vcount)) in per_buf.iter() {
|
||||
let batches = ibs.len();
|
||||
let total: u32 = ibs.iter().map(|i| i.icount).sum();
|
||||
let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta;
|
||||
let hi = ibs.iter().map(|i| i.ibase + i.icount * 2).max().unwrap() as i64 - base_delta;
|
||||
let umax = ibs.iter().map(|i| i.imax).max().unwrap();
|
||||
let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer
|
||||
let names = by_off.get(voff);
|
||||
let dec_idx = names
|
||||
.and_then(|v| v.iter().find(|(_, p, _)| *p as u32 == *vcount).map(|(_, _, i)| *i as u32));
|
||||
// The decoder's assumption, scored: it expects the whole index buffer at
|
||||
// `vb - 2*idx_count - pad`, pad ≤ 3.
|
||||
let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo);
|
||||
match dec_pad {
|
||||
Some(0) => pad0 += 1,
|
||||
Some(p) if (1..=3).contains(&p) => pad_small += 1,
|
||||
Some(_) => pad_off += 1,
|
||||
None => unnamed += 1,
|
||||
}
|
||||
if umax + 1 == *vcount {
|
||||
cover_exact += 1;
|
||||
} else {
|
||||
cover_short += 1;
|
||||
}
|
||||
match dec_idx {
|
||||
Some(i) if i == total => idx_equal += 1,
|
||||
Some(_) => idx_partial += 1,
|
||||
None => {}
|
||||
}
|
||||
rows.push((
|
||||
*voff,
|
||||
format!(
|
||||
"vb 0x{:07X} v={:<6} batches {:<3} idx {:<6} span {:<7} gap {:<8} decpad {:<7} cover {:<10} {}",
|
||||
voff,
|
||||
vcount,
|
||||
batches,
|
||||
total,
|
||||
hi - lo,
|
||||
gap,
|
||||
dec_pad.map(|p| p.to_string()).unwrap_or_else(|| "?".into()),
|
||||
if umax + 1 == *vcount { "exact".to_string() } else { format!("{}/{}", umax, vcount - 1) },
|
||||
names
|
||||
.map(|v| v
|
||||
.iter()
|
||||
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "))
|
||||
.unwrap_or_else(|| {
|
||||
// Nothing of ours sits here — is it a resource that never
|
||||
// decodes? Propose it by (vertex, index) counts.
|
||||
undecoded_by_counts
|
||||
.get(&(*vcount as usize, total as usize))
|
||||
.map(|v| format!("MISSED? {}", v.join(" ")))
|
||||
.unwrap_or_else(|| "-".into())
|
||||
})
|
||||
),
|
||||
));
|
||||
}
|
||||
rows.sort();
|
||||
for (_, r) in &rows {
|
||||
println!("{r}");
|
||||
}
|
||||
println!("\nplaced {} drawn buffers in this container", rows.len());
|
||||
println!(
|
||||
"DECODER layout assumption — whole index buffer at vb - 2*idx_count - pad: pad 0 {pad0} · pad 1..3 {pad_small} · elsewhere {pad_off} · not decoded here {unnamed}"
|
||||
);
|
||||
println!(
|
||||
"index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}"
|
||||
);
|
||||
println!("vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}");
|
||||
}
|
||||
175
crates/sylpheed-formats/examples/capture_index_bytes.rs
Normal file
175
crates/sylpheed-formats/examples/capture_index_bytes.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Do our decoded triangle indices equal the ones the GPU actually read?
|
||||
//!
|
||||
//! `capture_ib_truth` established *where* a block's index buffer lives and that
|
||||
//! its length matches ours. This asks the stronger question: are the index VALUES
|
||||
//! the same, in the same order? The capture prints the first 24 indices of every
|
||||
//! draw batch verbatim (`idx: …`), and a batch's `ibase` locates it inside the
|
||||
//! block's index buffer — so for each drawn buffer we can line the captured run
|
||||
//! up against `GameMesh::indices` at the right offset and compare element by
|
||||
//! element. That tests the whole index path at once: the anchor, the u16
|
||||
//! big-endian read, the triangle-list interpretation (a strip would disagree
|
||||
//! immediately), and the sub-mesh carve.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>...
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn q(v: f32) -> i64 {
|
||||
(v as f64 * 1e4).round() as i64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: capture_index_bytes <container.xpr> <capture.log>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
|
||||
let mut draws: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for log in &a[2..] {
|
||||
let text = std::fs::read_to_string(log).expect("log");
|
||||
for d in parse_capture(&text) {
|
||||
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
|
||||
if d.ib.map_or(false, |i| i.head_len > 0) && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
|
||||
draws.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{} draw batches with an index head", draws.len());
|
||||
|
||||
// Place each drawn buffer in the file by its dumped positions (see
|
||||
// capture_ib_truth for the method) and keep the modal load constant.
|
||||
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let mut o = 0usize;
|
||||
while o + 12 <= bytes.len() {
|
||||
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
|
||||
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
|
||||
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
let mut deltas: HashMap<i64, usize> = HashMap::new();
|
||||
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new();
|
||||
for d in &draws {
|
||||
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
|
||||
for dx in -1..=1i64 {
|
||||
for dy in -1..=1i64 {
|
||||
for dz in -1..=1i64 {
|
||||
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
|
||||
for &off in cands {
|
||||
for stride in (12..=64).step_by(4) {
|
||||
let ok = (1..4).all(|j| {
|
||||
let at = off as usize + j * stride;
|
||||
at + 12 <= bytes.len()
|
||||
&& (0..3).all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4)
|
||||
});
|
||||
if ok {
|
||||
*deltas.entry(d.vbase as i64 - off as i64).or_default() += 1;
|
||||
hits.push((d.vbase as i64 - off as i64, off as usize, d));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((&base_delta, _)) = deltas.iter().max_by_key(|(_, n)| **n) else {
|
||||
eprintln!("no draw could be placed in this container");
|
||||
std::process::exit(1);
|
||||
};
|
||||
println!("load constant 0x{base_delta:X}");
|
||||
|
||||
// Our decode, indexed by vertex offset. A model may hold several sub-meshes;
|
||||
// compare against the one whose vertex count matches the draw.
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
let mut by_off: HashMap<usize, Vec<(String, usize, Vec<u32>)>> = HashMap::new();
|
||||
for m in &models {
|
||||
for sm in &m.meshes {
|
||||
if let Some(off) = sm.vbuf_offset {
|
||||
by_off
|
||||
.entry(off)
|
||||
.or_default()
|
||||
.push((m.name.clone(), sm.positions.len(), sm.indices.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Where does each buffer's index data START? Take it from the capture, not
|
||||
// from an assumed pad: `capture_ib_truth` established that a buffer's batches
|
||||
// tile its index buffer exactly (sum of counts == our idx_count, span ==
|
||||
// 2*count), so the lowest `ibase` over a buffer's batches IS the block's
|
||||
// index start. Assuming `vb - 2*len` instead is wrong for the buffers that
|
||||
// sit at pad 2 and shifts the whole comparison by one element.
|
||||
let mut ib_start: HashMap<u32, i64> = HashMap::new();
|
||||
for (delta, _, d) in &hits {
|
||||
if *delta != base_delta {
|
||||
continue;
|
||||
}
|
||||
let ibase = d.ib.unwrap().ibase as i64;
|
||||
ib_start.entry(d.vbase).and_modify(|e| *e = (*e).min(ibase)).or_insert(ibase);
|
||||
}
|
||||
|
||||
let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
let mut checked_elems = 0usize;
|
||||
let mut done: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
|
||||
for (delta, voff, d) in &hits {
|
||||
if *delta != base_delta {
|
||||
continue;
|
||||
}
|
||||
let ib = d.ib.unwrap();
|
||||
if !done.insert((d.vbase, ib.ibase)) {
|
||||
continue;
|
||||
}
|
||||
let Some(cands) = by_off.get(voff) else {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
};
|
||||
let Some((name, _, ours)) = cands.iter().find(|(_, p, _)| *p as u32 == d.vcount) else {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
};
|
||||
// Element 0 of our index list is the block's index start, as observed.
|
||||
let ours_start = ib_start[&d.vbase] - base_delta;
|
||||
let batch_off = ib.ibase as i64 - base_delta - ours_start;
|
||||
if batch_off < 0 || batch_off % 2 != 0 {
|
||||
nooverlap += 1;
|
||||
continue;
|
||||
}
|
||||
let first = (batch_off / 2) as usize;
|
||||
let n = (ib.head_len as usize).min(ours.len().saturating_sub(first));
|
||||
if n == 0 {
|
||||
nooverlap += 1;
|
||||
continue;
|
||||
}
|
||||
let mism = (0..n).find(|&k| ours[first + k] != ib.head[k]);
|
||||
checked_elems += n;
|
||||
match mism {
|
||||
None => agree += 1,
|
||||
Some(k) => {
|
||||
disagree += 1;
|
||||
if bad.len() < 8 {
|
||||
bad.push(format!(
|
||||
"{name} vb 0x{:07X} batch@{first}: ours {:?} != captured {:?} (first differs at {k})",
|
||||
voff,
|
||||
&ours[first..first + n.min(12)],
|
||||
&ib.head[..n.min(12)]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"index runs compared: {agree} identical · {disagree} differing · {unmatched} no decoded resource · {nooverlap} batch outside our buffer"
|
||||
);
|
||||
println!("{checked_elems} index elements checked against the GPU");
|
||||
for b in &bad {
|
||||
println!(" MISMATCH {b}");
|
||||
}
|
||||
}
|
||||
43
crates/sylpheed-formats/examples/capture_match.rs
Normal file
43
crates/sylpheed-formats/examples/capture_match.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Identify which stage + ship a capture log came from: match the capture's
|
||||
//! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr.
|
||||
//! cargo run --release --example capture_match -- <capture.log> <resource3d dir>
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let text = std::fs::read_to_string(&a[1]).unwrap();
|
||||
let mut draws = parse_capture(&text);
|
||||
if draws.is_empty() { draws = parse_drawlog(&text); }
|
||||
let caps: HashSet<u32> = draws.iter().map(|d| d.vcount).filter(|v| *v >= 100).collect();
|
||||
eprintln!("{} draws, {} distinct vcounts>=100", draws.len(), caps.len());
|
||||
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&a[2]).unwrap().filter_map(|e| e.ok())
|
||||
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n.starts_with("Stage_") && n.ends_with(".xpr") })
|
||||
.map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
let want: HashSet<String> = names.iter().cloned().collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
// resource -> vcount; group hits by ship-id prefix
|
||||
let mut hits: BTreeMap<String, Vec<(String, u32)>> = BTreeMap::new();
|
||||
for m in &models {
|
||||
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
if vc >= 100 && caps.contains(&(vc as u32)) {
|
||||
let id = m.name.get(..4).unwrap_or("?").to_string();
|
||||
hits.entry(id).or_default().push((m.name.clone(), vc as u32));
|
||||
}
|
||||
}
|
||||
let total: usize = hits.values().map(|v| v.len()).sum();
|
||||
if total >= 3 {
|
||||
println!("== {} : {} matching resources ==", path.file_name().unwrap().to_string_lossy(), total);
|
||||
for (id, v) in &hits {
|
||||
let s: Vec<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect();
|
||||
println!(" {id}: {}", s.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
crates/sylpheed-formats/examples/capture_truth_scan.rs
Normal file
112
crates/sylpheed-formats/examples/capture_truth_scan.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Which container did each captured draw come from, and where in it?
|
||||
//!
|
||||
//! Extends the single-container `--map` check in `shared_vbase_check` to a whole
|
||||
//! `resource3d` directory. For each container it indexes every 4-byte-aligned
|
||||
//! position triple, looks up each draw's first dumped position, confirms the run
|
||||
//! at a fixed stride, and reports the modal `vbase − offset`. A container the
|
||||
//! engine loaded shows one dominant constant; an unrelated one shows noise.
|
||||
//!
|
||||
//! The output is capture-named ground truth for anchors far beyond the one ship
|
||||
//! `Stage_S01` gave us.
|
||||
//!
|
||||
//! Usage: capture_truth_scan <resource3d_dir> <capture.log>...
|
||||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn q(v: f32) -> i64 {
|
||||
(v as f64 * 1e4).round() as i64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let dir = &a[1];
|
||||
let mut draws: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for log in &a[2..] {
|
||||
let text = std::fs::read_to_string(log).expect("log");
|
||||
let mut d = parse_capture(&text);
|
||||
if d.is_empty() {
|
||||
d = parse_drawlog(&text);
|
||||
}
|
||||
for x in d {
|
||||
// One entry per buffer; the logs are already deduped per transform.
|
||||
if x.pos.len() >= 8 && x.vcount >= 20 && seen.insert((log.clone(), x.vbase)) {
|
||||
draws.push(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{} distinct (log, vbase) draws to place", draws.len());
|
||||
|
||||
let mut files: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut placed = 0usize;
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
// Index quantised position triples. Junk floats (NaN/huge) are skipped,
|
||||
// which prunes most of a texture-heavy container.
|
||||
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let mut o = 0usize;
|
||||
while o + 12 <= bytes.len() {
|
||||
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
|
||||
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6
|
||||
{
|
||||
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
let mut deltas: HashMap<i64, Vec<(u32, u32)>> = HashMap::new();
|
||||
for d in &draws {
|
||||
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
|
||||
// ±1 in each axis: the log rounds, our file value may round the
|
||||
// other way at a tie.
|
||||
for dx in -1..=1i64 {
|
||||
for dy in -1..=1i64 {
|
||||
for dz in -1..=1i64 {
|
||||
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
for &off in cands {
|
||||
for stride in (12..=64).step_by(4) {
|
||||
let ok = (1..4).all(|j| {
|
||||
let at = off as usize + j * stride;
|
||||
at + 12 <= bytes.len()
|
||||
&& (0..3).all(|c| {
|
||||
(be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4
|
||||
})
|
||||
});
|
||||
if ok {
|
||||
deltas
|
||||
.entry(d.vbase as i64 - off as i64)
|
||||
.or_default()
|
||||
.push((d.vbase, d.vcount));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut top: Vec<_> = deltas.into_iter().collect();
|
||||
top.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
|
||||
if let Some((delta, hits)) = top.first() {
|
||||
if hits.len() >= 3 {
|
||||
placed += hits.len();
|
||||
println!(
|
||||
"{:<22} base=0x{:<10X} buffers={}",
|
||||
f.file_name().unwrap().to_string_lossy(),
|
||||
delta,
|
||||
hits.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{placed} draws placed in a container");
|
||||
}
|
||||
192
crates/sylpheed-formats/examples/capture_verify.rs
Normal file
192
crates/sylpheed-formats/examples/capture_verify.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! Verify static ship assembly against multi-snapshot, multi-instance F10
|
||||
//! captures: cluster every part's ship-relative transform across ALL ships in
|
||||
//! ALL snapshots, and compare the dominant clusters with `assemble_ship`.
|
||||
//!
|
||||
//! cargo run --release --example capture_verify -- <stage.xpr> <ship_id> <log> [log…]
|
||||
//!
|
||||
//! Every draw is position-validated against the ship's parts (any LOD, set
|
||||
//! based). Each `bdy_04`(-LOD) draw acts as a ship reference; every validated
|
||||
//! part draw within ship range contributes `rel = WV_ref⁻¹ ∘ WV_part`. The true
|
||||
//! per-part placements appear as tight clusters repeated once PER SHIP per
|
||||
//! snapshot; cross-ship pairings scatter and stay below the cluster threshold.
|
||||
use std::collections::HashSet;
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
|
||||
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||
|
||||
type M3 = [[f64; 3]; 3];
|
||||
|
||||
fn transpose(m: &M3) -> M3 {
|
||||
[
|
||||
[m[0][0], m[1][0], m[2][0]],
|
||||
[m[0][1], m[1][1], m[2][1]],
|
||||
[m[0][2], m[1][2], m[2][2]],
|
||||
]
|
||||
}
|
||||
fn mmul(a: &M3, b: &M3) -> M3 {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
fn mv(m: &M3, v: [f64; 3]) -> [f64; 3] {
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let id = &a[2];
|
||||
|
||||
// Part position sets (base + LODs share the local frame) + per-variant vcounts.
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
let base_parts: Vec<String> = names
|
||||
.iter()
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||
for p in &base_parts {
|
||||
for suf in ["_m", "_l", "_d"] {
|
||||
let c = format!("{p}{suf}");
|
||||
if names.contains(&c) {
|
||||
want.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
struct Part {
|
||||
base: String,
|
||||
vcounts: Vec<u32>,
|
||||
pos: Vec<[f32; 3]>, // union of variants
|
||||
}
|
||||
let mut parts: Vec<Part> = Vec::new();
|
||||
for p in &base_parts {
|
||||
let mut vcounts = Vec::new();
|
||||
let mut pos = Vec::new();
|
||||
for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] {
|
||||
if let Some(m) = models.iter().find(|m| m.name == cand) {
|
||||
let mp: Vec<[f32; 3]> =
|
||||
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect();
|
||||
vcounts.push(mp.len() as u32);
|
||||
pos.extend(mp);
|
||||
}
|
||||
}
|
||||
parts.push(Part { base: p.clone(), vcounts, pos });
|
||||
}
|
||||
|
||||
// Validate a draw against a part (direct or X-mirrored), like ship_capture.
|
||||
let validate = |d: &CapturedDraw, p: &Part| -> Option<bool> {
|
||||
if !p.vcounts.contains(&d.vcount) || d.pos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let near = |a: &[f32; 3], b: &[f32; 3]| {
|
||||
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
|
||||
};
|
||||
let (mut direct, mut mirror) = (0usize, 0usize);
|
||||
for q in &d.pos {
|
||||
if p.pos.iter().any(|v| near(q, v)) {
|
||||
direct += 1;
|
||||
}
|
||||
let qm = [-q[0], q[1], q[2]];
|
||||
if p.pos.iter().any(|v| near(&qm, v)) {
|
||||
mirror += 1;
|
||||
}
|
||||
}
|
||||
let need = d.pos.len().div_ceil(2);
|
||||
if direct >= need && direct >= mirror {
|
||||
Some(false)
|
||||
} else if mirror >= need {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Collect rel samples per part across all logs.
|
||||
let mut samples: std::collections::BTreeMap<String, Vec<[f64; 3]>> = Default::default();
|
||||
for log in &a[3..] {
|
||||
let text = std::fs::read_to_string(log).unwrap();
|
||||
let draws = parse_capture(&text);
|
||||
// label validated draws
|
||||
let mut labeled: Vec<(usize, usize, bool)> = Vec::new(); // (draw, part, mirrored)
|
||||
for (di, d) in draws.iter().enumerate() {
|
||||
for (pi, p) in parts.iter().enumerate() {
|
||||
if let Some(mir) = validate(d, p) {
|
||||
labeled.push((di, pi, mir));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let refs: Vec<usize> = labeled
|
||||
.iter()
|
||||
.filter(|(_, pi, _)| parts[*pi].base.ends_with("bdy_04"))
|
||||
.map(|(di, ..)| *di)
|
||||
.collect();
|
||||
for &ri in &refs {
|
||||
let rf = &draws[ri];
|
||||
let rt = transpose(&rf.r);
|
||||
for &(di, pi, _mir) in &labeled {
|
||||
let d = &draws[di];
|
||||
let dt = [d.t[0] - rf.t[0], d.t[1] - rf.t[1], d.t[2] - rf.t[2]];
|
||||
let rel = mv(&rt, dt);
|
||||
if rel.iter().map(|v| v * v).sum::<f64>().sqrt() < 2600.0 {
|
||||
// also require the rel rotation be finite-sane
|
||||
let _rm = mmul(&rt, &d.r);
|
||||
samples.entry(parts[pi].base.clone()).or_default().push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len());
|
||||
}
|
||||
|
||||
// Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples.
|
||||
println!("\n== dominant ship-relative placements (clusters ≥3 samples) ==");
|
||||
for (part, mut v) in samples {
|
||||
v.sort_by(|x, y| x[0].partial_cmp(&y[0]).unwrap());
|
||||
let mut clusters: Vec<([f64; 3], usize)> = Vec::new();
|
||||
for s in &v {
|
||||
match clusters.iter_mut().find(|(c, _)| {
|
||||
(c[0] - s[0]).abs() < 8.0 && (c[1] - s[1]).abs() < 8.0 && (c[2] - s[2]).abs() < 8.0
|
||||
}) {
|
||||
Some((c, n)) => {
|
||||
for k in 0..3 {
|
||||
c[k] = (c[k] * *n as f64 + s[k]) / (*n as f64 + 1.0);
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
None => clusters.push((*s, 1)),
|
||||
}
|
||||
}
|
||||
clusters.sort_by(|x, y| y.1.cmp(&x.1));
|
||||
let tops: Vec<String> = clusters
|
||||
.iter()
|
||||
.filter(|(_, n)| *n >= 3)
|
||||
.take(6)
|
||||
.map(|(c, n)| format!("[{:7.1} {:7.1} {:7.1}]×{n}", c[0], c[1], c[2]))
|
||||
.collect();
|
||||
println!(" {part:20} {}", tops.join(" "));
|
||||
}
|
||||
|
||||
// Static assembly rel bdy_04 for comparison.
|
||||
println!("\n== static assemble_ship rel bdy_04 ==");
|
||||
let placed = assemble_ship(&bytes, id, true);
|
||||
if let Some(r4) = placed.iter().find(|p| p.resource == format!("{id}_bdy_04")) {
|
||||
for p in &placed {
|
||||
println!(
|
||||
" {:20} [{:7.1} {:7.1} {:7.1}]",
|
||||
p.resource,
|
||||
p.t[0] - r4.t[0],
|
||||
p.t[1] - r4.t[1],
|
||||
p.t[2] - r4.t[2]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
crates/sylpheed-formats/examples/composite_geometry.rs
Normal file
34
crates/sylpheed-formats/examples/composite_geometry.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! How much of the "decoded geometry" belongs to SCENE COMPOSITES rather than to
|
||||
//! real meshes? A composite (`rou_*`, `e_rou_*`) carries node transforms; the
|
||||
//! anchor scan nevertheless finds a block for it, and that pseudo-geometry lands
|
||||
//! in coverage and consistency counts.
|
||||
//! Usage: composite_geometry <resource3d_dir>
|
||||
use sylpheed_formats::mesh::{scene_world_nodes, Xbg7Model};
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let (mut total, mut composite, mut composite_named) = (0usize, 0usize, 0usize);
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||
total += 1;
|
||||
let has_nodes = !scene_world_nodes(&bytes, &m.name).is_empty();
|
||||
let named = m.name.starts_with("rou_") || m.name.starts_with("e_rou_");
|
||||
if has_nodes {
|
||||
composite += 1;
|
||||
}
|
||||
if named {
|
||||
composite_named += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{total} decoded; {composite} have scene nodes (a composite), {composite_named} are named rou_/e_rou_"
|
||||
);
|
||||
}
|
||||
87
crates/sylpheed-formats/examples/consensus_check.rs
Normal file
87
crates/sylpheed-formats/examples/consensus_check.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Minority report: which container decodes a shared resource differently from
|
||||
//! all the others?
|
||||
//!
|
||||
//! Cross-container consistency has been measured as "do the spans agree", which
|
||||
//! only says a resource is inconsistent — not which copy is wrong. With three or
|
||||
//! more copies the majority is the reference, and the minority names the
|
||||
//! container AND the resource to look at. That is what caught the `n054`/`n056`
|
||||
//! shift chain after the exact-coverage fix.
|
||||
//!
|
||||
//! Usage: consensus_check <resource3d_dir> [--list]
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let list = std::env::args().any(|a| a == "--list");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
// name -> [(container, verts, tris, span)]
|
||||
let mut seen: BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>> = BTreeMap::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for s in &m.meshes {
|
||||
for q in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(q[k]);
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo[0] == f32::MAX {
|
||||
continue;
|
||||
}
|
||||
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
|
||||
let span = [
|
||||
(hi[0] - lo[0]).round() as i64,
|
||||
(hi[1] - lo[1]).round() as i64,
|
||||
(hi[2] - lo[2]).round() as i64,
|
||||
];
|
||||
seen.entry(m.name.clone()).or_default().push((where_.clone(), v, t, span));
|
||||
}
|
||||
}
|
||||
|
||||
let (mut resources, mut minority) = (0usize, 0usize);
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
for (name, list_) in &seen {
|
||||
// Only compare decodes that agree on how much geometry they found, and
|
||||
// only where a majority can exist.
|
||||
if list_.len() < 3 || !list_.iter().all(|e| e.1 == list_[0].1 && e.2 == list_[0].2) {
|
||||
continue;
|
||||
}
|
||||
let mut votes: HashMap<[i64; 3], usize> = HashMap::new();
|
||||
for e in list_ {
|
||||
*votes.entry(e.3).or_default() += 1;
|
||||
}
|
||||
let (best, n) = votes.iter().max_by_key(|(_, n)| **n).unwrap();
|
||||
if *n * 2 <= list_.len() {
|
||||
continue; // no majority — cannot call anyone the odd one out
|
||||
}
|
||||
resources += 1;
|
||||
for e in list_.iter().filter(|e| e.3 != *best) {
|
||||
minority += 1;
|
||||
rows.push(format!(
|
||||
"{:<22} {name:<26} v={:<6} {:?} vs the other {n} containers' {:?}",
|
||||
e.0, e.1, e.3, best
|
||||
));
|
||||
}
|
||||
}
|
||||
if list {
|
||||
for r in &rows {
|
||||
println!("{r}");
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{minority} minority decodes across {resources} resources that have a majority"
|
||||
);
|
||||
}
|
||||
136
crates/sylpheed-formats/examples/correlate_capture.rs
Normal file
136
crates/sylpheed-formats/examples/correlate_capture.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Recover exact capital-ship part placement from a Canary capture log
|
||||
//! and (optionally) emit a checked-in placement-table block.
|
||||
//!
|
||||
//! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example
|
||||
//! is the CLI wrapper: it reads the log, decodes the ship's parts from the disc to
|
||||
//! get their vertex counts + leading positions (the match keys), correlates, and
|
||||
//! prints the result. With `--emit` it prints the `ship …` block to paste into
|
||||
//! `crates/sylpheed-formats/data/ship_placements.txt`.
|
||||
//!
|
||||
//! Matching is **LOD-aware**: a ship on screen at distance is drawn with its
|
||||
//! `_m`/`_l` LOD copies, which live in the same local frame as the base part — so
|
||||
//! each base part tries its own vcount first, then its LOD variants', and the
|
||||
//! recovered transform is recorded under the base name. Same-vcount twins
|
||||
//! (mirrored port/starboard hulls) are routed by the draw's position dump.
|
||||
//!
|
||||
//! Usage:
|
||||
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
|
||||
//! e.g. SYLPHEED_ISO="/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of
|
||||
//! Deception (USA, Europe) (En,Ja).iso" \
|
||||
//! cargo run --release --example correlate_capture -- \
|
||||
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit
|
||||
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
||||
use sylpheed_formats::ship_capture::{
|
||||
correlate, parse_capture, parse_drawlog, serialize_table, PartKey,
|
||||
};
|
||||
use sylpheed_formats::xiso::open_iso;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
||||
let emit = args.iter().any(|a| a == "--emit");
|
||||
if positional.len() < 3 {
|
||||
eprintln!(
|
||||
"usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (log, stage, id) = (positional[0], positional[1], positional[2]);
|
||||
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_04");
|
||||
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
||||
|
||||
// Accept either the F10 ship-capture format or the draw-logger format;
|
||||
// auto-detect by trying the F10 parser first.
|
||||
let text = std::fs::read_to_string(log).expect("read log");
|
||||
let mut draws = parse_capture(&text);
|
||||
if draws.is_empty() {
|
||||
draws = parse_drawlog(&text);
|
||||
eprintln!("parsed {} draws (draw-logger format)", draws.len());
|
||||
} else {
|
||||
eprintln!("parsed {} draws (F10 capture format)", draws.len());
|
||||
}
|
||||
|
||||
// Decode the ship's base parts AND their LOD copies (vcount + leading
|
||||
// positions are the match keys; LODs share the base part's local frame).
|
||||
let bytes = {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut r = open_iso(Path::new(&iso)).await.unwrap();
|
||||
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
|
||||
})
|
||||
};
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
let base_parts: Vec<String> = names
|
||||
.iter()
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
// Candidate resources per base part: itself + `_m`/`_l`/`_d` LOD copies.
|
||||
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||
for p in &base_parts {
|
||||
for suf in ["_m", "_l", "_d"] {
|
||||
let cand = format!("{p}{suf}");
|
||||
if names.contains(&cand) {
|
||||
want.insert(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||
let m = models.iter().find(|m| m.name == name)?;
|
||||
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
|
||||
};
|
||||
|
||||
// One PartKey per (part, variant-vcount present in the capture) — correlate
|
||||
// takes the first that validates. Validation uses the UNION of the part's
|
||||
// variant position sets: a draw's `vcount` can match one LOD while its
|
||||
// buffer holds another variant's geometry (seen on the real e106: the
|
||||
// 558-vcount draw carried the FULL 1633-vert bdy_04 buffer), and every
|
||||
// variant is the same part in the same local frame.
|
||||
let mut keys: Vec<PartKey> = Vec::new();
|
||||
for part in &base_parts {
|
||||
let variants =
|
||||
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
|
||||
let union: Vec<[f32; 3]> =
|
||||
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
|
||||
let mut any = false;
|
||||
for cand in &variants {
|
||||
if let Some(pos) = positions_of(cand) {
|
||||
let vcount = pos.len() as u32;
|
||||
if draws.iter().any(|d| d.vcount == vcount) {
|
||||
let lod = if cand == part { "full" } else { cand.rsplit('_').next().unwrap_or("?") };
|
||||
eprintln!(" {part:20} try vcount={vcount:6} [{lod}]");
|
||||
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
eprintln!(" {part:20} — no draw matches any LOD (culled/off-screen?)");
|
||||
}
|
||||
}
|
||||
|
||||
let Some(ship) = correlate(id, &draws, &keys, ref_sub) else {
|
||||
eprintln!("no parts matched a captured draw");
|
||||
return;
|
||||
};
|
||||
|
||||
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
|
||||
for p in &ship.parts {
|
||||
eprintln!(" {:18} T=[{:9.1}{:9.1}{:9.1}]", p.part, p.t[0], p.t[1], p.t[2]);
|
||||
}
|
||||
for part in &base_parts {
|
||||
if !ship.parts.iter().any(|p| &p.part == part) {
|
||||
eprintln!(" {part:18} — NOT placed (culled, or its only vcount hit failed position validation)");
|
||||
}
|
||||
}
|
||||
|
||||
if emit {
|
||||
print!("{}", serialize_table(std::slice::from_ref(&ship)));
|
||||
}
|
||||
}
|
||||
280
crates/sylpheed-formats/examples/correlate_frames.rs
Normal file
280
crates/sylpheed-formats/examples/correlate_frames.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! Correlate a capture **per frame** and cross-check the frames against each
|
||||
//! other — the placement is only believable if independent frames agree.
|
||||
//!
|
||||
//! `correlate_capture` treats one capture log as one set of draws. It is not:
|
||||
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
|
||||
//! cancels the camera within a single frame (see
|
||||
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
|
||||
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
|
||||
//!
|
||||
//! So: segment, correlate each frame independently, then report per part the
|
||||
//! median translation and the spread across frames. A part whose spread is a
|
||||
//! few units is measured; a part that swings by hundreds is not, whatever the
|
||||
//! single-shot number said.
|
||||
//!
|
||||
//! Usage:
|
||||
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
|
||||
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
||||
use sylpheed_formats::ship_capture::{
|
||||
correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
|
||||
};
|
||||
use sylpheed_formats::xiso::open_iso;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
fn median(mut v: Vec<f32>) -> f32 {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = v.len();
|
||||
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if positional.len() < 3 {
|
||||
eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (log, stage, id) = (positional[0], positional[1], positional[2]);
|
||||
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
|
||||
let min_parts: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--min-parts")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3);
|
||||
|
||||
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
||||
let text = std::fs::read_to_string(log).expect("read log");
|
||||
let mut draws = parse_capture(&text);
|
||||
if draws.is_empty() {
|
||||
draws = parse_drawlog(&text);
|
||||
}
|
||||
let frames = segment_frames(&draws);
|
||||
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
|
||||
|
||||
let bytes = {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut r = open_iso(Path::new(&iso)).await.unwrap();
|
||||
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
|
||||
})
|
||||
};
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
let base_parts: Vec<String> = names
|
||||
.iter()
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||
for p in &base_parts {
|
||||
for suf in ["_m", "_l", "_d"] {
|
||||
let c = format!("{p}{suf}");
|
||||
if names.contains(&c) {
|
||||
want.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||
let m = models.iter().find(|m| m.name == name)?;
|
||||
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
|
||||
};
|
||||
|
||||
// part -> [T per frame], and how many frames placed it at all.
|
||||
let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
|
||||
let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
|
||||
let mut used_frames = 0usize;
|
||||
for (fi, fr) in frames.iter().enumerate() {
|
||||
let mut keys: Vec<PartKey> = Vec::new();
|
||||
for part in &base_parts {
|
||||
let variants =
|
||||
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
|
||||
let union: Vec<[f32; 3]> =
|
||||
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
|
||||
for cand in &variants {
|
||||
if let Some(pos) = positions_of(cand) {
|
||||
let vcount = pos.len() as u32;
|
||||
if fr.iter().any(|d| d.vcount == vcount) {
|
||||
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
|
||||
if ship.parts.len() < min_parts {
|
||||
continue;
|
||||
}
|
||||
// Placements are expressed in the REFERENCE part's frame, so blocks that
|
||||
// fell back to a different reference (because the requested one was not
|
||||
// drawn in that block) are in a different coordinate system entirely.
|
||||
// Averaging them together is what makes an otherwise clean result look
|
||||
// like it disagrees by exactly the distance between the two references.
|
||||
if !ship.reference.contains(ref_sub) {
|
||||
println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
|
||||
continue;
|
||||
}
|
||||
used_frames += 1;
|
||||
println!(
|
||||
" block {fi:2} ({:4} draws): ref={} parts={}",
|
||||
fr.len(),
|
||||
ship.reference,
|
||||
ship.parts.len()
|
||||
);
|
||||
for p in &ship.parts {
|
||||
samples.entry(p.part.clone()).or_default().push(p.t);
|
||||
rots.entry(p.part.clone()).or_default().push(p.m);
|
||||
}
|
||||
}
|
||||
|
||||
if used_frames == 0 {
|
||||
println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
|
||||
return;
|
||||
}
|
||||
// Aggregate by CONSENSUS, not by average. A stage holds several ships of the
|
||||
// same class, they share vertex buffers, and a block can therefore contain
|
||||
// one instance's full-LOD part next to another instance's `_m` copy — two
|
||||
// different buffers, so nothing splits them, and the recovered translation
|
||||
// then belongs to whichever instance the correlator happened to pick. Those
|
||||
// are outliers by thousands of units, so a mean or a median over all blocks
|
||||
// is meaningless; the largest cluster of blocks that agree with each other
|
||||
// is the placement, and the rest are honestly reported as other instances.
|
||||
const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
|
||||
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
|
||||
let mut best: Vec<usize> = Vec::new();
|
||||
for seed in ts {
|
||||
let near: Vec<usize> = ts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if near.len() > best.len() {
|
||||
best = near;
|
||||
}
|
||||
}
|
||||
let out = ts.len() - best.len();
|
||||
(best, out)
|
||||
};
|
||||
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
|
||||
let mut agree = 0usize;
|
||||
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
|
||||
for (part, ts) in &samples {
|
||||
let (cl_idx, outliers) = cluster(ts);
|
||||
let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
|
||||
let med = [
|
||||
median(cl.iter().map(|t| t[0]).collect()),
|
||||
median(cl.iter().map(|t| t[1]).collect()),
|
||||
median(cl.iter().map(|t| t[2]).collect()),
|
||||
];
|
||||
let spread: Vec<f32> = (0..3)
|
||||
.map(|a| {
|
||||
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
|
||||
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
|
||||
})
|
||||
.collect();
|
||||
let verdict = if cl.len() < 2 {
|
||||
"single block — unverified"
|
||||
} else {
|
||||
agree += 1;
|
||||
"AGREES"
|
||||
};
|
||||
// How much the ROTATION varies between blocks that agree on position.
|
||||
// A part bolted to the hull reads 0 here; a part that is articulating
|
||||
// (turret aiming, engine gimballing) does not — which is what separates
|
||||
// "the assembler has the rotation wrong" from "the part moved".
|
||||
let all_ms = rots.get(part).cloned().unwrap_or_default();
|
||||
let ms: Vec<[[f32; 3]; 3]> =
|
||||
cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
|
||||
// Keep a rotation from INSIDE the cluster: the first sample overall can
|
||||
// belong to another instance, and diffing static against that reads as a
|
||||
// rotation error that is really an instance mix-up.
|
||||
consensus.insert(
|
||||
part.clone(),
|
||||
(med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
|
||||
);
|
||||
let rot_var = ms
|
||||
.iter()
|
||||
.flat_map(|a| ms.iter().map(move |b| (a, b)))
|
||||
.map(|(a, b)| {
|
||||
(0..3)
|
||||
.flat_map(|i| (0..3).map(move |j| (i, j)))
|
||||
.map(|(i, j)| (a[i][j] - b[i][j]).abs())
|
||||
.fold(0.0f32, f32::max)
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
println!(
|
||||
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}",
|
||||
cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
|
||||
if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() }
|
||||
);
|
||||
}
|
||||
println!("\n{agree}/{} parts reproduce across blocks", samples.len());
|
||||
|
||||
// `--static <Stage_SNN.xpr>`: diff the offline assembler against this
|
||||
// ground truth. Static placements are in ship space, so both sides are
|
||||
// re-expressed in the reference part's frame before comparing — and the
|
||||
// rotation is compared too, because "wrong orientation" is half of the
|
||||
// reported viewer symptom and a translation-only check cannot see it.
|
||||
let Some(si) = args.iter().position(|a| a == "--static") else { return };
|
||||
let Some(spath) = args.get(si + 1) else { return };
|
||||
let sbytes = std::fs::read(spath).expect("read stage container");
|
||||
// `include_external = true` — the engine cluster, the bridge and cross-id
|
||||
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
|
||||
// primary-composite pass does not reach. With `false` an e106 assembles as
|
||||
// 5 parts and the runtime capture's bridge/nacelles read as "not produced by
|
||||
// assemble_ship", which is a property of the caller, not of the format.
|
||||
let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
|
||||
let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
|
||||
println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
|
||||
return;
|
||||
};
|
||||
// The reference is placed axis-aligned in every ship seen so far; if that
|
||||
// ever stops holding, the rotation would have to be unwound here too.
|
||||
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
|
||||
let mut worst_t = 0.0f32;
|
||||
let mut worst_r = 0.0f32;
|
||||
for (part, (med, rm)) in &consensus {
|
||||
let (med, rm) = (*med, *rm);
|
||||
// A part may be instanced (mirrored twins share a resource name); take
|
||||
// the static copy that lands nearest the captured one.
|
||||
let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
|
||||
scene.iter().filter(|p| &p.resource == part).collect();
|
||||
if cands.is_empty() {
|
||||
println!(" {part:18} — not produced by assemble_ship");
|
||||
continue;
|
||||
}
|
||||
let rel = |p: &sylpheed_formats::mesh::ScenePart| {
|
||||
[p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
|
||||
};
|
||||
let best = cands
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
let d = |p: &sylpheed_formats::mesh::ScenePart| {
|
||||
let r = rel(p);
|
||||
(0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
|
||||
};
|
||||
d(a).partial_cmp(&d(b)).unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
let r = rel(best);
|
||||
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
|
||||
let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
|
||||
let drm = (0..3)
|
||||
.flat_map(|i| (0..3).map(move |j| (i, j)))
|
||||
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
worst_t = worst_t.max(dtm);
|
||||
worst_r = worst_r.max(drm);
|
||||
let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
|
||||
println!(
|
||||
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
|
||||
r[0], r[1], r[2]
|
||||
);
|
||||
}
|
||||
println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
|
||||
scene.len(), samples.len());
|
||||
}
|
||||
48
crates/sylpheed-formats/examples/coverage_audit.rs
Normal file
48
crates/sylpheed-formats/examples/coverage_audit.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Do real index buffers address their whole vertex pool?
|
||||
//!
|
||||
//! `validate_block` rejects a block whose indices reach fewer than `vtx_count−4`
|
||||
//! vertices ("buffer not covered"). That gate is the furthest-reached rejection
|
||||
//! for a handful of resources that never decode — so the question is whether it
|
||||
//! is well founded. This measures the slack on every block that DOES decode: if
|
||||
//! real geometry always covers its pool, under-coverage is good evidence of a
|
||||
//! wrong candidate and the gate stands.
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut hist: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut worst: Vec<(i64, String)> = Vec::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||
for sub in &m.meshes {
|
||||
if sub.positions.is_empty() || sub.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let max_idx = *sub.indices.iter().max().unwrap() as i64;
|
||||
let slack = sub.positions.len() as i64 - 1 - max_idx;
|
||||
*hist.entry(slack.min(20)).or_default() += 1;
|
||||
if slack > 4 {
|
||||
worst.push((slack, format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("unreferenced tail vertices (vtx_count − 1 − max index), over decoded sub-meshes:");
|
||||
for (slack, n) in &hist {
|
||||
println!(" {:>3}{} : {n}", slack, if *slack == 20 { "+" } else { " " });
|
||||
}
|
||||
worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s));
|
||||
for (s, w) in worst.iter().take(5) {
|
||||
println!(" largest slack {s}: {w}");
|
||||
}
|
||||
}
|
||||
59
crates/sylpheed-formats/examples/deep_map.rs
Normal file
59
crates/sylpheed-formats/examples/deep_map.rs
Normal 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)"); }
|
||||
}
|
||||
}
|
||||
50
crates/sylpheed-formats/examples/default_owners.rs
Normal file
50
crates/sylpheed-formats/examples/default_owners.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Scratch analysis: for one IDXD key, list every object that DECLARES it and
|
||||
//! whether it carries a value on disc or is left at the title-code default.
|
||||
//!
|
||||
//! Run: cargo run -p sylpheed-formats --example default_owners -- <KEY> [KEY...]
|
||||
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn is_number(s: &str) -> bool {
|
||||
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||
}
|
||||
fn is_key(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& !is_number(s)
|
||||
}
|
||||
fn is_value(s: &str) -> bool {
|
||||
is_number(s) || !is_key(s)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC")
|
||||
.unwrap_or_else(|_| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
|
||||
let wanted: Vec<String> = std::env::args().skip(1).collect();
|
||||
let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||
let toks = obj.tokens().to_vec();
|
||||
let mut hits: Vec<String> = vec![];
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
if !wanted.iter().any(|w| w == t) {
|
||||
continue;
|
||||
}
|
||||
let valued = i > 0 && is_value(&toks[i - 1]);
|
||||
hits.push(if valued {
|
||||
format!("{t}={}", toks[i - 1])
|
||||
} else {
|
||||
format!("{t}=<DEFAULT>")
|
||||
});
|
||||
}
|
||||
if !hits.is_empty() {
|
||||
println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
135
crates/sylpheed-formats/examples/defaulted_fields.rs
Normal file
135
crates/sylpheed-formats/examples/defaulted_fields.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
//! Scratch analysis: which IDXD fields are DECLARED but left at their default on
|
||||
//! disc, per schema. Those defaults live in title code, so they can only be read
|
||||
//! from the running game — this prints the shopping list for that dynamic capture.
|
||||
//!
|
||||
//! Run: cargo run -p sylpheed-formats --example defaulted_fields -- <disc-root>
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn is_number(s: &str) -> bool {
|
||||
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||
}
|
||||
|
||||
/// Same shape-test the parser uses: an identifier-looking token that is not a
|
||||
/// value literal is a field-name key.
|
||||
fn is_key(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& !is_number(s)
|
||||
}
|
||||
|
||||
fn is_value(s: &str) -> bool {
|
||||
is_number(s) || !is_key(s)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
|
||||
let paks: Vec<String> = std::env::args().skip(2).collect();
|
||||
let paks = if paks.is_empty() {
|
||||
vec![
|
||||
"dat/GP_MAIN_GAME_E.pak".to_string(),
|
||||
"dat/GP_HANGAR_ARSENAL.pak".to_string(),
|
||||
]
|
||||
} else {
|
||||
paks
|
||||
};
|
||||
|
||||
for pak_rel in &paks {
|
||||
let path = std::path::Path::new(&root).join(pak_rel);
|
||||
let Ok(arc) = PakArchive::open(&path) else {
|
||||
eprintln!("-- skip {pak_rel} (open failed)");
|
||||
continue;
|
||||
};
|
||||
println!("\n================ {pak_rel} ================");
|
||||
|
||||
// schema -> key -> (n_set, n_defaulted, distinct values, example owners)
|
||||
type Stat = (usize, usize, BTreeSet<String>, Vec<String>);
|
||||
let mut per_schema: BTreeMap<u32, (usize, BTreeMap<String, Stat>)> = BTreeMap::new();
|
||||
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let ident = obj.identity();
|
||||
let toks = obj.tokens().to_vec();
|
||||
let entry = per_schema.entry(obj.schema_hash).or_default();
|
||||
entry.0 += 1;
|
||||
// Track which keys this object declares, and whether each is valued.
|
||||
let mut seen_here: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
if !is_key(t) {
|
||||
continue;
|
||||
}
|
||||
let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
|
||||
let valued = prev.map(|p| is_value(p)).unwrap_or(false);
|
||||
let v = if valued { Some(toks[i - 1].clone()) } else { None };
|
||||
seen_here.entry(t.clone()).or_insert(v);
|
||||
}
|
||||
for (k, v) in seen_here {
|
||||
let s = entry.1.entry(k).or_default();
|
||||
match v {
|
||||
Some(val) => {
|
||||
s.0 += 1;
|
||||
if s.2.len() < 12 {
|
||||
s.2.insert(val);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
s.1 += 1;
|
||||
if s.3.len() < 6 {
|
||||
s.3.push(ident.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (schema, (n_obj, keys)) in per_schema {
|
||||
if n_obj < 2 {
|
||||
continue;
|
||||
}
|
||||
let name = match schema {
|
||||
0x0426_e81d => "PLAYER",
|
||||
0x6ab4_825a => "WEAPON",
|
||||
0x43fa_a517 => "UNIT",
|
||||
0x3c5b_0549 => "VESSEL",
|
||||
0xbd86_d41c => "CHARACTER",
|
||||
0x3c9a_e32e => "STAGE",
|
||||
0xb412_e6d8 => "MESSAGE",
|
||||
_ => "?",
|
||||
};
|
||||
let defaulted: Vec<_> = keys
|
||||
.iter()
|
||||
.filter(|(_, s)| s.1 > 0)
|
||||
.collect();
|
||||
if defaulted.is_empty() {
|
||||
continue;
|
||||
}
|
||||
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
|
||||
println!(
|
||||
"{:<30} {:>5} {:>5} {}",
|
||||
"KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
|
||||
);
|
||||
for (k, (n_set, n_def, vals, owners)) in defaulted {
|
||||
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
|
||||
println!(
|
||||
"{:<30} {:>5} {:>5} {} | {}",
|
||||
k,
|
||||
n_set,
|
||||
n_def,
|
||||
vv.join(","),
|
||||
owners.join(",")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
crates/sylpheed-formats/examples/desc_dump.rs
Normal file
71
crates/sylpheed-formats/examples/desc_dump.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Dump an XBG7 resource's descriptor around each index marker, to see whether a
|
||||
//! grouped pool carries a declaration PER sub-mesh (the 2026-08-13 mixed-stride
|
||||
//! finding says it must: n201's four sub-meshes use strides 24/24/24/28 and four
|
||||
//! different vertex shaders).
|
||||
//! Usage: desc_dump <container.xpr> <resource>
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let want = &a[2];
|
||||
// Walk the XPR2 directory by hand (the crate's reader is private).
|
||||
// 16-byte directory entries at 0x10: type tag, data offset, descriptor size,
|
||||
// name offset — all relative to 0x10 (see texture::Xpr2ResourceEntry).
|
||||
let num = be32(&bytes, 12) as usize;
|
||||
for i in 0..num {
|
||||
let e = &bytes[0x10 + i * 16..0x10 + i * 16 + 16];
|
||||
let tag = &e[0..4];
|
||||
let data_off = be32(e, 4) as usize + 0x10;
|
||||
let desc_size = be32(e, 8) as usize;
|
||||
let name_off = be32(e, 12) as usize + 0x10;
|
||||
if tag != b"XBG7" {
|
||||
continue;
|
||||
}
|
||||
let name = {
|
||||
let s = name_off.min(bytes.len());
|
||||
let end = bytes[s..].iter().position(|&c| c == 0).unwrap_or(0) + s;
|
||||
String::from_utf8_lossy(&bytes[s..end]).to_string()
|
||||
};
|
||||
if name != *want {
|
||||
continue;
|
||||
}
|
||||
let desc = &bytes[data_off..(data_off + desc_size).min(bytes.len())];
|
||||
println!("{name}: descriptor at 0x{data_off:X}, {} bytes", desc.len());
|
||||
// markers: a == c*2, c%3==0, vertex count 32 bytes earlier
|
||||
let mut rel = 0usize;
|
||||
while rel + 8 <= desc.len() {
|
||||
let (x, c) = (be32(desc, rel), be32(desc, rel + 4));
|
||||
if c >= 3 && c % 3 == 0 && c < 400_000 && x == c * 2 && rel >= 32 {
|
||||
let vc = be32(desc, rel - 32);
|
||||
if (3..=200_000).contains(&vc) {
|
||||
println!("\n marker @0x{rel:X}: {vc} verts / {c} indices — declaration triples after it:");
|
||||
let mut r = rel + 8;
|
||||
let mut stride = 0u32;
|
||||
for _ in 0..12 {
|
||||
if r + 12 > desc.len() {
|
||||
break;
|
||||
}
|
||||
let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
|
||||
if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 {
|
||||
println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}");
|
||||
break;
|
||||
}
|
||||
println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF);
|
||||
stride = stride.max(o + 4);
|
||||
r += 12;
|
||||
}
|
||||
println!(" (min stride from element offsets: {stride})");
|
||||
rel += 8;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rel += 4;
|
||||
}
|
||||
return;
|
||||
}
|
||||
eprintln!("resource not found");
|
||||
}
|
||||
13
crates/sylpheed-formats/examples/dialogue.rs
Normal file
13
crates/sylpheed-formats/examples/dialogue.rs
Normal 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>());
|
||||
}
|
||||
}
|
||||
29
crates/sylpheed-formats/examples/dm_extract.rs
Normal file
29
crates/sylpheed-formats/examples/dm_extract.rs
Normal 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(" ")); }
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/sylpheed-formats/examples/dm_roster.rs
Normal file
11
crates/sylpheed-formats/examples/dm_roster.rs
Normal 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(" ")); }
|
||||
}
|
||||
14
crates/sylpheed-formats/examples/dm_rows.rs
Normal file
14
crates/sylpheed-formats/examples/dm_rows.rs
Normal 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")); }
|
||||
}
|
||||
15
crates/sylpheed-formats/examples/dossier.rs
Normal file
15
crates/sylpheed-formats/examples/dossier.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
80
crates/sylpheed-formats/examples/edge_cap_sweep.rs
Normal file
80
crates/sylpheed-formats/examples/edge_cap_sweep.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
//! Calibrate the connectivity cap against the whole disc.
|
||||
//!
|
||||
//! `XBG7_EDGE_CAP` sets the cap; this reports, for one setting, how much
|
||||
//! geometry decodes and how self-consistent it is across containers — the two
|
||||
//! numbers any change to the cap has to trade off. Run it once per cap value.
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
// name -> (verts, tris) -> spans seen, exactly as mesh_consistency_disc.rs.
|
||||
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize)>> = BTreeMap::new();
|
||||
let (mut models, mut verts) = (0usize, 0usize);
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for s in &m.meshes {
|
||||
for q in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(q[k]);
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo[0] == f32::MAX {
|
||||
continue;
|
||||
}
|
||||
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
|
||||
models += 1;
|
||||
verts += v;
|
||||
if std::env::var("DUMP").is_ok() {
|
||||
// Per-resource signature, so two cap settings can be diffed:
|
||||
// a cap change that silently MOVES an existing anchor is the
|
||||
// risk a coverage count cannot see.
|
||||
println!(
|
||||
"{}|{}|{v}|{t}|{}|{}|{}|{}",
|
||||
f.file_name().unwrap().to_string_lossy(),
|
||||
m.name,
|
||||
m.meshes[0].vbuf_offset.unwrap_or(0),
|
||||
(hi[0] - lo[0]).round() as i64,
|
||||
(hi[1] - lo[1]).round() as i64,
|
||||
(hi[2] - lo[2]).round() as i64
|
||||
);
|
||||
}
|
||||
seen.entry(m.name.clone()).or_default().push((
|
||||
[
|
||||
(hi[0] - lo[0]).round() as i64,
|
||||
(hi[1] - lo[1]).round() as i64,
|
||||
(hi[2] - lo[2]).round() as i64,
|
||||
],
|
||||
v,
|
||||
t,
|
||||
));
|
||||
}
|
||||
}
|
||||
let (mut shared, mut inconsistent) = (0usize, 0usize);
|
||||
for (_, list) in &seen {
|
||||
if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
|
||||
continue;
|
||||
}
|
||||
shared += 1;
|
||||
if list.iter().any(|e| e.0 != list[0].0) {
|
||||
inconsistent += 1;
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"cap={} models={models} verts={verts} shared={shared} inconsistent={inconsistent}",
|
||||
std::env::var("XBG7_EDGE_CAP").unwrap_or_else(|_| "library default".into())
|
||||
);
|
||||
}
|
||||
106
crates/sylpheed-formats/examples/envelope_screen.rs
Normal file
106
crates/sylpheed-formats/examples/envelope_screen.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! Does every part of a ship sit inside the envelope its siblings describe?
|
||||
//!
|
||||
//! `slab_screen` compared *scale* and could not see the `e106` slab. What the eye
|
||||
//! used when the render exposed it was **relationship**: a blocky mass sitting
|
||||
//! apart from the hull. This measures that — assemble a ship, and for each part
|
||||
//! ask how far its world box protrudes beyond the box of all the OTHER parts,
|
||||
//! relative to the ship's own size. A mis-anchored block sticks out; a real part,
|
||||
//! however big, is part of the silhouette.
|
||||
//!
|
||||
//! Usage: envelope_screen <resource3d_dir> [protrusion_fraction]
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::ship::{assemble_ship, ship_id_of};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let limit: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(0.35);
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut flagged = 0usize;
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let ids: BTreeSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
|
||||
.iter()
|
||||
.filter_map(|m| ship_id_of(&m.name).map(|s| s.to_string()))
|
||||
.collect();
|
||||
for id in &ids {
|
||||
let placed = assemble_ship(&bytes, id, true);
|
||||
if placed.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
// World box per placement.
|
||||
let mut boxes: Vec<(String, [f32; 3], [f32; 3])> = Vec::new();
|
||||
for p in &placed {
|
||||
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for s in &m.meshes {
|
||||
for q in &s.positions {
|
||||
let w = p.apply(*q);
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(w[k]);
|
||||
hi[k] = hi[k].max(w[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo[0] != f32::MAX {
|
||||
boxes.push((p.resource.clone(), lo, hi));
|
||||
}
|
||||
}
|
||||
if boxes.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
for i in 0..boxes.len() {
|
||||
// Envelope of every OTHER part.
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for (j, b) in boxes.iter().enumerate() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(b.1[k]);
|
||||
hi[k] = hi[k].max(b.2[k]);
|
||||
}
|
||||
}
|
||||
// Per-AXIS: a bow legitimately extends the ship along its long
|
||||
// axis, so protrusion only means something measured against the
|
||||
// envelope's size IN THAT AXIS. The e106 slab stuck ~1 500 out in
|
||||
// Y where its siblings spanned ~800.
|
||||
let mut worst = 0.0f32;
|
||||
let mut worst_out = 0.0f32;
|
||||
for k in 0..3 {
|
||||
let size_k = hi[k] - lo[k];
|
||||
if size_k <= 1.0 {
|
||||
continue;
|
||||
}
|
||||
let out_k = (lo[k] - boxes[i].1[k]).max(boxes[i].2[k] - hi[k]).max(0.0);
|
||||
if out_k / size_k > worst {
|
||||
worst = out_k / size_k;
|
||||
worst_out = out_k;
|
||||
}
|
||||
}
|
||||
let (out, size) = (worst_out, 1.0f32);
|
||||
let _ = size;
|
||||
if worst > limit {
|
||||
flagged += 1;
|
||||
println!(
|
||||
"{:<20} {:<22} protrudes {:>7.0} beyond its siblings ({:.0}% of the ship)",
|
||||
f.file_name().unwrap().to_string_lossy(),
|
||||
boxes[i].0,
|
||||
out,
|
||||
100.0 * worst
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{flagged} parts protrude more than {:.0}% of their ship's size", 100.0 * limit);
|
||||
}
|
||||
35
crates/sylpheed-formats/examples/filter_consistency.rs
Normal file
35
crates/sylpheed-formats/examples/filter_consistency.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
//! Does a FILTERED decode agree with the full one?
|
||||
//!
|
||||
//! Distinct anchor assignment resolves collisions against the set of resources
|
||||
//! being decoded — so `models_named` (a filtered subset, used by the ship
|
||||
//! assembler and the viewer) can reach a different answer from a whole-container
|
||||
//! decode. This measures that directly.
|
||||
//! Usage: filter_consistency <container.xpr> [resource...]
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::collections::HashSet;
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let full = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
|
||||
let names: Vec<String> = if a.len() > 2 {
|
||||
a[2..].to_vec()
|
||||
} else {
|
||||
full.iter().map(|m| m.name.clone()).collect()
|
||||
};
|
||||
let mut differ = 0usize;
|
||||
for n in &names {
|
||||
let want: HashSet<String> = std::iter::once(n.clone()).collect();
|
||||
let one = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let (Some(f), Some(s)) = (full.iter().find(|m| &m.name == n), one.first()) else {
|
||||
continue;
|
||||
};
|
||||
let off = |m: &Xbg7Model| m.meshes.first().and_then(|s| s.vbuf_offset).unwrap_or(0);
|
||||
if off(f) != off(s) {
|
||||
differ += 1;
|
||||
if differ <= 10 {
|
||||
println!("{n}: full decode at 0x{:x}, filtered at 0x{:x}", off(f), off(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{differ} of {} resources decode differently when filtered", names.len());
|
||||
}
|
||||
27
crates/sylpheed-formats/examples/find_ib.rs
Normal file
27
crates/sylpheed-formats/examples/find_ib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Where could a resource's index buffer be, given the vertex buffer a runtime
|
||||
//! capture proves the engine drew from? Tests the anchor scan's adjacency
|
||||
//! assumption against ground truth.
|
||||
use sylpheed_formats::mesh::{debug_find_index_buffer, debug_resource_params};
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
for pair in a[2..].iter() {
|
||||
let (name, off) = pair.split_once('@').unwrap();
|
||||
let vb = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
|
||||
let params = debug_resource_params(&bytes, name);
|
||||
let markers = params.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
|
||||
println!("{name} @ 0x{vb:x} markers={markers:?}");
|
||||
let hits = debug_find_index_buffer(&bytes, name, vb);
|
||||
if hits.is_empty() {
|
||||
println!(" no index buffer anywhere in the container validates this block");
|
||||
}
|
||||
let mut hits = hits;
|
||||
hits.sort_by_key(|(_, d)| d.abs());
|
||||
for (ib, d) in hits.iter().take(8) {
|
||||
println!(" ib 0x{ib:x} vb-ib = {d} bytes");
|
||||
}
|
||||
if hits.len() > 8 {
|
||||
println!(" … {} total", hits.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/sylpheed-formats/examples/find_mirror.rs
Normal file
44
crates/sylpheed-formats/examples/find_mirror.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! Does the container hold an X-mirrored copy of a resource's decoded buffer?
|
||||
//!
|
||||
//! The twin invariant (see `twin_mirror_audit`) flags `…_01`/`…_02` pairs that
|
||||
//! decode to unrelated geometry. If the mirror of one twin's buffer exists
|
||||
//! somewhere else in the container, that offset is where the other twin belongs
|
||||
//! and the pair is a mis-anchor; if it does not exist, the pair is simply not a
|
||||
//! mirrored pair.
|
||||
//!
|
||||
//! Usage: find_mirror <container.xpr> <resource>...
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let want: HashSet<String> = a[2..].iter().cloned().collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
|
||||
for m in &models {
|
||||
let pos: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).take(8).collect();
|
||||
if pos.len() < 8 {
|
||||
continue;
|
||||
}
|
||||
let anchored = m.meshes[0].vbuf_offset.unwrap_or(0);
|
||||
let (mut direct, mut mirror) = (Vec::new(), Vec::new());
|
||||
for o in (0..bytes.len().saturating_sub(12 + 8 * 24)).step_by(4) {
|
||||
for (flip, out) in [(1.0f32, &mut direct), (-1.0f32, &mut mirror)] {
|
||||
if (0..8).all(|k| {
|
||||
let at = o + k * 24;
|
||||
(be(at) - flip * pos[k][0]).abs() <= 1e-4
|
||||
&& (be(at + 4) - pos[k][1]).abs() <= 1e-4
|
||||
&& (be(at + 8) - pos[k][2]).abs() <= 1e-4
|
||||
}) {
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{:<20} anchored 0x{anchored:x} direct copies {:x?} mirrored copies {:x?}",
|
||||
m.name, direct, mirror
|
||||
);
|
||||
}
|
||||
}
|
||||
17
crates/sylpheed-formats/examples/flights.rs
Normal file
17
crates/sylpheed-formats/examples/flights.rs
Normal 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(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
60
crates/sylpheed-formats/examples/gate_histogram.rs
Normal file
60
crates/sylpheed-formats/examples/gate_histogram.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Which gate stops the resources that never decode?
|
||||
//! Usage: gate_histogram <resource3d_dir> [max_resources]
|
||||
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let cap: usize = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut hist: BTreeMap<usize, (usize, String)> = BTreeMap::new();
|
||||
let mut done = 0usize;
|
||||
for f in &files {
|
||||
if done >= cap {
|
||||
break;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
if names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let got: HashSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
|
||||
.into_iter()
|
||||
.map(|m| m.name)
|
||||
.collect();
|
||||
for n in names.iter().filter(|n| !got.contains(*n)) {
|
||||
if done >= cap {
|
||||
break;
|
||||
}
|
||||
done += 1;
|
||||
if let Some((rank, why)) = debug_best_rejection(&bytes, n) {
|
||||
let e = hist.entry(rank).or_insert((0, String::new()));
|
||||
e.0 += 1;
|
||||
if e.1.is_empty() {
|
||||
e.1 = format!("{n}: {why}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("furthest gate reached, over {done} resources that never decode:");
|
||||
let label = |r: usize| match r {
|
||||
0 => "no gate reached",
|
||||
1 => "index out of range",
|
||||
2 => "buffer not covered by indices",
|
||||
3 => "degenerate / implausible positions",
|
||||
4 => "connectivity (mean edge / diagonal)",
|
||||
5 => "winding consistency",
|
||||
9 => "grouped pool (different path)",
|
||||
_ => "?",
|
||||
};
|
||||
for (r, (n, ex)) in &hist {
|
||||
println!(" {:<38} {n:>5} e.g. {ex}", label(*r));
|
||||
}
|
||||
}
|
||||
2
crates/sylpheed-formats/examples/grouped_report.rs
Normal file
2
crates/sylpheed-formats/examples/grouped_report.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
|
||||
for l in sylpheed_formats::mesh::debug_grouped_report(&b,&a[2],a[3].parse().unwrap()){println!("{l}")}}
|
||||
43
crates/sylpheed-formats/examples/hokyu_demo_resolve.rs
Normal file
43
crates/sylpheed-formats/examples/hokyu_demo_resolve.rs
Normal 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(®);
|
||||
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(®ion); if rf.is_empty(){rf=slb::to_xma_riff_best(®ion).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()));
|
||||
}
|
||||
}
|
||||
36
crates/sylpheed-formats/examples/hokyu_final.rs
Normal file
36
crates/sylpheed-formats/examples/hokyu_final.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, slb, PakArchive};
|
||||
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
|
||||
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
|
||||
fn contains(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
|
||||
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
|
||||
fn hokyu_fallback(m:&str)->Option<String>{if !m.starts_with("hokyu_"){return None}let ls=m.contains("_LS_");let ds=m.contains("_DS_");let a=m.ends_with('A');let h=m.ends_with('H');Some(match(ls,ds,a,h){(true,_,true,_)=>"VOICE_D_450",(true,_,_,true)=>"VOICE_D_453",(_,true,true,_)=>"VOICE_D_452",(_,true,_,true)=>"VOICE_D_454",_=>return None}.into())}
|
||||
fn main(){
|
||||
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
|
||||
let code="eng";
|
||||
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
|
||||
let ids=movie_voice::registry_voice_ids(®istry);
|
||||
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries=PakArchive::parse_toc(&stoc).unwrap();
|
||||
let hokyu:Vec<String>=movie_manifest::parse(&manifest).into_iter().filter(|m|m.movie.starts_with("hokyu_")).map(|m|m.movie).collect();
|
||||
for movie in &hokyu{
|
||||
let bound=movie_manifest::voice_token(&manifest,movie);
|
||||
let token=bound.clone().or_else(||hokyu_fallback(movie));
|
||||
let Some(token)=token else{println!("{movie:16} NO TOKEN"); continue};
|
||||
let src = if bound.is_some(){"bound"}else{"fallback"};
|
||||
let Some(&id)=ids.get(&token) else{println!("{movie:16} {token} not in registry");continue};
|
||||
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else{println!("{movie:16} no anchor");continue};
|
||||
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
|
||||
let window=rg(&disc,win_start,8*1024*1024);
|
||||
let Some(el)=movie_voice::find_descriptor(&window,id) else{println!("{movie:16} desc not found");continue};
|
||||
let end=win_start+el as u64;
|
||||
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&window,el)).map(|o|win_start+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
|
||||
let region=rg(&disc,start,(end-start)as usize);
|
||||
let mut riffs=slb::to_xma_riffs(®ion); if riffs.is_empty(){riffs=slb::to_xma_riff_best(®ion).into_iter().collect();}
|
||||
let mut d="?".into();
|
||||
if let Some(r)=riffs.first(){let xp=format!("/tmp/hf_{movie}.xma.wav");let wp=format!("/tmp/hf_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!("{movie:16} {src:8} {token:12} id={id} voice={d}s movie={mv}s");
|
||||
}
|
||||
}
|
||||
108
crates/sylpheed-formats/examples/idxd_tokens.rs
Normal file
108
crates/sylpheed-formats/examples/idxd_tokens.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! RE diagnostic: emit every `weapon\Weapon_*.tbl` in a pak as machine-readable
|
||||
//! records, split at the sub-object boundaries the schema declares.
|
||||
//!
|
||||
//! An IDXD `.tbl` for a weapon holds `count` sub-records — a `Weapon` and its
|
||||
//! `Shell` — flattened into one string pool. `sylpheed-cli pak dump` shows them
|
||||
//! merged, which is ambiguous (both declare `ID`/`Name`). The title's schema
|
||||
//! name pool gives the split point: the pool contains the literal type names
|
||||
//! (`Weapon`, `Shell`), and each sub-record's fields follow its type name.
|
||||
//!
|
||||
//! Here we split on a type-name token appearing as a *key* position, so each
|
||||
//! record is emitted with its own ID and field set:
|
||||
//!
|
||||
//! ```text
|
||||
//! REC <tbl-hash> <index> <TypeName> <ID>
|
||||
//! F <key> <value>
|
||||
//! ```
|
||||
//!
|
||||
//! Run: cargo run --release -p sylpheed-formats --example idxd_tokens -- <pak> [types...]
|
||||
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn is_number(s: &str) -> bool {
|
||||
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||
}
|
||||
|
||||
/// `Yes`/`No`-style scalar literals are values, not field names, even though
|
||||
/// they are identifier-shaped.
|
||||
fn is_enum_value(s: &str) -> bool {
|
||||
matches!(s, "Yes" | "No" | "YES" | "NO" | "On" | "Off" | "ON" | "OFF")
|
||||
}
|
||||
|
||||
fn is_key_like(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& !is_number(s)
|
||||
&& !is_enum_value(s)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let pak_path = args.next().expect("usage: idxd_tokens <pak> [TypeName...]");
|
||||
let types: Vec<String> = {
|
||||
let v: Vec<String> = args.collect();
|
||||
if v.is_empty() {
|
||||
vec!["Weapon".into(), "Shell".into()]
|
||||
} else {
|
||||
v
|
||||
}
|
||||
};
|
||||
|
||||
let pak = PakArchive::open(&pak_path).expect("open pak");
|
||||
for entry in pak.entries() {
|
||||
let Ok(bytes) = pak.read(entry) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||
let toks = obj.tokens();
|
||||
// Only entries that actually declare one of the requested sub-record
|
||||
// types (the pool-start heuristic can prefix one stray byte, so match a
|
||||
// short suffix rather than equality -- same rule as the splitter below).
|
||||
if !toks
|
||||
.iter()
|
||||
.any(|t| types.iter().any(|ty| t == ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if std::env::var("SYLPH_RAW").is_ok() {
|
||||
println!("RAW {:08x}", entry.name_hash);
|
||||
for t in toks {
|
||||
println!("T {t}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-record boundaries: a bare type-name token. The first one is the
|
||||
// schema's own declaration (`EnumWeapon`-style header lives in title
|
||||
// code, not here), so we take every occurrence in order.
|
||||
let mut bounds: Vec<(usize, &str)> = Vec::new();
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
// The pool-start heuristic can glue one stray binary byte onto the
|
||||
// first token ("YWeapon"), so match a short suffix, not equality.
|
||||
if let Some(ty) = types
|
||||
.iter()
|
||||
.find(|ty| t == *ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2))
|
||||
{
|
||||
bounds.push((i, ty.as_str()));
|
||||
}
|
||||
}
|
||||
for (n, &(start, ty)) in bounds.iter().enumerate() {
|
||||
let end = bounds.get(n + 1).map(|b| b.0).unwrap_or(toks.len());
|
||||
let slice = &toks[start..end];
|
||||
// The record's own ID value is the token straight after its type
|
||||
// name (`Shell` -> `Shell_DSaber_P_wep_01_Beam`). The `ID`/`Name`
|
||||
// *keys* are interned once in the pool, so only the first record
|
||||
// shows them -- position-of-key lookup would miss the rest.
|
||||
let id = slice.get(1).map(|s| s.as_str()).unwrap_or("?");
|
||||
println!("REC {:08x} {n} {ty} {id}", entry.name_hash);
|
||||
for i in 1..slice.len() {
|
||||
let (k, v) = (slice[i].as_str(), slice[i - 1].as_str());
|
||||
if is_key_like(k) && (!is_key_like(v) || is_number(v)) {
|
||||
println!("F {k} {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
crates/sylpheed-formats/examples/index_hash_dump.rs
Normal file
32
crates/sylpheed-formats/examples/index_hash_dump.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Dump a fingerprint of every decoded index run, so two decoder settings can be
|
||||
//! diffed exactly. Usage: index_hash_dump <resource3d_dir>
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let w = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
for m in Xbg7Model::stage_models(&bytes) {
|
||||
for (k, sm) in m.meshes.iter().enumerate() {
|
||||
let mut h = 1469598103934665603u64;
|
||||
for i in &sm.indices {
|
||||
h = (h ^ *i as u64).wrapping_mul(1099511628211);
|
||||
}
|
||||
println!(
|
||||
"{w} {} {k} vb={:?} n={} h={h:016x}",
|
||||
m.name,
|
||||
sm.vbuf_offset,
|
||||
sm.indices.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
crates/sylpheed-formats/examples/index_pad_check.rs
Normal file
134
crates/sylpheed-formats/examples/index_pad_check.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! Is our index list one element late? A capture-free check of the `pad` choice.
|
||||
//!
|
||||
//! `anchor_pool_mesh` tries `ib = vb − idx_count*2 − pad` for `pad` in `0..=3`,
|
||||
//! **pad 0 first and with the looser winding gate** (`XBG7_PAD0_CONSISTENCY`,
|
||||
//! 0.70) than the pad ≥ 1 path (0.85). The 2026-08-13 index-byte comparison
|
||||
//! against the runtime showed 17 draw batches whose captured indices equal our
|
||||
//! decoded list shifted by exactly one element — all of them on buffers whose
|
||||
//! real index data sits at pad 2. A one-element shift re-wires every triangle,
|
||||
//! and it is invisible to every count-based metric (the count, the coverage and
|
||||
//! the positions are all still right).
|
||||
//!
|
||||
//! This reproduces that finding from the container alone: for each resource it
|
||||
//! reads the index run at pad 0 and at pad 2 and scores both on two properties a
|
||||
//! correct triangle list has — **no degenerate triangles** (two equal indices)
|
||||
//! and consistent winding against the stored normals.
|
||||
//!
|
||||
//! Usage: index_pad_check <container.xpr> [name-substring]
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> u32 {
|
||||
((b[at] as u32) << 8) | b[at + 1] as u32
|
||||
}
|
||||
|
||||
/// Degenerate triangles (a repeated index) and the fraction of triangles whose
|
||||
/// face normal agrees with their vertices' stored normals.
|
||||
fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize) {
|
||||
let mut degen = 0usize;
|
||||
let mut agree = 0usize;
|
||||
let mut counted = 0usize;
|
||||
for t in idx.chunks_exact(3) {
|
||||
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
if a == b || b == c || a == c {
|
||||
degen += 1;
|
||||
continue;
|
||||
}
|
||||
if a >= pos.len() || b >= pos.len() || c >= pos.len() {
|
||||
continue;
|
||||
}
|
||||
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
|
||||
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
|
||||
let f = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
if nrm.len() <= a {
|
||||
continue;
|
||||
}
|
||||
let n = nrm[a];
|
||||
let dot = f[0] * n[0] + f[1] * n[1] + f[2] * n[2];
|
||||
counted += 1;
|
||||
if dot > 0.0 {
|
||||
agree += 1;
|
||||
}
|
||||
}
|
||||
let frac = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
|
||||
(degen, frac.max(1.0 - frac), counted)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let filter = a.get(2).cloned().unwrap_or_default();
|
||||
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
println!(
|
||||
"{:<22} {:>6} {:>6} {:<26} {:<26} verdict",
|
||||
"resource", "verts", "idx", "pad 0 (what we decode)", "pad 2 (2 bytes earlier)"
|
||||
);
|
||||
let (mut better_p2, mut better_p0, mut tie) = (0usize, 0usize, 0usize);
|
||||
let mut hist_ok: Vec<(usize, String, usize)> = Vec::new();
|
||||
for m in &models {
|
||||
if !filter.is_empty() && !m.name.contains(&filter) {
|
||||
continue;
|
||||
}
|
||||
if m.meshes.len() != 1 {
|
||||
continue; // single-block path only; grouped pools use another formula
|
||||
}
|
||||
let sm = &m.meshes[0];
|
||||
let Some(vb) = sm.vbuf_offset else { continue };
|
||||
let n = sm.indices.len();
|
||||
if n < 6 || sm.normals.is_empty() || vb < n * 2 + 2 {
|
||||
continue;
|
||||
}
|
||||
let read_at = |start: usize| -> Vec<u32> { (0..n).map(|k| be16(&bytes, start + k * 2)).collect() };
|
||||
let l0 = read_at(vb - n * 2);
|
||||
let l2 = read_at(vb - n * 2 - 2);
|
||||
// Sanity: l0 must be what the decoder emitted, or the assumption that we
|
||||
// took pad 0 is wrong for this resource and the comparison is meaningless.
|
||||
let took_pad0 = l0 == sm.indices;
|
||||
if !took_pad0 {
|
||||
continue;
|
||||
}
|
||||
let (d0, c0, _) = score(&l0, &sm.positions, &sm.normals);
|
||||
let (d2, c2, _) = score(&l2, &sm.positions, &sm.normals);
|
||||
let max_i0 = l0.iter().max().copied().unwrap_or(0);
|
||||
let max_i2 = l2.iter().max().copied().unwrap_or(0);
|
||||
let v = sm.positions.len() as u32;
|
||||
// A shifted list typically also overruns the pool by one vertex or leaves
|
||||
// the last vertex unreferenced, so carry the coverage as evidence too.
|
||||
let verdict = if d2 < d0 && c2 >= c0 {
|
||||
better_p2 += 1;
|
||||
"pad 2 is the real one"
|
||||
} else if d0 < d2 || c0 > c2 {
|
||||
better_p0 += 1;
|
||||
"pad 0 fine"
|
||||
} else {
|
||||
tie += 1;
|
||||
"indistinguishable"
|
||||
};
|
||||
if verdict == "pad 0 fine" {
|
||||
hist_ok.push((d0, m.name.clone(), n));
|
||||
}
|
||||
if verdict != "pad 0 fine" || !filter.is_empty() {
|
||||
println!(
|
||||
"{:<22} {:>6} {:>6} degen {:>5} wind {:.3} max {:<5} degen {:>5} wind {:.3} max {:<5} {}",
|
||||
m.name, v, n, d0, c0, max_i0, d2, c2, max_i2, verdict
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\npad 2 wins: {better_p2} · pad 0 wins: {better_p0} · indistinguishable: {tie}");
|
||||
// Is "no degenerate triangle" a safe invariant for a correctly anchored
|
||||
// block? Distribution over the resources where pad 0 is the better choice.
|
||||
let zero = hist_ok.iter().filter(|(d, _, _)| *d == 0).count();
|
||||
println!(
|
||||
"of the {} pad-0-correct resources, {zero} have ZERO degenerate triangles",
|
||||
hist_ok.len()
|
||||
);
|
||||
let mut worst: Vec<_> = hist_ok.iter().filter(|(d, _, _)| *d > 0).collect();
|
||||
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
|
||||
for (d, n, idx) in worst.iter().take(10) {
|
||||
println!(" {n}: {d} degenerate of {} triangles", idx / 3);
|
||||
}
|
||||
}
|
||||
156
crates/sylpheed-formats/examples/invert_capture.rs
Normal file
156
crates/sylpheed-formats/examples/invert_capture.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! Invert the capture↔part match: instead of asking, per ship part, "is there a
|
||||
//! draw with this vertex count?", ask of the **capture's** biggest draws "which
|
||||
//! decoded resource in this stage container has that vertex count?".
|
||||
//!
|
||||
//! This is the diagnostic for the 2026-07-31 negative result (Stage_S02 capture,
|
||||
//! zero parts correlated). It separates three hypotheses:
|
||||
//! 1. LOD/variant vcount not covered by the correlator's variant list
|
||||
//! → the big draws DO map to named resources, just not to the `_m`/`_l`/`_d`
|
||||
//! set the correlator tries;
|
||||
//! 2. position validation over-rejects
|
||||
//! → the vcounts match the very parts we asked for (so the vcount key was
|
||||
//! fine and the rejection happened later);
|
||||
//! 3. a different draw path (instanced/batched/merged buffers)
|
||||
//! → the big draws match NO resource in the container at all.
|
||||
//!
|
||||
//! Usage:
|
||||
//! SYLPHEED_ISO=... cargo run --release --example invert_capture -- \
|
||||
//! <capture.log> <Stage_SNN> [top_n] [--all]
|
||||
//! `--all` lists every capture vcount, not just the `top_n` (default 40) largest.
|
||||
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
|
||||
use sylpheed_formats::xiso::open_iso;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
||||
let all = args.iter().any(|a| a == "--all");
|
||||
if positional.len() < 2 {
|
||||
eprintln!("usage: invert_capture <capture.log> <Stage_SNN> [top_n] [--all]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (log, stage) = (positional[0], positional[1]);
|
||||
let top_n: usize = positional.get(2).and_then(|s| s.parse().ok()).unwrap_or(40);
|
||||
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
||||
|
||||
let text = std::fs::read_to_string(log).expect("read log");
|
||||
let mut draws = parse_capture(&text);
|
||||
if draws.is_empty() {
|
||||
draws = parse_drawlog(&text);
|
||||
println!("parsed {} draws (draw-logger format)", draws.len());
|
||||
} else {
|
||||
println!("parsed {} draws (F10 capture format)", draws.len());
|
||||
}
|
||||
|
||||
// Decode EVERY geometry resource in the stage container, not just one ship's.
|
||||
let bytes = {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut r = open_iso(Path::new(&iso)).await.unwrap();
|
||||
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
|
||||
})
|
||||
};
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
println!("{stage}.xpr: {} XBG7 resources", names.len());
|
||||
let want: HashSet<String> = names.iter().cloned().collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
println!("decoded {} models", models.len());
|
||||
|
||||
// vcount -> resource names with that many vertices.
|
||||
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
|
||||
for m in &models {
|
||||
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
by_vcount.entry(v as u32).or_default().push(m.name.clone());
|
||||
}
|
||||
// Per-submesh counts too: a draw may be one sub-mesh of a multi-mesh resource.
|
||||
let mut by_sub_vcount: HashMap<u32, Vec<String>> = HashMap::new();
|
||||
for m in &models {
|
||||
for (i, s) in m.meshes.iter().enumerate() {
|
||||
if m.meshes.len() > 1 {
|
||||
by_sub_vcount
|
||||
.entry(s.positions.len() as u32)
|
||||
.or_default()
|
||||
.push(format!("{}#{i}", m.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture vcounts, de-duped by (vbase, vcount) so a re-drawn part counts once
|
||||
// per distinct buffer.
|
||||
let mut draw_count: HashMap<u32, usize> = HashMap::new();
|
||||
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
|
||||
for d in &draws {
|
||||
*draw_count.entry(d.vcount).or_default() += 1;
|
||||
bufs.entry(d.vcount).or_default().insert(d.vbase);
|
||||
}
|
||||
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
|
||||
vcounts.sort_unstable_by(|a, b| b.cmp(a));
|
||||
|
||||
let matched_draws: usize = draws
|
||||
.iter()
|
||||
.filter(|d| by_vcount.contains_key(&d.vcount) || by_sub_vcount.contains_key(&d.vcount))
|
||||
.count();
|
||||
println!(
|
||||
"\n{} distinct vcounts; {}/{} draws have a vcount present in {stage}.xpr ({:.1}%)",
|
||||
vcounts.len(),
|
||||
matched_draws,
|
||||
draws.len(),
|
||||
100.0 * matched_draws as f64 / draws.len().max(1) as f64
|
||||
);
|
||||
|
||||
let shown = if all { vcounts.len() } else { top_n.min(vcounts.len()) };
|
||||
println!("\nlargest capture vcounts (draws / distinct vbufs) → matching resources:");
|
||||
for &v in vcounts.iter().take(shown) {
|
||||
let n = draw_count[&v];
|
||||
let b = bufs[&v].len();
|
||||
let mut hit: Vec<String> = by_vcount.get(&v).cloned().unwrap_or_default();
|
||||
let sub: Vec<String> = by_sub_vcount.get(&v).cloned().unwrap_or_default();
|
||||
hit.extend(sub.into_iter().map(|s| format!("{s} (sub)")));
|
||||
let label = if hit.is_empty() {
|
||||
"— no resource".to_string()
|
||||
} else {
|
||||
let mut h = hit.clone();
|
||||
h.sort();
|
||||
h.truncate(6);
|
||||
format!("{}{}", h.join(", "), if hit.len() > 6 { ", …" } else { "" })
|
||||
};
|
||||
println!(" vcount {v:6} draws {n:4} bufs {b:3} {label}");
|
||||
}
|
||||
|
||||
// `--ship <id>`: every resource of one ship family, with its vertex count and
|
||||
// whether the capture drew it — this is what shows an all-`_l` (far-LOD) frame.
|
||||
if let Some(i) = args.iter().position(|a| a == "--ship") {
|
||||
if let Some(id) = args.get(i + 1) {
|
||||
let mut rows: Vec<(String, u32, usize)> = models
|
||||
.iter()
|
||||
.filter(|m| m.name.contains(id.as_str()))
|
||||
.map(|m| {
|
||||
let v = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
|
||||
(m.name.clone(), v, draw_count.get(&v).copied().unwrap_or(0))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let drawn = rows.iter().filter(|r| r.2 > 0).count();
|
||||
println!("\n{id} resources in {stage}.xpr ({drawn}/{} with a drawn vcount):", rows.len());
|
||||
for (name, v, n) in rows {
|
||||
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "—".into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other direction, for orientation: the container's biggest resources and
|
||||
// whether the capture ever drew that many vertices.
|
||||
let mut sizes: Vec<(u32, String)> = models
|
||||
.iter()
|
||||
.map(|m| (m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32, m.name.clone()))
|
||||
.collect();
|
||||
sizes.sort_unstable_by(|a, b| b.0.cmp(&a.0));
|
||||
println!("\nlargest resources in {stage}.xpr → drawn in the capture?");
|
||||
for (v, name) in sizes.iter().take(top_n.min(sizes.len())) {
|
||||
let n = draw_count.get(v).copied().unwrap_or(0);
|
||||
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "not drawn".into() });
|
||||
}
|
||||
}
|
||||
50
crates/sylpheed-formats/examples/iso_map.rs
Normal file
50
crates/sylpheed-formats/examples/iso_map.rs
Normal 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:?}"); }
|
||||
}
|
||||
90
crates/sylpheed-formats/examples/live_offsets.rs
Normal file
90
crates/sylpheed-formats/examples/live_offsets.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Where does each declared field sit inside the engine's live definition object?
|
||||
//!
|
||||
//! A live object carries no names, so the mapping has to be inferred: take a unit
|
||||
//! whose disc record SETS a field, look for that value in the dumped words of the
|
||||
//! matching live object, and keep the offsets that agree across several units.
|
||||
//!
|
||||
//! Run: live_offsets <disc-root> <live-dump.txt> <ID=0xVA> ...
|
||||
//! e.g. … UN_f106_TCAF_Destroyer=0xbd3ee300 UN_f105_TCAF_Cruiser=0xbd40e200
|
||||
use std::collections::BTreeMap;
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let disc = a.next().expect("disc root");
|
||||
let dump = a.next().expect("live dump");
|
||||
let pairs: Vec<(String, String)> = a
|
||||
.filter_map(|s| s.split_once('=').map(|(i, v)| (i.to_string(), v.to_string())))
|
||||
.collect();
|
||||
|
||||
// live: va -> offset -> f32
|
||||
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
|
||||
let mut cur = String::new();
|
||||
for line in std::fs::read_to_string(&dump).expect("dump").lines() {
|
||||
if let Some(rest) = line.strip_prefix("=== ") {
|
||||
cur = rest.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.len() >= 4 && f[1].starts_with('+') {
|
||||
if let (Ok(off), Ok(val)) =
|
||||
(usize::from_str_radix(&f[1][1..], 16), f[3].parse::<f32>())
|
||||
{
|
||||
live.entry(cur.clone()).or_default().insert(off, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
|
||||
// key -> offset -> how many units agree
|
||||
let mut votes: BTreeMap<String, BTreeMap<usize, usize>> = BTreeMap::new();
|
||||
let mut units = 0usize;
|
||||
for entry in pak.entries() {
|
||||
let Ok(bytes) = pak.read(entry) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||
let Some(id) = obj.get_raw("ID") else { continue };
|
||||
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
|
||||
let Some(words) = live.get(va) else { continue };
|
||||
units += 1;
|
||||
let mut numeric = 0usize;
|
||||
let mut hit = 0usize;
|
||||
for key in obj.tokens() {
|
||||
let Some(v) = obj.get_f32(key) else { continue };
|
||||
if !v.is_finite() || v == 0.0 {
|
||||
continue; // zero matches everywhere and says nothing
|
||||
}
|
||||
numeric += 1;
|
||||
let mut found = false;
|
||||
for (off, w) in words {
|
||||
if (*w - v).abs() <= v.abs() * 1e-6 {
|
||||
*votes.entry(key.clone()).or_default().entry(*off).or_default() += 1;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if found {
|
||||
hit += 1;
|
||||
}
|
||||
}
|
||||
eprintln!(" {id}: {numeric} numeric fields set on disc, {hit} found in the dumped window");
|
||||
}
|
||||
println!("{units} units correlated against their live objects\n");
|
||||
println!("{:<28} {:>8} offsets agreeing (votes)", "field", "unique?");
|
||||
for (key, offs) in &votes {
|
||||
let best = offs.iter().max_by_key(|(_, n)| **n).unwrap();
|
||||
if *best.1 < units.max(2) {
|
||||
continue; // needs every correlated unit to agree
|
||||
}
|
||||
let list: Vec<String> = offs
|
||||
.iter()
|
||||
.filter(|(_, n)| **n == *best.1)
|
||||
.map(|(o, n)| format!("+{o:03x}×{n}"))
|
||||
.collect();
|
||||
println!(
|
||||
"{:<28} {:>8} {}",
|
||||
key,
|
||||
if list.len() == 1 { "unique" } else { "ambig" },
|
||||
list.join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
53
crates/sylpheed-formats/examples/locate_draw.rs
Normal file
53
crates/sylpheed-formats/examples/locate_draw.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! How many copies of a captured buffer does a container hold?
|
||||
//!
|
||||
//! The twins showed that two resources decoding to one buffer can mean the
|
||||
//! container really holds two (mirrored) copies and our scan found only one.
|
||||
//! This asks that question for any draw: give it a `vbase`, and it reports every
|
||||
//! offset whose leading vertices match the draw's dumped positions — directly, and
|
||||
//! X-mirrored.
|
||||
//!
|
||||
//! Usage: locate_draw <container.xpr> <capture.log> <vbase-hex>...
|
||||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let text = std::fs::read_to_string(&a[2]).expect("log");
|
||||
let mut draws = parse_capture(&text);
|
||||
if draws.is_empty() {
|
||||
draws = parse_drawlog(&text);
|
||||
}
|
||||
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
|
||||
for want in &a[3..] {
|
||||
let vb = u32::from_str_radix(want.trim_start_matches("0x"), 16).expect("hex vbase");
|
||||
let Some(d) = draws.iter().find(|d| d.vbase == vb) else {
|
||||
println!("vbase 0x{vb:08X}: not in this log");
|
||||
continue;
|
||||
};
|
||||
let n = d.pos.len().min(8);
|
||||
let mut direct = Vec::new();
|
||||
let mut mirror = Vec::new();
|
||||
for o in (0..bytes.len().saturating_sub(12 + 64 * 24)).step_by(4) {
|
||||
for (flip, out) in [(1.0f32, &mut direct), (-1.0f32, &mut mirror)] {
|
||||
let hit = (0..n).all(|k| {
|
||||
let at = o + k * 24;
|
||||
(be(at) - flip * d.pos[k][0]).abs() <= 1e-4
|
||||
&& (be(at + 4) - d.pos[k][1]).abs() <= 1e-4
|
||||
&& (be(at + 8) - d.pos[k][2]).abs() <= 1e-4
|
||||
});
|
||||
if hit {
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"vbase 0x{vb:08X} vcount={}: {} direct copy/copies {:x?}, {} mirrored {:x?}",
|
||||
d.vcount,
|
||||
direct.len(),
|
||||
&direct[..],
|
||||
mirror.len(),
|
||||
&mirror[..]
|
||||
);
|
||||
}
|
||||
}
|
||||
46
crates/sylpheed-formats/examples/manifest_map.rs
Normal file
46
crates/sylpheed-formats/examples/manifest_map.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Validation: dump the parsed mission/phase cutscene map from the real manifest.
|
||||
use sylpheed_formats::movie_manifest::{self, MovieKind};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC");
|
||||
let arc = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let man = arc
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| arc.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let entries = movie_manifest::parse(&man);
|
||||
let mut counts = [0usize; 5];
|
||||
for e in &entries {
|
||||
counts[match e.kind {
|
||||
MovieKind::System => 0,
|
||||
MovieKind::Intro => 1,
|
||||
MovieKind::Phase => 2,
|
||||
MovieKind::PhaseEnd => 3,
|
||||
MovieKind::Supply => 4,
|
||||
}] += 1;
|
||||
let m = e.mission.map(|x| x.to_string()).unwrap_or_default();
|
||||
let p = e.phase.map(|x| x.to_string()).unwrap_or_default();
|
||||
println!(
|
||||
"{:<22} {:<9} m={:<2} p={:<1} {:<16} {:<14} tel={:<14} sub={}",
|
||||
e.slot,
|
||||
format!("{:?}", e.kind),
|
||||
m,
|
||||
p,
|
||||
e.movie,
|
||||
e.voice_token.as_deref().unwrap_or("-"),
|
||||
e.telop.as_deref().unwrap_or("-"),
|
||||
e.subtitle.as_deref().unwrap_or("-"),
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"TOTAL {} entries — System={} Intro={} Phase={} PhaseEnd={} Supply={}",
|
||||
entries.len(),
|
||||
counts[0],
|
||||
counts[1],
|
||||
counts[2],
|
||||
counts[3],
|
||||
counts[4]
|
||||
);
|
||||
}
|
||||
86
crates/sylpheed-formats/examples/miss_targets.rs
Normal file
86
crates/sylpheed-formats/examples/miss_targets.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! Which container should the next runtime capture aim at?
|
||||
//!
|
||||
//! The 2026-08-13 stage-05 capture showed that a mission keeps several containers
|
||||
//! resident and that its OWN `Stage_SNN.xpr` is among them (the f101 ACROPOLIS was
|
||||
//! drawn from `Stage_S05.xpr`), so a capture can be aimed by choosing the mission.
|
||||
//! This ranks the targets: for every XBG7 resource on the disc it records the
|
||||
//! containers where it decodes and the ones where it does not, then reports
|
||||
//!
|
||||
//! * resources that decode **nowhere** — the real gaps, where a capture is the
|
||||
//! only ground truth left;
|
||||
//! * per container, how many of those it holds (fly that mission);
|
||||
//! * resources that miss in one container but decode in another — a defect worth
|
||||
//! fixing, but the geometry is already recoverable, so a capture is not needed.
|
||||
//!
|
||||
//! Usage: miss_targets <resource3d_dir>
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
// resource -> (containers where it decodes, containers where it misses)
|
||||
let mut ok: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
let mut miss: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
let declared: BTreeSet<String> = xbg7_resource_names(&bytes).into_iter().collect();
|
||||
if declared.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let decoded: BTreeSet<String> =
|
||||
Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
|
||||
for n in &declared {
|
||||
if decoded.contains(n) {
|
||||
ok.entry(n.clone()).or_default().insert(where_.clone());
|
||||
} else {
|
||||
miss.entry(n.clone()).or_default().insert(where_.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let never: BTreeMap<&String, &BTreeSet<String>> =
|
||||
miss.iter().filter(|(n, _)| !ok.contains_key(*n)).collect();
|
||||
let recoverable: Vec<&String> = miss.keys().filter(|n| ok.contains_key(*n)).collect();
|
||||
|
||||
println!("resources that decode NOWHERE: {}", never.len());
|
||||
println!("resources that miss somewhere but decode elsewhere: {}\n", recoverable.len());
|
||||
|
||||
// Rank containers by how many never-decoding resources they hold.
|
||||
let mut per_container: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
|
||||
for (n, wheres) in &never {
|
||||
for w in wheres.iter() {
|
||||
per_container.entry(w).or_default().push(n);
|
||||
}
|
||||
}
|
||||
let mut ranked: Vec<_> = per_container.iter().collect();
|
||||
ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
|
||||
println!("container never-decoding resources it holds");
|
||||
for (w, v) in ranked.iter().take(14) {
|
||||
let sample: Vec<&str> = v.iter().take(6).map(|s| s.as_str()).collect();
|
||||
println!("{:<26} {:>3} {}", w, v.len(), sample.join(", "));
|
||||
}
|
||||
|
||||
// Exclusives: a never-decoding resource that only ONE container holds is only
|
||||
// reachable through whatever loads that container.
|
||||
let excl: Vec<(&&String, &&BTreeSet<String>)> =
|
||||
never.iter().filter(|(_, w)| w.len() == 1).collect();
|
||||
println!("\nof the never-decoding, {} live in exactly one container", excl.len());
|
||||
let mut by_one: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
|
||||
for (n, w) in &excl {
|
||||
by_one.entry(w.iter().next().unwrap()).or_default().push(n);
|
||||
}
|
||||
let mut b: Vec<_> = by_one.iter().collect();
|
||||
b.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
|
||||
for (w, v) in b.iter().take(10) {
|
||||
println!(" {:<24} {:>3} {}", w, v.len(), v.iter().take(5).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
}
|
||||
47
crates/sylpheed-formats/examples/mission_map.rs
Normal file
47
crates/sylpheed-formats/examples/mission_map.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
crates/sylpheed-formats/examples/node_dump.rs
Normal file
89
crates/sylpheed-formats/examples/node_dump.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! RE diagnostic: raw-dump a composite scene graph's node records — name, table
|
||||
//! pointers, and the FULL joint-table slot values (not just the 8 the decoder
|
||||
//! currently reads) — to derive the true TRS slot layout against captured
|
||||
//! ground truth.
|
||||
//! cargo run --release --example node_dump -- <Stage.xpr path> <composite name> [slots]
|
||||
use sylpheed_formats::mesh::xbg7_descriptor_range;
|
||||
|
||||
fn be32(d: &[u8], o: usize) -> u32 {
|
||||
if o + 4 > d.len() {
|
||||
return 0;
|
||||
}
|
||||
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
||||
}
|
||||
fn be_f64(d: &[u8], o: usize) -> f64 {
|
||||
if o + 8 > d.len() {
|
||||
return f64::NAN;
|
||||
}
|
||||
f64::from_be_bytes(d[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let comp = &a[2];
|
||||
let nslots: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(12);
|
||||
let (desc, desc_end) = xbg7_descriptor_range(&bytes, comp).expect("composite found");
|
||||
let d = &bytes[desc..desc_end];
|
||||
println!("descriptor [{desc:#x}..{desc_end:#x}) len {:#x}", d.len());
|
||||
|
||||
let mut i = 0usize;
|
||||
while i + 3 <= d.len() {
|
||||
let starts = (i + 4 <= d.len() && &d[i..i + 4] == b"rou_")
|
||||
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
|
||||
if !starts {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i;
|
||||
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||||
j += 1;
|
||||
}
|
||||
let name = std::str::from_utf8(&d[i..j]).unwrap_or("?").to_string();
|
||||
if name.ends_with("_col") || name.ends_with("_spc") || name.ends_with("_gls") || name.ends_with("_lum") {
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
// find the 0xFFFFFFFF end marker
|
||||
let mut ff = i;
|
||||
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||||
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||||
ff += 4;
|
||||
}
|
||||
if ff >= scan_end || ff < 0x10 {
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
let t44 = be32(d, ff - 0x10) as usize;
|
||||
let t48 = be32(d, ff - 0x0C) as usize;
|
||||
let child = be32(d, ff - 0x08) as usize;
|
||||
let sib = be32(d, ff - 0x04) as usize;
|
||||
// Dump the record's u32s between name end and marker for structure context.
|
||||
let rec_u32: Vec<String> = ((j + 1)..ff)
|
||||
.step_by(4)
|
||||
.map(|o| format!("{:08X}", be32(d, o)))
|
||||
.collect();
|
||||
println!("\nNODE {name} @{i:#x} ff@{ff:#x} t44={t44:#x} t48={t48:#x} child={child:#x} sib={sib:#x}");
|
||||
println!(" rec: {}", rec_u32.join(" "));
|
||||
if t44 != 0 && t44 < d.len() {
|
||||
// Joint table: nslots pointer slots. Each slot record appears to be a
|
||||
// keyframe track: a value ALSO sits 0x48 BEFORE the pointed address
|
||||
// (the rest-pose key A), while the decoder currently reads ptr+8 (B).
|
||||
for k in 0..nslots {
|
||||
let p = be32(d, t44 + k * 4) as usize;
|
||||
if p == 0 || p + 24 > d.len() {
|
||||
continue;
|
||||
}
|
||||
let a = if p >= 0x48 { be_f64(d, p - 0x48) } else { f64::NAN };
|
||||
let b = be_f64(d, p + 8);
|
||||
let head = if p >= 0x50 { be32(d, p - 0x50) } else { 0 };
|
||||
let t_a = if p >= 0x50 { be_f64(d, p - 0x50) } else { f64::NAN };
|
||||
let t_b = be_f64(d, p);
|
||||
println!(
|
||||
" slot{k:2} @{p:#x} head={head:08X}: A={a:12.5} (t={t_a:8.3}) B={b:12.5} (t={t_b:8.3})"
|
||||
);
|
||||
}
|
||||
}
|
||||
i = j.max(i + 1);
|
||||
}
|
||||
}
|
||||
20
crates/sylpheed-formats/examples/node_scale.rs
Normal file
20
crates/sylpheed-formats/examples/node_scale.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! Dump a composite's scene nodes with their scale — the transform the static
|
||||
//! assembler copies verbatim into a placement.
|
||||
//! Usage: node_scale <container.xpr> <composite name>
|
||||
use sylpheed_formats::mesh::scene_world_nodes;
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
for n in scene_world_nodes(&bytes, &a[2]) {
|
||||
// Row norms of `m`: a rotation has all three at 1.0; anything else is a
|
||||
// scale baked into the matrix, which `s` does not show.
|
||||
let norm = |r: [f32; 3]| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
|
||||
println!(
|
||||
"{:<26} s[{:7.3}{:7.3}{:7.3}] |m rows|[{:7.3}{:7.3}{:7.3}] t[{:9.1}{:9.1}{:9.1}]",
|
||||
n.resource,
|
||||
n.s[0], n.s[1], n.s[2],
|
||||
norm(n.m[0]), norm(n.m[1]), norm(n.m[2]),
|
||||
n.t[0], n.t[1], n.t[2]
|
||||
);
|
||||
}
|
||||
}
|
||||
129
crates/sylpheed-formats/examples/pad_shift_audit.rs
Normal file
129
crates/sylpheed-formats/examples/pad_shift_audit.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! How much geometry on the disc was being read one index element late?
|
||||
//!
|
||||
//! The pad-scoring fix (2026-08-13) makes `anchor_pool_mesh` choose the pad whose
|
||||
//! index run is cleanest instead of the first that validates. This measures the
|
||||
//! blast radius: per container, how many single-block resources now decode from a
|
||||
//! run that is NOT the old first-match (`pad 0`) one, and how many decoded runs
|
||||
//! still contain degenerate triangles (the shift signature) afterwards.
|
||||
//!
|
||||
//! Usage: pad_shift_audit <resource3d_dir>
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> u32 {
|
||||
((b[at] as u32) << 8) | b[at + 1] as u32
|
||||
}
|
||||
|
||||
/// Fraction of triangles whose face normal agrees with the stored normals,
|
||||
/// folded to `max(na, 1-na)` so both authored windings read as ≈1.
|
||||
fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
|
||||
let (mut agree, mut n) = (0usize, 0usize);
|
||||
for t in idx.chunks_exact(3) {
|
||||
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
if a == b || b == c || a == c || a.max(b).max(c) >= pos.len() || a >= nrm.len() {
|
||||
continue;
|
||||
}
|
||||
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
|
||||
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
|
||||
let f = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
let nv = nrm[a];
|
||||
n += 1;
|
||||
if f[0] * nv[0] + f[1] * nv[1] + f[2] * nv[2] > 0.0 {
|
||||
agree += 1;
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let fr = agree as f32 / n as f32;
|
||||
fr.max(1.0 - fr)
|
||||
}
|
||||
|
||||
fn degenerate(idx: &[u32]) -> usize {
|
||||
idx.chunks_exact(3)
|
||||
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
|
||||
.count()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let (mut total, mut moved, mut still_degen) = (0usize, 0usize, 0usize);
|
||||
let mut weak = 0usize;
|
||||
let mut weak_wind: Vec<(f32, f32, String)> = Vec::new();
|
||||
let mut worst: Vec<(usize, String, String)> = Vec::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
let mut c_moved = 0usize;
|
||||
for m in Xbg7Model::stage_models(&bytes) {
|
||||
// Degeneracy is counted over EVERY sub-mesh (grouped pools included —
|
||||
// they have their own pad choice); the pad-0 comparison below only
|
||||
// applies to single-block resources, whose run starts at `vb - 2n`.
|
||||
for sm in &m.meshes {
|
||||
let d = degenerate(&sm.indices);
|
||||
if d > 0 {
|
||||
still_degen += 1;
|
||||
worst.push((d, m.name.clone(), where_.clone()));
|
||||
}
|
||||
}
|
||||
if m.meshes.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let sm = &m.meshes[0];
|
||||
let Some(vb) = sm.vbuf_offset else { continue };
|
||||
let n = sm.indices.len();
|
||||
if n < 6 || vb < n * 2 {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
let pad0: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 + k * 2)).collect();
|
||||
if pad0 != sm.indices {
|
||||
moved += 1;
|
||||
c_moved += 1;
|
||||
// How strong was the evidence for moving? A pad-0 run with
|
||||
// degenerate triangles is a decisive shift signature; one with
|
||||
// none moved on winding alone, which is weaker.
|
||||
if degenerate(&pad0) == 0 {
|
||||
weak += 1;
|
||||
// Record how far apart the two runs' winding is, to see
|
||||
// whether these look like the same shift signature as the
|
||||
// decisive cases (pad-0 winding drifting toward 0.5) or like
|
||||
// noise between two equally clean readings.
|
||||
let w0 = winding(&pad0, &sm.positions, &sm.normals);
|
||||
let w2: f32 = {
|
||||
let l2: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 - 2 + k * 2)).collect();
|
||||
winding(&l2, &sm.positions, &sm.normals)
|
||||
};
|
||||
weak_wind.push((w0, w2, m.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if c_moved > 0 {
|
||||
println!("{where_:<24} {c_moved} resources read from a non-pad-0 index run");
|
||||
}
|
||||
}
|
||||
println!("\n{moved} of {total} single-block resources moved off the pad-0 run");
|
||||
println!("{still_degen} decoded runs still contain a degenerate triangle");
|
||||
println!("of the moved, {weak} had a pad-0 run with no degenerate triangle (moved on winding alone)");
|
||||
let clear = weak_wind.iter().filter(|(w0, w2, _)| *w2 - *w0 > 0.15).count();
|
||||
let close = weak_wind.iter().filter(|(w0, w2, _)| (*w2 - *w0).abs() <= 0.05).count();
|
||||
println!(" of those: {clear} show the shift signature (pad-2 winding > pad-0 by >0.15), {close} are within 0.05 (noise)");
|
||||
for (w0, w2, n) in weak_wind.iter().take(8) {
|
||||
println!(" {n}: pad0 wind {w0:.3} -> pad2 {w2:.3}");
|
||||
}
|
||||
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
|
||||
for (d, n, w) in worst.iter().take(15) {
|
||||
println!(" {n} ({w}): {d} degenerate triangles");
|
||||
}
|
||||
}
|
||||
13
crates/sylpheed-formats/examples/params.rs
Normal file
13
crates/sylpheed-formats/examples/params.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
fn main(){
|
||||
let a:Vec<String>=std::env::args().collect();
|
||||
let bytes=std::fs::read(&a[1]).unwrap();
|
||||
let filter=a.get(2).cloned().unwrap_or_default();
|
||||
for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) {
|
||||
if !filter.is_empty() && !n.contains(&filter) { continue }
|
||||
if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) {
|
||||
let strides = sylpheed_formats::mesh::debug_decl_strides(&bytes, &n);
|
||||
println!("{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}",
|
||||
&m[..m.len().min(4)], strides);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/sylpheed-formats/examples/part_pos.rs
Normal file
20
crates/sylpheed-formats/examples/part_pos.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! Print a resource's first N decoded positions (buffer order), for matching a
|
||||
//! capture draw's `pos:` dump to a part:
|
||||
//! cargo run --release --example part_pos -- <xpr path> <resource> [n]
|
||||
use std::collections::HashSet;
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let n: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(4);
|
||||
let want: HashSet<String> = [a[2].clone()].into();
|
||||
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
|
||||
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
print!("{} vtx={}:", m.name, vc);
|
||||
for p in m.meshes.iter().flat_map(|s| &s.positions).take(n) {
|
||||
print!(" ({:.4},{:.4},{:.4})", p[0], p[1], p[2]);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
29
crates/sylpheed-formats/examples/pool_window.rs
Normal file
29
crates/sylpheed-formats/examples/pool_window.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! Raw token sequence around a key, for one record — the ground truth for
|
||||
//! "is this field actually defaulted, or is our reader missing it?"
|
||||
//! Run: pool_window <disc-root> <ID substring> <key>
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).unwrap();
|
||||
let want = std::env::args().nth(2).unwrap();
|
||||
let key = std::env::args().nth(3).unwrap();
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||
for e in pak.entries() {
|
||||
let Ok(b) = pak.read(e) else { continue };
|
||||
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||
let Some(id) = o.get_raw("ID") else { continue };
|
||||
if !id.contains(&want) { continue; }
|
||||
let t = o.tokens();
|
||||
println!("=== {id} get_f32({key}) = {:?}", o.get_f32(&key));
|
||||
for (i, tok) in t.iter().enumerate() {
|
||||
if tok == &key {
|
||||
let lo = i.saturating_sub(6);
|
||||
let hi = (i + 7).min(t.len());
|
||||
for j in lo..hi {
|
||||
println!(" [{j}]{} {:?}", if j == i { " <-- key" } else { " " }, t[j]);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
crates/sylpheed-formats/examples/probe_anchor.rs
Normal file
6
crates/sylpheed-formats/examples/probe_anchor.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let vb: usize = a[3].parse().unwrap();
|
||||
println!("{:?}", sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3));
|
||||
}
|
||||
36
crates/sylpheed-formats/examples/resolve_all.rs
Normal file
36
crates/sylpheed-formats/examples/resolve_all.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, slb, PakArchive};
|
||||
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
|
||||
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
|
||||
fn contains(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
|
||||
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
|
||||
fn main(){
|
||||
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
|
||||
let code="eng";
|
||||
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
|
||||
let ids=movie_voice::registry_voice_ids(®istry);
|
||||
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries=PakArchive::parse_toc(&stoc).unwrap();
|
||||
for movie in ["ADV","RT01A","RT01B","RT01C_1","S00A","S01A","hokyu_LS_s02A","hokyu_LS_s09A","hokyu_LS_s02H","hokyu_DS_s07H"]{
|
||||
let Some(token)=movie_manifest::voice_token(&manifest,movie) else { println!("{movie:16} no token"); continue };
|
||||
let Some(&id)=ids.get(&token) else { println!("{movie:16} token {token} not in registry"); continue };
|
||||
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else { println!("{movie:16} no anchor for {token}"); continue };
|
||||
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
|
||||
let window=rg(&disc,win_start,8*1024*1024);
|
||||
let Some(end_local)=movie_voice::find_descriptor(&window,id) else { println!("{movie:16} id {id}: desc NOT FOUND"); continue };
|
||||
let end=win_start+end_local as u64;
|
||||
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1))
|
||||
.or_else(||movie_voice::find_descriptor_before(&window,end_local))
|
||||
.map(|o|win_start+o as u64)
|
||||
.filter(|&s|s<end && end-s<1_500_000)
|
||||
.unwrap_or(anchor);
|
||||
let region=rg(&disc,start,(end-start) as usize);
|
||||
let mut riffs=slb::to_xma_riffs(®ion);
|
||||
if riffs.is_empty(){ riffs=slb::to_xma_riff_best(®ion).into_iter().collect(); }
|
||||
let mut d="?".into();
|
||||
if let Some(r)=riffs.first(){ let xp=format!("/tmp/ra2_{movie}.xma.wav");let wp=format!("/tmp/ra2_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!("{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s", (end-start)/1024, riffs.len());
|
||||
}
|
||||
}
|
||||
60
crates/sylpheed-formats/examples/roster_target.rs
Normal file
60
crates/sylpheed-formats/examples/roster_target.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Which mission should the next unit harvest fly?
|
||||
//!
|
||||
//! Unit definitions are instantiated per stage, so coverage grows by visiting
|
||||
//! missions — but only if the mission fields units we have not read yet. This
|
||||
//! ranks every stage by how many of its roster units are missing from the
|
||||
//! harvested CSV.
|
||||
//!
|
||||
//! Usage: roster_target <disc-root> <unit-runtime-fields.csv>
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let pak = PakArchive::open(format!("{}/dat/GP_MAIN_GAME_E.pak", a[1])).expect("pak");
|
||||
let have: BTreeSet<String> = std::fs::read_to_string(&a[2])
|
||||
.expect("csv")
|
||||
.lines()
|
||||
.skip(1)
|
||||
.filter_map(|l| l.split(',').next().map(str::to_string))
|
||||
.collect();
|
||||
// Label each roster by STAGE: the TOC stores a hash of the original path, and
|
||||
// the table names are known (`EnumUnit_SNN.tbl`), so hashing the candidates
|
||||
// maps entries back to stages. Most rosters carry no `UN_S<NN>_` prop id, which
|
||||
// is all `UnitRoster::stage` can infer from.
|
||||
let mut by_hash: BTreeMap<u32, String> = BTreeMap::new();
|
||||
// The TOC hashes a PATH, and these tables live under a directory — try the
|
||||
// known schemes (see hash::TOC_NAME_SCHEMES) rather than the bare name.
|
||||
for i in 1..=29 {
|
||||
let stage = format!("S{i:02}");
|
||||
for pre in ["", "unit\\", "battle\\", "stage\\", "enemy\\"] {
|
||||
for suf in [".tbl", ""] {
|
||||
by_hash.insert(name_hash(&format!("{pre}EnumUnit_{stage}{suf}")), stage.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<(usize, String, Vec<String>)> = Vec::new();
|
||||
for e in pak.entries() {
|
||||
let Some(stage) = by_hash.get(&e.name_hash).cloned() else { continue };
|
||||
let Ok(b) = pak.read(e) else { continue };
|
||||
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||
let mut units: Vec<String> = Vec::new();
|
||||
for id in o.tokens().iter().filter(|s| s.starts_with("UN_")) {
|
||||
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
|
||||
if !prop && !units.iter().any(|u| u == id) {
|
||||
units.push(id.clone());
|
||||
}
|
||||
}
|
||||
let missing: Vec<String> = units.iter().filter(|u| !have.contains(*u)).cloned().collect();
|
||||
rows.push((missing.len(), stage, missing));
|
||||
}
|
||||
rows.sort_by_key(|(n, _, _)| std::cmp::Reverse(*n));
|
||||
println!("units already harvested: {}\n", have.len());
|
||||
println!("stage missing roster units not yet read");
|
||||
for (n, stage, missing) in rows.iter().take(12) {
|
||||
let s: Vec<&str> = missing.iter().take(6).map(|s| s.as_str()).collect();
|
||||
println!("{stage:<8} {n:>5} {}", s.join(", "));
|
||||
}
|
||||
}
|
||||
111
crates/sylpheed-formats/examples/screen_layout.rs
Normal file
111
crates/sylpheed-formats/examples/screen_layout.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Dump a UI screen's full layout from one RATC bundle: the element declaration
|
||||
//! table (what is drawn, in back-to-front order, with pivots and parent links)
|
||||
//! plus the placement region that follows it (per element: a keyframe group of
|
||||
//! scale / tint / X / Y).
|
||||
//!
|
||||
//! See docs/re/structures/ui-rat-layout.md. Run:
|
||||
//! cargo run -p sylpheed-formats --example screen_layout -- <PAK> [0xHASH]
|
||||
//! With no hash, the largest RATC entry in the pak is used.
|
||||
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let pak = args.next().expect("usage: screen_layout <pak> [0xHASH]");
|
||||
let want = args.next();
|
||||
let arc = PakArchive::open(&pak).expect("open pak");
|
||||
|
||||
let bytes = match want {
|
||||
Some(h) if h.starts_with("0x") => {
|
||||
let h = u32::from_str_radix(h.trim_start_matches("0x"), 16).expect("hash");
|
||||
arc.read_by_hash(h).expect("entry").expect("decompress")
|
||||
}
|
||||
Some(name) => arc.read_by_name(&name).expect("entry present").expect("decompress"),
|
||||
None => arc
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|e| arc.read(e).ok())
|
||||
.filter(|b| b.len() > 16 && &b[..4] == b"RATC")
|
||||
.max_by_key(|b| b.len())
|
||||
.expect("no RATC entry"),
|
||||
};
|
||||
assert_eq!(&bytes[..4], b"RATC", "not a RATC bundle");
|
||||
|
||||
let count = be32(&bytes, 0x14) as usize;
|
||||
let mut names = Vec::with_capacity(count);
|
||||
let mut meta = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let e = &bytes[0x20 + i * 60..0x20 + (i + 1) * 60];
|
||||
let name = String::from_utf8_lossy(&e[..28])
|
||||
.trim_end_matches('\0')
|
||||
.trim_end_matches(char::from(0))
|
||||
.to_string();
|
||||
let parent = be32(e, 32);
|
||||
let kind = be32(e, 40);
|
||||
let (px, py) = (be32(e, 48), be32(e, 52));
|
||||
names.push(name);
|
||||
meta.push((parent, kind, px, py));
|
||||
}
|
||||
|
||||
// Placement region: groups of (u32 element index, u32 keyframe count) then
|
||||
// `count` 40-byte keyframes; the X/Y sit 12 bytes into the scale/tint block.
|
||||
let mut pos = 0x20 + count * 60;
|
||||
// X/Y are SIGNED: off-screen animation starts are negative (e.g. -516).
|
||||
let mut placements: Vec<Vec<(i32, i32, u32)>> = vec![Vec::new(); count];
|
||||
for _ in 0..count {
|
||||
if pos + 8 > bytes.len() { break }
|
||||
let idx = be32(&bytes, pos) as usize;
|
||||
let frames = be32(&bytes, pos + 4) as usize;
|
||||
if idx >= count || frames == 0 || frames > 4096 { break }
|
||||
let first = pos + 28; // header + lead-in, verified on the pause bundles
|
||||
let mut group = Vec::with_capacity(frames);
|
||||
for k in 0..frames {
|
||||
let blk = first + k * 40;
|
||||
if blk + 20 > bytes.len() { break }
|
||||
group.push((be32(&bytes, blk + 12) as i32, be32(&bytes, blk + 16) as i32, be32(&bytes, blk + 20)));
|
||||
}
|
||||
placements[idx] = group;
|
||||
pos = first + frames * 40 - 20;
|
||||
}
|
||||
|
||||
println!("{} elements", count);
|
||||
println!("{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} placement", "#", "element", "parent", "kind", "pivot", "kf");
|
||||
for i in 0..count {
|
||||
let (parent, kind, px, py) = meta[i];
|
||||
let p = &placements[i];
|
||||
// A group is an in → hold → out animation, so neither the first nor the
|
||||
// last keyframe is where the element sits: report the MAX-DWELL one
|
||||
// (longest gap to the next keyframe's time).
|
||||
let shown = if p.is_empty() {
|
||||
"—".into()
|
||||
} else if p.len() == 1 {
|
||||
format!("({},{})", p[0].0, p[0].1)
|
||||
} else {
|
||||
let mut best = (0usize, 0u32);
|
||||
for k in 0..p.len() - 1 {
|
||||
let d = p[k + 1].2.saturating_sub(p[k].2);
|
||||
if d > best.1 { best = (k, d) }
|
||||
}
|
||||
let r = p[best.0];
|
||||
format!(
|
||||
"rest ({},{}) t={}..{} [{}]",
|
||||
r.0, r.1, r.2, p[best.0 + 1].2,
|
||||
p.iter().map(|(x, y, t)| format!("{t}:{x},{y}")).collect::<Vec<_>>().join(" ")
|
||||
)
|
||||
};
|
||||
println!(
|
||||
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {}",
|
||||
i,
|
||||
names[i],
|
||||
if parent == u32::MAX { "-".into() } else { parent.to_string() },
|
||||
format!("{kind:#x}"),
|
||||
format!("({px},{py})"),
|
||||
p.len(),
|
||||
shown
|
||||
);
|
||||
}
|
||||
}
|
||||
262
crates/sylpheed-formats/examples/shared_vbase_check.rs
Normal file
262
crates/sylpheed-formats/examples/shared_vbase_check.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
//! Ask the runtime capture whether two resources that our decoder gives the
|
||||
//! **same geometry** really are the same geometry.
|
||||
//!
|
||||
//! Our XBG7 anchor scan sometimes lands two different resource names on one
|
||||
//! vertex buffer. Statics cannot separate "the container genuinely reuses a
|
||||
//! buffer" from "the scan picked the wrong candidate" — but a capture can: the
|
||||
//! engine uploads a buffer per resource and reuses one only 3.4 % of the time
|
||||
//! (see docs/re/structures/xbg7-mesh.md), so a group of `k` resources our
|
||||
//! decoder collapses onto one buffer should show up as `k` distinct `vbase`s
|
||||
//! carrying that same vertex count and those same positions. Fewer means at
|
||||
//! most one member of the group is really that geometry.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example shared_vbase_check -- \
|
||||
//! <Stage_SNN.xpr> <capture.log>...
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Quantised position key — the logs print 4 decimals, so compare at that scale.
|
||||
fn key(p: [f32; 3]) -> (i64, i64, i64) {
|
||||
(
|
||||
(p[0] as f64 * 1e4).round() as i64,
|
||||
(p[1] as f64 * 1e4).round() as i64,
|
||||
(p[2] as f64 * 1e4).round() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Where in the container does a captured buffer live? POSITION is `f32×3` big
|
||||
/// endian at vertex offset 0, so a draw's dumped positions are a literal byte
|
||||
/// pattern: find the first one, then confirm the next few at a fixed stride.
|
||||
/// This turns a capture into ground truth for a resource we mis-anchored.
|
||||
fn locate_run(bytes: &[u8], pos: &[[f32; 3]]) -> Vec<(usize, usize)> {
|
||||
if pos.len() < 4 {
|
||||
return Vec::new();
|
||||
}
|
||||
// The log prints 4 decimals, so match on value with the printing tolerance
|
||||
// rather than on bytes.
|
||||
let be = |b: &[u8], at: usize| f32::from_be_bytes(b[at..at + 4].try_into().unwrap());
|
||||
let same = |b: &[u8], at: usize, p: [f32; 3]| {
|
||||
at + 12 <= b.len() && (0..3).all(|c| (be(b, at + c * 4) - p[c]).abs() <= 1e-4)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for o in (0..bytes.len().saturating_sub(12)).step_by(4) {
|
||||
if !same(bytes, o, pos[0]) {
|
||||
continue;
|
||||
}
|
||||
for stride in (12..=64).step_by(4) {
|
||||
if (1..4).all(|k| same(bytes, o + k * stride, pos[k])) {
|
||||
out.push((o, stride));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: shared_vbase_check <Stage_SNN.xpr> <capture.log>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let bytes = std::fs::read(&args[1]).expect("read container");
|
||||
|
||||
// Every draw from every log, keyed by vertex count.
|
||||
// Keep the logs apart: each is its own emulator run, so a `vbase` only
|
||||
// means something within one log.
|
||||
let mut logs: Vec<(String, Vec<CapturedDraw>)> = Vec::new();
|
||||
for log in args[2..].iter().filter(|a| !a.starts_with("--")) {
|
||||
let text = std::fs::read_to_string(log).expect("read log");
|
||||
let mut d = parse_capture(&text);
|
||||
if d.is_empty() {
|
||||
d = parse_drawlog(&text);
|
||||
}
|
||||
eprintln!("{log}: {} draws", d.len());
|
||||
logs.push((log.rsplit('/').next().unwrap_or(log).to_string(), d));
|
||||
}
|
||||
|
||||
// `--map`: is a draw's guest `vbase` just the container file offset plus a
|
||||
// constant? If the container is uploaded contiguously it is — and then a
|
||||
// capture names the exact offset of every buffer the engine drew, which is
|
||||
// ground truth the anchor scan currently has to guess at.
|
||||
if args.iter().any(|a| a == "--map") {
|
||||
for (log, draws) in &logs {
|
||||
let mut seen: BTreeSet<u32> = BTreeSet::new();
|
||||
let mut delta: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut unfound = 0usize;
|
||||
for d in draws {
|
||||
if d.pos.len() < 8 || d.vcount < 20 || !seen.insert(d.vbase) {
|
||||
continue;
|
||||
}
|
||||
let at = locate_run(&bytes, &d.pos);
|
||||
if at.is_empty() {
|
||||
unfound += 1;
|
||||
continue;
|
||||
}
|
||||
for (o, _) in at {
|
||||
*delta.entry(d.vbase as i64 - o as i64).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut top: Vec<_> = delta.iter().collect();
|
||||
top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
|
||||
println!("{log}: {} distinct vbases located, {unfound} not in this container", seen.len() - unfound);
|
||||
for (d, n) in top.iter().take(5) {
|
||||
println!(" vbase - offset = 0x{:X} ×{n}", d);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode the container and group resources by the exact geometry they got.
|
||||
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
|
||||
|
||||
// `--truth <base>`: with the container's guest load address (from `--map`),
|
||||
// every draw names a file offset. Print it against the offset our anchor
|
||||
// scan chose for each resource — a direct read-out of what we got wrong.
|
||||
if let Some(a) = args.iter().find_map(|a| a.strip_prefix("--truth=")) {
|
||||
let base = u32::from_str_radix(a.trim_start_matches("0x"), 16).expect("base");
|
||||
// Where the anchor scan actually put each sub-mesh — exact, from the
|
||||
// decoder, not inferred by searching for its leading vertices (the same
|
||||
// leading run occurs at several offsets in a container, so a search
|
||||
// cannot tell where a resource was anchored).
|
||||
let mut ours: BTreeMap<usize, Vec<(String, usize)>> = BTreeMap::new();
|
||||
for m in &models {
|
||||
for sub in &m.meshes {
|
||||
if let Some(o) = sub.vbuf_offset {
|
||||
ours.entry(o).or_default().push((m.name.clone(), sub.positions.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if args.iter().any(|a| a == "--anchors") {
|
||||
println!("{:<12} where our decode put each resource", "file offset");
|
||||
for (o, v) in &ours {
|
||||
for (n, c) in v {
|
||||
println!("0x{o:<10x} {n} ({c} verts)");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut drawn: BTreeMap<usize, u32> = BTreeMap::new();
|
||||
for (_, draws) in &logs {
|
||||
for d in draws {
|
||||
let off = d.vbase.wrapping_sub(base) as usize;
|
||||
if off < bytes.len() && d.vcount >= 20 {
|
||||
drawn.insert(off, d.vcount);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Which resources have the drawn vertex count, wherever we put them?
|
||||
// Right size + wrong place is a different bug from never finding it.
|
||||
let mut by_count: BTreeMap<usize, Vec<String>> = BTreeMap::new();
|
||||
for m in &models {
|
||||
let n: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
by_count.entry(n).or_default().push(m.name.clone());
|
||||
}
|
||||
// Is a capture-proven offset even a candidate the scan considers?
|
||||
// Absent ⇒ the run scan misses it; present ⇒ selection picked another.
|
||||
let starts: BTreeSet<usize> =
|
||||
sylpheed_formats::mesh::debug_vertex_run_starts(&bytes, 24).into_iter().collect();
|
||||
eprintln!("{} stride-24 candidate starts in this container", starts.len());
|
||||
println!("{:<12} {:>7} {:>9} {:<44} our resources with that vcount", "file offset", "vcount", "candidate", "claimed by our decode");
|
||||
for (off, vcount) in &drawn {
|
||||
let who = ours
|
||||
.get(off)
|
||||
.map(|v| {
|
||||
v.iter().map(|(n, c)| format!("{n}({c})")).collect::<Vec<_>>().join(", ")
|
||||
})
|
||||
.unwrap_or_else(|| "— NOBODY".into());
|
||||
let same = by_count
|
||||
.get(&(*vcount as usize))
|
||||
.map(|v| v.join(", "))
|
||||
.unwrap_or_else(|| "— none".into());
|
||||
// Nearest resource we anchored at or before this offset — the
|
||||
// likely owner of a buffer nobody claims.
|
||||
let near = ours
|
||||
.range(..=*off)
|
||||
.next_back()
|
||||
.map(|(o, v)| format!("{} @ -0x{:x}", v[0].0, off - o))
|
||||
.unwrap_or_default();
|
||||
let cand = if starts.contains(off) { "yes" } else { "NO" };
|
||||
println!("0x{off:<10x} {vcount:>7} {cand:>9} {who:<44} {same:<34} {near}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut groups: BTreeMap<Vec<(i64, i64, i64)>, Vec<String>> = BTreeMap::new();
|
||||
for m in &models {
|
||||
let pos: Vec<(i64, i64, i64)> =
|
||||
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).map(key).collect();
|
||||
if pos.is_empty() {
|
||||
continue;
|
||||
}
|
||||
groups.entry(pos).or_default().push(m.name.clone());
|
||||
}
|
||||
let shared: Vec<_> = groups.iter().filter(|(_, n)| n.len() > 1).collect();
|
||||
eprintln!(
|
||||
"{} models, {} distinct geometries, {} shared by >1 resource",
|
||||
models.len(),
|
||||
groups.len(),
|
||||
shared.len()
|
||||
);
|
||||
|
||||
for (pos, names) in shared {
|
||||
let vcount = pos.len() as u32;
|
||||
// A draw belongs to this geometry if every dumped position is one of
|
||||
// the decoded ones (the log dumps at most the first 64).
|
||||
let want: BTreeSet<(i64, i64, i64)> = pos.iter().copied().collect();
|
||||
println!("\n{} ({vcount} verts, {} resources)", names.join(" ≡ "), names.len());
|
||||
for (log, draws) in &logs {
|
||||
let hits: Vec<&CapturedDraw> = draws.iter().filter(|d| d.vcount == vcount).collect();
|
||||
let all: BTreeSet<u32> = hits.iter().map(|d| d.vbase).collect();
|
||||
let matching: Vec<&&CapturedDraw> = hits
|
||||
.iter()
|
||||
.filter(|d| !d.pos.is_empty() && d.pos.iter().all(|p| want.contains(&key(*p))))
|
||||
.collect();
|
||||
let ok: BTreeSet<u32> = matching.iter().map(|d| d.vbase).collect();
|
||||
// A buffer we do NOT match may still be the mirrored twin: same
|
||||
// geometry with x negated. That is the case our assembler papers
|
||||
// over with `apply_twin_mirrors`.
|
||||
let mirrored: BTreeSet<u32> = hits
|
||||
.iter()
|
||||
.filter(|d| !ok.contains(&d.vbase))
|
||||
.filter(|d| {
|
||||
!d.pos.is_empty()
|
||||
&& d.pos.iter().all(|p| want.contains(&key([-p[0], p[1], p[2]])))
|
||||
})
|
||||
.map(|d| d.vbase)
|
||||
.collect();
|
||||
println!(
|
||||
" {log:32} draws={:<5} vbases@vcount={:<3} ours={} mirrored={} other={}",
|
||||
hits.len(),
|
||||
all.len(),
|
||||
ok.len(),
|
||||
mirrored.len(),
|
||||
all.len() - ok.len() - mirrored.len()
|
||||
);
|
||||
// Where does each captured buffer live in the container? One
|
||||
// representative draw per vbase is enough.
|
||||
let mut done: BTreeSet<u32> = BTreeSet::new();
|
||||
for d in &hits {
|
||||
if d.pos.len() < 8 || !done.insert(d.vbase) {
|
||||
continue;
|
||||
}
|
||||
let kind = if ok.contains(&d.vbase) {
|
||||
"ours"
|
||||
} else if mirrored.contains(&d.vbase) {
|
||||
"mirror"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
let at = locate_run(&bytes, &d.pos);
|
||||
let shown: Vec<String> =
|
||||
at.iter().take(4).map(|(o, s)| format!("0x{o:x}/stride{s}")).collect();
|
||||
println!(
|
||||
" vbase=0x{:08X} [{kind:6}] in container at: {}",
|
||||
d.vbase,
|
||||
if shown.is_empty() { "NOT FOUND".into() } else { shown.join(" ") }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
crates/sylpheed-formats/examples/ship_audit.rs
Normal file
141
crates/sylpheed-formats/examples/ship_audit.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Audit static ship assembly across ALL stages: flag parts placed far outside
|
||||
//! their ship's cluster (placement bugs) and joint tracks with more than one
|
||||
//! keyframe (which the single-key TRS read does not model).
|
||||
//! cargo run --release --example ship_audit -- <resource3d dir>
|
||||
use std::collections::HashSet;
|
||||
use sylpheed_formats::mesh::{xbg7_descriptor_range, xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{assemble_ship, ships_in_container};
|
||||
|
||||
fn be32(d: &[u8], o: usize) -> u32 {
|
||||
if o + 4 > d.len() {
|
||||
return 0;
|
||||
}
|
||||
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
||||
}
|
||||
|
||||
/// Count joint-track records whose key count != 1 in a composite's descriptor.
|
||||
fn multikey_tracks(bytes: &[u8], comp: &str) -> usize {
|
||||
let Some((desc, desc_end)) = xbg7_descriptor_range(bytes, comp) else { return 0 };
|
||||
let d = &bytes[desc..desc_end];
|
||||
let mut n = 0usize;
|
||||
let mut i = 0usize;
|
||||
while i + 4 <= d.len() {
|
||||
let starts = &d[i..i.min(d.len() - 4) + 4] == b"rou_"
|
||||
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
|
||||
if !starts {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i;
|
||||
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||||
j += 1;
|
||||
}
|
||||
let mut ff = i;
|
||||
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||||
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||||
ff += 4;
|
||||
}
|
||||
if ff < scan_end && ff >= 0x10 {
|
||||
let t44 = be32(d, ff - 0x10) as usize;
|
||||
if t44 != 0 && t44 + 32 <= d.len() {
|
||||
for k in 0..8 {
|
||||
let p = be32(d, t44 + k * 4) as usize;
|
||||
if p >= 0x50 && p + 16 <= d.len() {
|
||||
let head = be32(d, p - 0x50);
|
||||
if head != 1 && head != 0 {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i = j.max(i + 1);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).unwrap();
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
n.starts_with("Stage_") && n.ends_with(".xpr")
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
|
||||
for path in entries {
|
||||
let stage = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
|
||||
// Multi-key animation tracks per composite (breaks the single-key read).
|
||||
for n in names.iter().filter(|n| n.contains("rou_") && !n.contains("break")) {
|
||||
let mk = multikey_tracks(&bytes, n);
|
||||
if mk > 0 {
|
||||
println!("MULTIKEY {stage} {n}: {mk} tracks");
|
||||
}
|
||||
}
|
||||
|
||||
for ship in ships_in_container(&bytes) {
|
||||
if ship.parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let placed = assemble_ship(&bytes, &ship.id, true);
|
||||
if placed.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
// Per-placement world centroid.
|
||||
let mut cents: Vec<(String, [f32; 3])> = Vec::new();
|
||||
for p in &placed {
|
||||
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
|
||||
let (mut c, mut n) = ([0.0f32; 3], 0f32);
|
||||
for sub in &m.meshes {
|
||||
for v in &sub.positions {
|
||||
let w = p.apply(*v);
|
||||
c[0] += w[0];
|
||||
c[1] += w[1];
|
||||
c[2] += w[2];
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
if n > 0.0 {
|
||||
cents.push((p.resource.clone(), [c[0] / n, c[1] / n, c[2] / n]));
|
||||
}
|
||||
}
|
||||
if cents.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// Ship cluster = median centroid; flag parts > 3× the median spread.
|
||||
let med = |k: usize| -> f32 {
|
||||
let mut v: Vec<f32> = cents.iter().map(|(_, c)| c[k]).collect();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
v[v.len() / 2]
|
||||
};
|
||||
let m = [med(0), med(1), med(2)];
|
||||
let dists: Vec<f32> = cents
|
||||
.iter()
|
||||
.map(|(_, c)| {
|
||||
((c[0] - m[0]).powi(2) + (c[1] - m[1]).powi(2) + (c[2] - m[2]).powi(2)).sqrt()
|
||||
})
|
||||
.collect();
|
||||
let mut sd: Vec<f32> = dists.clone();
|
||||
sd.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let spread = sd[sd.len() / 2].max(50.0);
|
||||
for ((res, c), dist) in cents.iter().zip(&dists) {
|
||||
if *dist > 6.0 * spread && *dist > 800.0 {
|
||||
println!(
|
||||
"OUTLIER {stage} {}: {res} centroid=[{:.0} {:.0} {:.0}] dist={:.0} (spread {:.0})",
|
||||
ship.id, c[0], c[1], c[2], dist, spread
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("audit done");
|
||||
}
|
||||
50
crates/sylpheed-formats/examples/ship_dump.rs
Normal file
50
crates/sylpheed-formats/examples/ship_dump.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Dump a ship's static placement + scene-graph nodes for a stage container FILE
|
||||
//! (works offline from an extracted disc, no ISO needed).
|
||||
//! cargo run --release --example ship_dump -- <Stage_SNN.xpr path> <ship_id>
|
||||
use sylpheed_formats::mesh::{scene_world_nodes, xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let (path, id) = (&a[1], &a[2]);
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
|
||||
// Base parts + their vertex counts.
|
||||
let want: HashSet<String> = names.iter().filter(|n| is_base_part(n) && ship_id_of(n)==Some(id.as_str())).cloned().collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
println!("== base parts ({}) ==", want.len());
|
||||
for m in &models {
|
||||
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
println!(" {:20} vtx={}", m.name, vc);
|
||||
}
|
||||
|
||||
// Composites present for this id.
|
||||
println!("== composites ==");
|
||||
for n in &names {
|
||||
if n.contains(&format!("rou_{id}")) && ship_id_of(n).is_none() {
|
||||
let nodes = scene_world_nodes(&bytes, n);
|
||||
println!(" {:24} {} nodes", n, nodes.len());
|
||||
}
|
||||
}
|
||||
|
||||
// assemble_ship result: centroids + extent.
|
||||
for ext in [false, true] {
|
||||
let placed = assemble_ship(&bytes, id, ext);
|
||||
println!("== assemble_ship(external={ext}) -> {} parts ==", placed.len());
|
||||
let mut lo=[f32::MAX;3]; let mut hi=[f32::MIN;3];
|
||||
for p in &placed {
|
||||
let src = models.iter().find(|m| m.name==p.resource);
|
||||
let (mut c, mut n) = ([0.0f32;3], 0f32);
|
||||
if let Some(src)=src { for sub in &src.meshes { for v in &sub.positions {
|
||||
let w=p.apply(*v); for k in 0..3 { c[k]+=w[k]; lo[k]=lo[k].min(w[k]); hi[k]=hi[k].max(w[k]); } n+=1.0; }}}
|
||||
let n=n.max(1.0);
|
||||
println!(" {:20} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
|
||||
p.resource, p.t[0],p.t[1],p.t[2], c[0]/n,c[1]/n,c[2]/n);
|
||||
}
|
||||
if placed.iter().any(|p| models.iter().any(|m| m.name==p.resource)) {
|
||||
println!(" EXTENT=[{:.0} {:.0} {:.0}]", hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
141
crates/sylpheed-formats/examples/ship_render.rs
Normal file
141
crates/sylpheed-formats/examples/ship_render.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Diagnostic: assemble a ship from the BAKED capture table (or the static
|
||||
//! scene graph with --static) and render orthographic PNGs + print per-part
|
||||
//! world bounds, to see exactly what the viewer shows.
|
||||
//! cargo run --release --example ship_render -- <Stage_SNN.xpr path> <ship_id> <out_prefix> [--static]
|
||||
use std::collections::HashSet;
|
||||
use sylpheed_formats::mesh::{ScenePart, Xbg7Model};
|
||||
use sylpheed_formats::ship::assemble_ship;
|
||||
use sylpheed_formats::ship_capture::{embedded_placement, to_scene_parts};
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let use_static = a.iter().any(|s| s == "--static");
|
||||
let (path, id, out) = (&a[1], &a[2], &a[3]);
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
|
||||
let placed: Vec<ScenePart> = if use_static {
|
||||
assemble_ship(&bytes, id, true)
|
||||
} else {
|
||||
let cap = embedded_placement(id).expect("ship not in baked table");
|
||||
to_scene_parts(&cap)
|
||||
};
|
||||
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
|
||||
// World triangles + per-part bounds.
|
||||
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
|
||||
for p in &placed {
|
||||
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
for sub in &m.meshes {
|
||||
let w: Vec<[f32; 3]> = sub.positions.iter().map(|v| p.apply(*v)).collect();
|
||||
for v in &w {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(v[k]);
|
||||
hi[k] = hi[k].max(v[k]);
|
||||
}
|
||||
}
|
||||
for t in sub.indices.chunks_exact(3) {
|
||||
tris.push([w[t[0] as usize], w[t[1] as usize], w[t[2] as usize]]);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{:20} X[{:8.1},{:8.1}] Y[{:8.1},{:8.1}] Z[{:8.1},{:8.1}]",
|
||||
p.resource, lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]
|
||||
);
|
||||
}
|
||||
// Exhaust cones at the GN_Jet/GN_SJet frames (the game's visible thrusters).
|
||||
for f in sylpheed_formats::ship::exhaust_frames(&bytes, id) {
|
||||
const SEG: usize = 12;
|
||||
let (r, len) = (22.0f32, 140.0f32);
|
||||
let ring: Vec<[f32; 3]> = (0..SEG)
|
||||
.map(|s| {
|
||||
let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
|
||||
f.apply([a.cos() * r, a.sin() * r, 0.0])
|
||||
})
|
||||
.collect();
|
||||
let apex = f.apply([0.0, 0.0, len]);
|
||||
for s in 0..SEG {
|
||||
tris.push([apex, ring[(s + 1) % SEG], ring[s]]);
|
||||
}
|
||||
println!(" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]", f.resource, f.t[0], f.t[1], f.t[2]);
|
||||
}
|
||||
println!("total {} tris from {} placements", tris.len(), placed.len());
|
||||
|
||||
// Orthographic z-buffered flat renders: top (X/Z, look down −Y) and side (Z/Y, look down −X).
|
||||
for (name, ax, ay, az) in [("top", 0usize, 2usize, 1usize), ("side", 2usize, 1usize, 0usize)] {
|
||||
let size = 900usize;
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for t in &tris {
|
||||
for v in t {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(v[k]);
|
||||
hi[k] = hi[k].max(v[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cx = (lo[ax] + hi[ax]) * 0.5;
|
||||
let cy = (lo[ay] + hi[ay]) * 0.5;
|
||||
let ext = (hi[ax] - lo[ax]).max(hi[ay] - lo[ay]) * 0.55;
|
||||
let scale = (size as f32 * 0.5) / ext;
|
||||
let mut img = vec![0u8; size * size * 3];
|
||||
let mut zbuf = vec![f32::MIN; size * size];
|
||||
for t in &tris {
|
||||
// screen coords
|
||||
let p: Vec<[f32; 3]> = t
|
||||
.iter()
|
||||
.map(|v| {
|
||||
[
|
||||
(v[ax] - cx) * scale + size as f32 * 0.5,
|
||||
(cy - v[ay]) * scale + size as f32 * 0.5,
|
||||
v[az],
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
// flat shade by triangle normal Z-ish
|
||||
let e1 = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
|
||||
let e2 = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
|
||||
let n = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
let nl = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-6);
|
||||
let lum = (n[az].abs() / nl * 200.0 + 40.0) as u8;
|
||||
// bbox raster
|
||||
let minx = p.iter().map(|v| v[0]).fold(f32::MAX, f32::min).max(0.0) as usize;
|
||||
let maxx = (p.iter().map(|v| v[0]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
|
||||
let miny = p.iter().map(|v| v[1]).fold(f32::MAX, f32::min).max(0.0) as usize;
|
||||
let maxy = (p.iter().map(|v| v[1]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
|
||||
let det = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
|
||||
if det.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
for y in miny..=maxy {
|
||||
for x in minx..=maxx {
|
||||
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let w0 = ((p[1][0] - fx) * (p[2][1] - fy) - (p[2][0] - fx) * (p[1][1] - fy)) / det;
|
||||
let w1 = ((p[2][0] - fx) * (p[0][1] - fy) - (p[0][0] - fx) * (p[2][1] - fy)) / det;
|
||||
let w2 = 1.0 - w0 - w1;
|
||||
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let z = w0 * p[0][2] + w1 * p[1][2] + w2 * p[2][2];
|
||||
let idx = y * size + x;
|
||||
if z > zbuf[idx] {
|
||||
zbuf[idx] = z;
|
||||
img[idx * 3] = lum;
|
||||
img[idx * 3 + 1] = lum;
|
||||
img[idx * 3 + 2] = lum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let file = format!("{out}_{name}.ppm");
|
||||
let mut ppm = format!("P6\n{size} {size}\n255\n").into_bytes();
|
||||
ppm.extend_from_slice(&img);
|
||||
std::fs::write(&file, ppm).unwrap();
|
||||
println!("wrote {file}");
|
||||
}
|
||||
}
|
||||
74
crates/sylpheed-formats/examples/slab_screen.rs
Normal file
74
crates/sylpheed-formats/examples/slab_screen.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
//! Screen every ship family for a part that is wildly out of scale with its
|
||||
//! siblings — the "slab" signature of a mis-anchored block.
|
||||
//!
|
||||
//! Rendering `e106` found such a part (`bdy_03`, 600×1600×998 beside parts of
|
||||
//! ~250) that coverage, cross-container consistency, the capture oracle and the
|
||||
//! twin invariant were all blind to. Eyeballing does not scale to 166 containers;
|
||||
//! this does the same comparison numerically.
|
||||
//!
|
||||
//! Usage: slab_screen <resource3d_dir> [factor]
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let factor: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(4.0);
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut flagged = 0usize;
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let mut by_ship: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
|
||||
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||
if !is_base_part(&m.name) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = ship_id_of(&m.name) else { continue };
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for s in &m.meshes {
|
||||
for q in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(q[k]);
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo[0] == f32::MAX {
|
||||
continue;
|
||||
}
|
||||
// Compare the part's SMALLEST axis span, not its diagonal. A long
|
||||
// thin part (an antenna, a 100 000-unit tether on `f002`) is
|
||||
// legitimately huge in one axis and would swamp a diagonal test; a
|
||||
// mis-anchored block is bulky in all three, which is what the e106
|
||||
// slab looked like (600×1600×998 beside siblings of ~250).
|
||||
let thin = (hi[0] - lo[0]).min(hi[1] - lo[1]).min(hi[2] - lo[2]);
|
||||
by_ship.entry(id.to_string()).or_default().push((m.name.clone(), thin));
|
||||
}
|
||||
for (id, parts) in &by_ship {
|
||||
if parts.len() < 3 {
|
||||
continue; // no meaningful median
|
||||
}
|
||||
let mut d: Vec<f32> = parts.iter().map(|(_, x)| *x).collect();
|
||||
d.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let median = d[d.len() / 2];
|
||||
for (name, diag) in parts {
|
||||
if *diag > median * factor {
|
||||
flagged += 1;
|
||||
println!(
|
||||
"{:<22} {name:<22} min-axis {diag:>8.0} vs ship median {median:>8.0} ({:.1}×)",
|
||||
f.file_name().unwrap().to_string_lossy(),
|
||||
diag / median
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{flagged} parts flagged at {factor}× the ship median");
|
||||
}
|
||||
33
crates/sylpheed-formats/examples/spawn_survey.rs
Normal file
33
crates/sylpheed-formats/examples/spawn_survey.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/sylpheed-formats/examples/squadrons.rs
Normal file
11
crates/sylpheed-formats/examples/squadrons.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
crates/sylpheed-formats/examples/starts.rs
Normal file
13
crates/sylpheed-formats/examples/starts.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts};
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let (markers, stride) = debug_resource_params(&bytes, &a[2]).expect("resource");
|
||||
println!("{}: stride={stride} markers={markers:?}", a[2]);
|
||||
let starts = debug_vertex_run_starts(&bytes, stride);
|
||||
println!("{} candidate starts at stride {stride}", starts.len());
|
||||
for off in &a[3..] {
|
||||
let o = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
|
||||
println!(" 0x{o:x} in candidate list: {}", starts.binary_search(&o).is_ok());
|
||||
}
|
||||
}
|
||||
7
crates/sylpheed-formats/examples/starts_probe.rs
Normal file
7
crates/sylpheed-formats/examples/starts_probe.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
|
||||
let stride:usize=a[2].parse().unwrap(); let want:usize=a[3].parse().unwrap();
|
||||
let s=sylpheed_formats::mesh::debug_vertex_run_starts(&b,stride);
|
||||
println!("{} candidate starts at stride {}", s.len(), stride);
|
||||
println!("contains {:#x}: {}", want, s.contains(&want));
|
||||
let near:Vec<String>=s.iter().filter(|&&o| o.abs_diff(want)<0x200).map(|o|format!("{o:#x}")).collect();
|
||||
println!("nearby: {}", near.join(" "));}
|
||||
34
crates/sylpheed-formats/examples/submesh_dump.rs
Normal file
34
crates/sylpheed-formats/examples/submesh_dump.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Per-sub-mesh vertex/index/coverage dump for one resource.
|
||||
//! Usage: submesh_dump <container.xpr> <resource>...
|
||||
use sylpheed_formats::mesh::{debug_resource_params, Xbg7Model};
|
||||
use std::collections::HashSet;
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
let want: HashSet<String> = a[2..].iter().cloned().collect();
|
||||
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
|
||||
let markers = debug_resource_params(&bytes, &m.name).map(|(mk, _)| mk).unwrap_or_default();
|
||||
println!("{} — {} sub-meshes decoded, {} markers declared", m.name, m.meshes.len(), markers.len());
|
||||
for (i, s) in m.meshes.iter().enumerate() {
|
||||
let max_idx = s.indices.iter().max().copied().unwrap_or(0) as usize;
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for q in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(q[k]);
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" #{i:<2} at 0x{:<9x} verts {:<6} idx {:<6} max_idx {:<6} slack {} span [{:.0} {:.0} {:.0}]",
|
||||
s.vbuf_offset.unwrap_or(0),
|
||||
s.positions.len(),
|
||||
s.indices.len(),
|
||||
max_idx,
|
||||
s.positions.len() as i64 - 1 - max_idx as i64,
|
||||
hi[0] - lo[0],
|
||||
hi[1] - lo[1],
|
||||
hi[2] - lo[2]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
crates/sylpheed-formats/examples/try_anchor.rs
Normal file
17
crates/sylpheed-formats/examples/try_anchor.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! Does the capture-proven offset validate for the resource that should own it?
|
||||
use sylpheed_formats::mesh::debug_try_anchor;
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
// The production scan tries pads 0..=3; pass a bigger one to ask whether the
|
||||
// block would validate at all with a wider index/vertex gap.
|
||||
let max_pad: usize = std::env::var("MAX_PAD").ok().and_then(|v| v.parse().ok()).unwrap_or(3);
|
||||
for pair in a[2..].iter() {
|
||||
let (name, off) = pair.split_once('@').unwrap();
|
||||
let off = usize::from_str_radix(off.trim_start_matches("0x"), 16).unwrap();
|
||||
match debug_try_anchor(&bytes, name, off, max_pad) {
|
||||
Some((v, i, pad)) => println!("{name:22} @ 0x{off:x} ACCEPTED v={v} idx={i} pad={pad}"),
|
||||
None => println!("{name:22} @ 0x{off:x} rejected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
119
crates/sylpheed-formats/examples/twin_mirror_audit.rs
Normal file
119
crates/sylpheed-formats/examples/twin_mirror_audit.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! Do port/starboard twins decode to mirror images of each other?
|
||||
//!
|
||||
//! A runtime capture proved the container stores both halves of the `e106` hull
|
||||
//! as separate, X-reflected buffers. That gives a **capture-free invariant**:
|
||||
//! a `…_01`/`…_02` pair of equal vertex count should decode to geometry that is
|
||||
//! an exact X-mirror — never to identical geometry (that is the collapse the
|
||||
//! distinct-assignment fix targets), and never to something unrelated (that is a
|
||||
//! mis-anchor no count-based metric can see).
|
||||
//!
|
||||
//! Usage: twin_mirror_audit <resource3d_dir>
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn key(p: [f32; 3]) -> (i64, i64, i64) {
|
||||
(
|
||||
(p[0] * 1e3).round() as i64,
|
||||
(p[1] * 1e3).round() as i64,
|
||||
(p[2] * 1e3).round() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Elementwise equality with a tolerance. Truncating keys is too strict for a
|
||||
/// mirrored pair: the halves are authored, not bit-negated, so they differ in
|
||||
/// the last digits and an exact key test reports them as unrelated.
|
||||
fn near(p: [f32; 3], q: [f32; 3]) -> bool {
|
||||
(0..3).all(|c| (p[c] - q[c]).abs() <= 1e-3 * (1.0 + q[c].abs()))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let (mut same, mut mirrored, mut unrelated, mut related) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
|
||||
let by_name: BTreeMap<&str, &Xbg7Model> =
|
||||
models.iter().map(|m| (m.name.as_str(), m)).collect();
|
||||
for m in &models {
|
||||
let Some(stem) = m.name.strip_suffix("_01") else { continue };
|
||||
let Some(t) = by_name.get(format!("{stem}_02").as_str()) else { continue };
|
||||
let a: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).collect();
|
||||
let b: Vec<[f32; 3]> = t.meshes.iter().flat_map(|s| s.positions.clone()).collect();
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ident = a.iter().zip(&b).all(|(p, q)| near(*p, *q));
|
||||
let mirr = a.iter().zip(&b).all(|(p, q)| near([-p[0], p[1], p[2]], *q));
|
||||
// A pair that is neither may still be RELATED: mirrored on another
|
||||
// axis, or the same point cloud in a different vertex order. Only a
|
||||
// pair that is none of these is evidence of a mis-anchor.
|
||||
let mirr_y = a.iter().zip(&b).all(|(p, q)| near([p[0], -p[1], p[2]], *q));
|
||||
let mirr_z = a.iter().zip(&b).all(|(p, q)| near([p[0], p[1], -p[2]], *q));
|
||||
let set = |v: &Vec<[f32; 3]>| {
|
||||
let mut s: Vec<_> = v.iter().map(|p| key(*p)).collect();
|
||||
s.sort_unstable();
|
||||
s
|
||||
};
|
||||
let same_cloud = set(&a) == set(&b);
|
||||
// …and the same point cloud after mirroring, for a pair whose
|
||||
// halves are authored in different vertex order.
|
||||
let mirrored_cloud = {
|
||||
let am: Vec<[f32; 3]> = a.iter().map(|p| [-p[0], p[1], p[2]]).collect();
|
||||
set(&am) == set(&b)
|
||||
};
|
||||
if !ident && !mirr && (mirr_y || mirr_z || same_cloud || mirrored_cloud) {
|
||||
related += 1;
|
||||
continue;
|
||||
}
|
||||
// Identical GEOMETRY is only a collapse if it also comes from the
|
||||
// same buffer: a container may store a part twice unmirrored, and
|
||||
// then two twins on two copies decode identically and correctly.
|
||||
let shared_buffer = m.meshes[0].vbuf_offset.is_some()
|
||||
&& m.meshes[0].vbuf_offset == t.meshes[0].vbuf_offset;
|
||||
if ident && !shared_buffer {
|
||||
related += 1;
|
||||
continue;
|
||||
}
|
||||
if ident {
|
||||
same += 1;
|
||||
if examples.len() < 6 {
|
||||
examples.push(format!(
|
||||
"identical: {} / {}_02 in {}",
|
||||
m.name,
|
||||
stem,
|
||||
f.file_name().unwrap().to_string_lossy()
|
||||
));
|
||||
}
|
||||
} else if mirr {
|
||||
mirrored += 1;
|
||||
} else {
|
||||
unrelated += 1;
|
||||
if examples.len() < 6 {
|
||||
examples.push(format!(
|
||||
"unrelated: {} / {}_02 in {}",
|
||||
m.name,
|
||||
stem,
|
||||
f.file_name().unwrap().to_string_lossy()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("twin pairs of equal vertex count: {}", same + mirrored + unrelated + related);
|
||||
println!(" exact X-mirror (expected) : {mirrored}");
|
||||
println!(" IDENTICAL, one buffer (collapse) : {same}");
|
||||
println!(" related other way (Y/Z mirror, reordered): {related}");
|
||||
println!(" unrelated (mis-anchor?) : {unrelated}");
|
||||
for e in examples {
|
||||
println!(" e.g. {e}");
|
||||
}
|
||||
}
|
||||
55
crates/sylpheed-formats/examples/ui_screen.rs
Normal file
55
crates/sylpheed-formats/examples/ui_screen.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Scratch analysis: inventory one UI screen's pak — every entry, and for RATC
|
||||
//! entries the children they bundle. Optionally write an entry's decompressed
|
||||
//! bytes out for hex inspection.
|
||||
//!
|
||||
//! Run: cargo run -p sylpheed-formats --example ui_screen -- <PAK> [--dump 0xHASH out.bin]
|
||||
|
||||
use sylpheed_formats::pak::{self, PakArchive};
|
||||
use sylpheed_formats::ratc;
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let pak = args.next().expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
|
||||
let arc = PakArchive::open(&pak).expect("open pak");
|
||||
|
||||
let rest: Vec<String> = args.collect();
|
||||
if rest.first().map(|s| s == "--dump").unwrap_or(false) {
|
||||
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
|
||||
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
|
||||
std::fs::write(&rest[2], &bytes).unwrap();
|
||||
println!("wrote {} bytes to {}", bytes.len(), rest[2]);
|
||||
return;
|
||||
}
|
||||
|
||||
if rest.first().map(|s| s == "--child").unwrap_or(false) {
|
||||
// --child 0xHASH <child-name> <out>: carve one RATC child out by its
|
||||
// listed offset/size, so a 165-byte layout record can be hexdumped alone.
|
||||
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
|
||||
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
|
||||
let kids = ratc::parse(&bytes).expect("ratc");
|
||||
let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found");
|
||||
std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap();
|
||||
println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("{pak}: {} entries", arc.len());
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else {
|
||||
println!(" {:08x} <decompress failed>", e.name_hash);
|
||||
continue;
|
||||
};
|
||||
let label = pak::inner_format_label(&bytes);
|
||||
println!(" {:08x} {:>9} B {}", e.name_hash, bytes.len(), label);
|
||||
if ratc::is_ratc(&bytes) {
|
||||
if let Some(kids) = ratc::parse(&bytes) {
|
||||
for k in &kids {
|
||||
println!(" - {:<40} {:>9} B {}", k.name, k.size, k.kind);
|
||||
}
|
||||
if kids.is_empty() {
|
||||
println!(" (no children found)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
crates/sylpheed-formats/examples/undecoded.rs
Normal file
56
crates/sylpheed-formats/examples/undecoded.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Which XBG7 resources never decode, and how big is that population?
|
||||
//!
|
||||
//! Coverage has been reported as "resources decoded" without a denominator. This
|
||||
//! prints both, per container and in total, and names the misses so the gate
|
||||
//! attribution (`why_rejected`) has a work list.
|
||||
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("resource3d dir");
|
||||
let show = std::env::args().nth(2).is_some();
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let (mut total, mut decoded, mut no_decl) = (0usize, 0usize, 0usize);
|
||||
let mut misses: Vec<String> = Vec::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
if names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let got: HashSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
|
||||
.into_iter()
|
||||
.map(|m| m.name)
|
||||
.collect();
|
||||
for n in &names {
|
||||
total += 1;
|
||||
if got.contains(n) {
|
||||
decoded += 1;
|
||||
} else if debug_resource_params(&bytes, n).is_none() {
|
||||
// No vertex declaration / no index markers: the anchor scan
|
||||
// never even considers these, so they are a different question
|
||||
// from "searched and not found".
|
||||
no_decl += 1;
|
||||
} else {
|
||||
misses.push(format!("{}|{n}", f.file_name().unwrap().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"XBG7 resources: {total} total, {decoded} decoded ({:.1}%), {no_decl} without a usable descriptor, {} searched-and-missed",
|
||||
100.0 * decoded as f64 / total as f64,
|
||||
misses.len()
|
||||
);
|
||||
if show {
|
||||
for m in &misses {
|
||||
println!("{m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
31
crates/sylpheed-formats/examples/unit_fields.rs
Normal file
31
crates/sylpheed-formats/examples/unit_fields.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Every key/value a named unit record actually sets on disc.
|
||||
//!
|
||||
//! The live definition object in guest RAM has no field names; to find where a
|
||||
//! field like `HQRatio` sits inside it, anchor on a unit whose disc record DOES
|
||||
//! set that field and look for the value. This prints those anchors.
|
||||
//! Run: unit_fields <disc-root> <substring of ID>...
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).expect("disc root");
|
||||
let want: Vec<String> = std::env::args().skip(2).collect();
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
|
||||
for entry in pak.entries() {
|
||||
let Ok(bytes) = pak.read(entry) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||
let Some(id) = obj.get_raw("ID") else { continue };
|
||||
if !want.iter().any(|w| id.contains(w.as_str())) {
|
||||
continue;
|
||||
}
|
||||
println!("=== {id} (schema {:08x})", obj.schema_hash);
|
||||
// The pool interleaves VALUE before KEY (see the module docs), so the
|
||||
// pairs read (t[i] = value, t[i+1] = key).
|
||||
let t = obj.tokens();
|
||||
let mut i = 0;
|
||||
while i + 1 < t.len() {
|
||||
println!(" {:<30} {}", t[i + 1], t[i]);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
crates/sylpheed-formats/examples/unit_signatures.rs
Normal file
33
crates/sylpheed-formats/examples/unit_signatures.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! (HP, Size_X, Size_Y, Size_Z) for every craft unit and vessel on the disc.
|
||||
//!
|
||||
//! A live definition object in guest RAM carries HP at `+0x54` and the sizes at
|
||||
//! `+0x30/34/38` but no name in its first words, so identity has to come from the
|
||||
//! values themselves: this is the lookup table for that match.
|
||||
//! Run: unit_signatures <disc-root>
|
||||
use sylpheed_formats::game_data::{load_units, load_vessels};
|
||||
use sylpheed_formats::PakArchive;
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).expect("disc root");
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
|
||||
println!("{:<34} {:>9} {:>8} {:>8} {:>8} kind", "id", "hp", "size_x", "size_y", "size_z");
|
||||
for u in load_units(&pak) {
|
||||
println!(
|
||||
"{:<34} {:>9} {:>8} {:>8} {:>8} unit",
|
||||
u.id.unwrap_or_default(),
|
||||
u.hp.map(|v| v.to_string()).unwrap_or("—".into()),
|
||||
u.size_x.map(|v| v.to_string()).unwrap_or("—".into()),
|
||||
u.size_y.map(|v| v.to_string()).unwrap_or("—".into()),
|
||||
u.size_z.map(|v| v.to_string()).unwrap_or("—".into()),
|
||||
);
|
||||
}
|
||||
for v in load_vessels(&pak) {
|
||||
println!(
|
||||
"{:<34} {:>9} {:>8} {:>8} {:>8} vessel",
|
||||
v.id.unwrap_or_default(),
|
||||
v.hp.map(|x| x.to_string()).unwrap_or("—".into()),
|
||||
v.size_x.map(|x| x.to_string()).unwrap_or("—".into()),
|
||||
v.size_y.map(|x| x.to_string()).unwrap_or("—".into()),
|
||||
v.size_z.map(|x| x.to_string()).unwrap_or("—".into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
crates/sylpheed-formats/examples/unit_values.rs
Normal file
22
crates/sylpheed-formats/examples/unit_values.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).unwrap();
|
||||
let want = std::env::args().nth(2).unwrap();
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||
for e in pak.entries() {
|
||||
let Ok(b) = pak.read(e) else { continue };
|
||||
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||
let Some(id) = o.get_raw("ID") else { continue };
|
||||
if !id.contains(&want) { continue; }
|
||||
println!("=== {id}");
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for k in o.tokens() {
|
||||
if let Some(v) = o.get_f32(k) {
|
||||
if v != 0.0 && seen.insert(k.clone()) {
|
||||
println!(" {:<28} {:<12} 0x{:08x}", k, v, v.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
crates/sylpheed-formats/examples/validate_cues.rs
Normal file
32
crates/sylpheed-formats/examples/validate_cues.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use sylpheed_formats::slb;
|
||||
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
|
||||
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
|
||||
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration:stream=channels","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).replace('\n'," ")}
|
||||
fn movdur(disc:&str,m:&str)->String{ dur(&format!("{disc}/dat/movie/{m}")) }
|
||||
fn main(){
|
||||
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||
// descriptor trailer global offsets (from scan) => cue N data = [desc(N-1)..desc(N)]
|
||||
// (id, end_off, movie)
|
||||
let cues=[(1600u32,437044592u64,"ADV.wmv"),(1601,437345648,"RT01A.wmv"),(1602,437712240,"RT01B.wmv"),
|
||||
(1603,438080880,"RT01C_1.wmv"),(1604,438451568,"RT01C_2.wmv"),(1605,438789488,"RT02A.wmv")];
|
||||
for k in 1..cues.len(){
|
||||
let (id,end,mov)=cues[k];
|
||||
let start=cues[k-1].1;
|
||||
let region=rg(&disc,start,(end-start) as usize + 12000); // +tail to include full data before next trailer
|
||||
let riffs=slb::to_xma_riffs(®ion);
|
||||
let mut total=0.0f32; let mut parts=vec![];
|
||||
for (j,r) in riffs.iter().enumerate(){
|
||||
let xp=format!("/tmp/cue{id}_{j}.xma.wav"); let wp=format!("/tmp/cue{id}_{j}.wav");
|
||||
fs::write(&xp,r).unwrap();
|
||||
let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();
|
||||
let d=dur(&wp); parts.push(d.clone());
|
||||
total+=d.split_whitespace().next().unwrap_or("0").parse::<f32>().unwrap_or(0.0);
|
||||
}
|
||||
// spanning?
|
||||
let span_adv_end=437547264u64;
|
||||
let spans = start < span_adv_end && end > span_adv_end || (start/1_000 != end/1_000);
|
||||
println!("cue {id} ({mov}): region[{start}..{end}] {} bytes, {} riff(s), Σ={:.1}s | movie={} parts={:?}",
|
||||
end-start, riffs.len(), total, movdur(&disc,mov), parts);
|
||||
}
|
||||
println!("\nWAVs at /tmp/cue16XX_*.wav (listen: RT01B=1602, RT01C_1=1603, RT01C_2=1604)");
|
||||
}
|
||||
98
crates/sylpheed-formats/examples/vcount_index.rs
Normal file
98
crates/sylpheed-formats/examples/vcount_index.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! Global "which resource has N vertices?" index over every `.xpr` container in
|
||||
//! an extracted `resource3d` directory, answered for the vcounts a capture log
|
||||
//! actually drew.
|
||||
//!
|
||||
//! Companion to `invert_capture`: that one asks the question inside a single
|
||||
//! stage container, this one asks it across ALL containers — so a draw whose
|
||||
//! geometry lives in `Common.xpr`, a `rou_*` weapon pack or a `BG_*` backdrop is
|
||||
//! still identified instead of coming back "no resource".
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example vcount_index -- <resource3d_dir> <capture.log> [top_n]
|
||||
//! cargo run --release --example vcount_index -- <resource3d_dir> --vcounts 10891,6000
|
||||
|
||||
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: vcount_index <resource3d_dir> <capture.log|--vcounts a,b,c> [top_n]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let dir = &args[1];
|
||||
|
||||
// Which vertex counts are we asking about, and how often was each drawn?
|
||||
let mut draw_count: HashMap<u32, usize> = HashMap::new();
|
||||
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
|
||||
if args[2] == "--vcounts" {
|
||||
for v in args[3].split(',').filter_map(|s| s.trim().parse::<u32>().ok()) {
|
||||
draw_count.insert(v, 0);
|
||||
}
|
||||
} else {
|
||||
let text = std::fs::read_to_string(&args[2]).expect("read log");
|
||||
let mut draws = parse_capture(&text);
|
||||
if draws.is_empty() {
|
||||
draws = parse_drawlog(&text);
|
||||
}
|
||||
eprintln!("parsed {} draws", draws.len());
|
||||
for d in &draws {
|
||||
*draw_count.entry(d.vcount).or_default() += 1;
|
||||
bufs.entry(d.vcount).or_default().insert(d.vbase);
|
||||
}
|
||||
}
|
||||
let top_n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
|
||||
|
||||
// Decode every container once; keep only the vcount → names mapping.
|
||||
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
|
||||
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
|
||||
.expect("read dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut total_res = 0usize;
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
if names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let want: HashSet<String> = names.iter().cloned().collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let container = f.file_stem().unwrap().to_string_lossy().to_string();
|
||||
for m in &models {
|
||||
total_res += 1;
|
||||
let whole: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
by_vcount.entry(whole as u32).or_default().push(format!("{container}:{}", m.name));
|
||||
if m.meshes.len() > 1 {
|
||||
for (i, s) in m.meshes.iter().enumerate() {
|
||||
by_vcount
|
||||
.entry(s.positions.len() as u32)
|
||||
.or_default()
|
||||
.push(format!("{container}:{}#{i}", m.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("indexed {} resources from {} containers", total_res, files.len());
|
||||
|
||||
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
|
||||
vcounts.sort_unstable_by(|a, b| b.cmp(a));
|
||||
println!("\nvcount draws bufs resources anywhere in resource3d/");
|
||||
for v in vcounts.into_iter().take(top_n) {
|
||||
let n = draw_count[&v];
|
||||
let b = bufs.get(&v).map(|s| s.len()).unwrap_or(0);
|
||||
let hit = by_vcount.get(&v).cloned().unwrap_or_default();
|
||||
let label = if hit.is_empty() {
|
||||
"— NONE".to_string()
|
||||
} else {
|
||||
let mut h = hit.clone();
|
||||
h.sort();
|
||||
let shown = h.len().min(8);
|
||||
format!("{}{}", h[..shown].join(", "), if h.len() > shown { format!(", … ({} total)", h.len()) } else { String::new() })
|
||||
};
|
||||
println!("{v:6} {n:5} {b:4} {label}");
|
||||
}
|
||||
}
|
||||
70
crates/sylpheed-formats/examples/verify_fieldmap.rs
Normal file
70
crates/sylpheed-formats/examples/verify_fieldmap.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Check the code-derived offset→field map against the live dump, then read out
|
||||
//! the runtime value of every field the disc record leaves defaulted.
|
||||
//! Run: verify_fieldmap <disc-root> <live-dump> <ID=0xVA>...
|
||||
use std::collections::BTreeMap;
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
|
||||
// Offsets read out of sub_82341A20's own key strings (stfs stores only).
|
||||
// Offsets and names recovered from `sub_82341A20` itself: each key is built as
|
||||
// `addi r4, r30, -N` (r30 = 0x82088f94, so the name is a string in the image),
|
||||
// and the value lands in the first store into the object after the accessor
|
||||
// call. 125 float-typed fields.
|
||||
const MAP: &[(usize, &str)] = &[(48, "Size_X"), (52, "Size_Y"), (56, "Size_Z"), (64, "Color_R"), (68, "Color_G"), (72, "Color_B"), (80, "Size_Radius"), (84, "HP"), (88, "HQRatio"), (92, "ShieldRatio"), (96, "ThrusterRatio"), (116, "ResistanceToOptics"), (120, "ResistanceToShell"), (124, "ResistanceToExplosion"), (128, "ResistanceToPlayer"), (132, "ResistanceParalyze"), (156, "MinimumVelocity"), (160, "MaximumVelocity"), (164, "CruisingVelocity"), (168, "Acceleration"), (172, "Deceleration"), (176, "AV_PitchPlus_Max"), (180, "AV_PitchPlus_Min"), (184, "AA_PitchPlus_Max"), (188, "AA_PitchPlus_Min"), (192, "AV_PitchMinus_Max"), (196, "AV_PitchMinus_Min"), (200, "AA_PitchMinus_Max"), (204, "AA_PitchMinus_Min"), (208, "AV_Yaw_Max"), (212, "AV_Yaw_Min"), (216, "AA_Yaw_Max"), (220, "AA_Yaw_Min"), (224, "AV_Roll_Max"), (228, "AV_Roll_Min"), (232, "AA_Roll_Max"), (236, "AA_Roll_Min"), (248, "SideThrustVelocity_Max"), (252, "SideThrustAcceleration"), (256, "MaximumBank_Normal"), (260, "YawDragFactor"), (264, "PitchDragFactor"), (268, "RollDragFactor"), (272, "DragFactorThreshold"), (276, "ArterBurner_Vc"), (280, "ReverseThrust_Vc"), (284, "ArterBurner_Acc"), (288, "ReverseThrust_Acc"), (292, "AccPitchFactor"), (296, "DecPitchFactor"), (300, "AV_AxisMode_Max"), (304, "AV_AxisMode_Min"), (308, "AA_AxisMode_Max"), (312, "AA_AxisMode_Min"), (316, "PowerCutConsumeShield"), (320, "PowerCutDeceleration"), (324, "AB_ConsumeShield_Begin"), (328, "AB_ConsumeShield"), (332, "AB_AV_PitchPlus"), (336, "AB_AA_PitchPlus"), (340, "AB_AV_PitchMinus"), (344, "AB_AA_PitchMinus"), (348, "AB_AV_Yaw"), (352, "AB_AA_Yaw"), (356, "AB_AV_Roll"), (360, "AB_AA_Roll"), (368, "SideRoll_Time"), (372, "SideRoll_Length"), (380, "BarrelRoll_CountMinimum"), (384, "BarrelRoll_CountMaximum"), (388, "BarrelRoll_Time"), (392, "BarrelRoll_Radius"), (400, "TurnAttack_CutoffRatio"), (404, "TurnAttack_DoubleRatio"), (408, "CutoffTimeMin"), (412, "CutoffTimeMax"), (416, "TurnAttack_DoubleTimeMin"), (420, "TurnAttack_DoubleTimeMax"), (428, "Turn_AngularVelocity"), (432, "TurnAway_Time_Minimum"), (436, "TurnAway_Time_Maximum"), (444, "BoostAway_Time_Minimum"), (448, "BoostAway_Time_Maximum"), (456, "HoldPosition_LengthMin"), (460, "HoldPosition_LengthMax"), (464, "HoldPosition_MinimumTime"), (468, "HoldPosition_MaximumTime"), (472, "HoldPosition_SideRatio"), (476, "HoldPosition_BackRatio"), (480, "HoldPosition_CutoffRatio"), (484, "HoldPosition_CancelTime"), (492, "Slalom_CutoffRatio"), (496, "Slalom_TurnCount_Min"), (500, "Slalom_TurnCount_Max"), (508, "Through_CutoffRatio"), (512, "Through_AngleMinimum"), (516, "Through_AngleMaximum"), (520, "Through_Time1Max"), (524, "Through_Time1Min"), (528, "Through_Time2Max"), (532, "Through_Time2Min"), (536, "Through_LengthMinimum"), (540, "Through_LengthMaximum"), (548, "SolidCutoff_Ratio"), (552, "SolidCutoff_LengthMin"), (556, "SolidCutoff_LengthMax"), (560, "HomingResistAdjustment"), (564, "UsingChaffRatio"), (568, "MaxValue"), (572, "ChargeDelay"), (576, "ChargeDelay_Break"), (580, "ChargeSpeed"), (584, "Delay"), (588, "DelayAdjustment"), (624, "DestroyMotionTime"), (628, "DryMass"), (632, "GrossMass"), (664, "LowerHPThresholdRatio"), (668, "SELength"), (672, "RadarRange"), (676, "FCSRange"), (680, "FiringRange"), (692, "AttackVesselPoint"), (696, "AttackCraftPoint"), (700, "DefencePoint")];
|
||||
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let disc = a.next().unwrap();
|
||||
let dump = a.next().unwrap();
|
||||
let pairs: Vec<(String, String)> =
|
||||
a.filter_map(|s| s.split_once('=').map(|(i, v)| (i.into(), v.into()))).collect();
|
||||
|
||||
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
|
||||
let mut cur = String::new();
|
||||
for line in std::fs::read_to_string(&dump).unwrap().lines() {
|
||||
if let Some(r) = line.strip_prefix("=== ") { cur = r.trim().into(); continue; }
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
// dump columns: addr +off hex u32 f32 -- the float is f[4], not f[3]
|
||||
if f.len() >= 5 && f[1].starts_with('+') {
|
||||
if let (Ok(o), Ok(v)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>()) {
|
||||
live.entry(cur.clone()).or_default().insert(o, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||
let (mut agree, mut disagree) = (0, 0);
|
||||
let mut defaults: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
|
||||
for e in pak.entries() {
|
||||
let Ok(b) = pak.read(e) else { continue };
|
||||
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||
let Some(id) = o.get_raw("ID") else { continue };
|
||||
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
|
||||
let Some(w) = live.get(va) else { continue };
|
||||
for (off, key) in MAP {
|
||||
let Some(got) = w.get(off) else { continue };
|
||||
match o.get_f32(key) {
|
||||
Some(want) => {
|
||||
// Angle fields are stored in RADIANS at runtime and degrees on
|
||||
// disc, so a match is either the raw value or its conversion.
|
||||
let rad = want * std::f32::consts::PI / 180.0;
|
||||
let ok = (want - got).abs() <= want.abs() * 1e-4
|
||||
|| (rad - got).abs() <= rad.abs() * 1e-4;
|
||||
if ok { agree += 1 } else {
|
||||
disagree += 1;
|
||||
println!(" MISMATCH {id} {key}: disc {want}, live +{off} = {got}");
|
||||
}
|
||||
}
|
||||
None => defaults.entry((*key).into()).or_default().push((id.into(), *got)),
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\nmap check: {agree} fields agree with the disc, {disagree} disagree\n");
|
||||
println!("runtime value where the disc record DEFAULTS the field:");
|
||||
for (key, hits) in &defaults {
|
||||
let vals: Vec<String> = hits.iter().map(|(_, v)| format!("{v}")).collect();
|
||||
let uniq: std::collections::BTreeSet<&String> = vals.iter().collect();
|
||||
println!(" {:<22} {:<28} ({} units)", key,
|
||||
uniq.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "), hits.len());
|
||||
}
|
||||
}
|
||||
19
crates/sylpheed-formats/examples/why_missed.rs
Normal file
19
crates/sylpheed-formats/examples/why_missed.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Why does a named resource never decode? Reports the furthest gate its best
|
||||
//! candidate reached. Usage: why_missed <container.xpr> [name-substring]
|
||||
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
|
||||
use std::collections::HashSet;
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let filter = a.get(2).cloned().unwrap_or_default();
|
||||
let decoded: HashSet<String> = Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
|
||||
for n in xbg7_resource_names(&bytes) {
|
||||
if decoded.contains(&n) || (!filter.is_empty() && !n.contains(&filter)) {
|
||||
continue;
|
||||
}
|
||||
match debug_best_rejection(&bytes, &n) {
|
||||
Some((rank, why)) => println!("{n:<28} rank {rank} {why}"),
|
||||
None => println!("{n:<28} (no candidate reached any gate / skipped before anchoring)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
14
crates/sylpheed-formats/examples/why_rejected.rs
Normal file
14
crates/sylpheed-formats/examples/why_rejected.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Why does the decoder refuse a grouped-pool resource at a given pool start?
|
||||
//! Usage: why_rejected <container.xpr> <resource>@<vb0-hex>...
|
||||
use sylpheed_formats::mesh::debug_grouped_report;
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
for pair in &a[2..] {
|
||||
let (name, off) = pair.split_once('@').expect("name@hex");
|
||||
let vb0 = usize::from_str_radix(off.trim_start_matches("0x"), 16).expect("hex");
|
||||
for line in debug_grouped_report(&bytes, name, vb0) {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
980
crates/sylpheed-formats/src/game_data.rs
Normal file
980
crates/sylpheed-formats/src/game_data.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,22 @@ pub mod slb;
|
||||
// The movie manifest: authoritative movie → subtitle → voice index.
|
||||
pub mod movie_manifest;
|
||||
|
||||
pub mod movie_voice;
|
||||
|
||||
pub mod game_data;
|
||||
|
||||
pub mod localization;
|
||||
|
||||
// Whole-ship assembly from XBG7 part families (capital ships as split parts).
|
||||
pub mod ship;
|
||||
|
||||
/// The runtime layout of a unit / vessel definition object, read out of the
|
||||
/// title's loader and verified against a live mission (see the module docs).
|
||||
pub mod unit_layout;
|
||||
|
||||
// Exact capital-ship placement from a runtime F10 ship-capture (ground truth).
|
||||
pub mod ship_capture;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
|
||||
179
crates/sylpheed-formats/src/localization.rs
Normal file
179
crates/sylpheed-formats/src/localization.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad)
|
||||
//! frames concatenated back-to-back.
|
||||
//! `LSTA` — a display list: a header followed by N inline elements concatenated
|
||||
//! back-to-back, each either a [`T8aD`](crate::t8ad) sprite or a `PRMD`
|
||||
//! primitive (a flat coloured quad — the same primitive the UI bundles use to
|
||||
//! dim a scene).
|
||||
//!
|
||||
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame
|
||||
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame
|
||||
//! from its slice up to the next frame (or end).
|
||||
//! The `count` at `@0x04` is **exact, and counts both kinds**: across all 64
|
||||
//! lists on the disc, `count == T8aD frames + PRMD primitives` with no
|
||||
//! exceptions (measured 2026-08-11). An earlier note here said "a few entries
|
||||
//! disagree with the actual frame count" — they do not; that comparison was
|
||||
//! counting sprites against a total that includes primitives.
|
||||
//!
|
||||
//! [`parse`] walks by magic and returns the **sprites**, deliberately skipping
|
||||
//! `PRMD` entries, so its result length is `count` only for lists that hold no
|
||||
//! primitives. Six lists do (in `GP_DEBRIEFING_PILOTLOG`, `GP_MISSION_SELECT`),
|
||||
//! each with exactly one.
|
||||
|
||||
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
|
||||
|
||||
@@ -15,8 +24,9 @@ pub fn is_lsta(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
|
||||
}
|
||||
|
||||
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD
|
||||
/// variant) are skipped. Returns `None` only for non-LSTA input.
|
||||
/// Decode the inline T8aD sprites, skipping `PRMD` primitives. Returns `None`
|
||||
/// only for non-LSTA input. Every one of the 1 281 sprite frames on the disc
|
||||
/// decodes (measured 2026-08-11, after the T8aD rectangle-list fix).
|
||||
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
|
||||
if !is_lsta(bytes) {
|
||||
return None;
|
||||
@@ -48,12 +58,21 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A faithful one-rectangle T8aD frame: base header, a 1-entry offset table,
|
||||
/// then the rectangle header (dst 0,0, size w×h) and its pixels. (Before
|
||||
/// 2026-08-11 this fixture wrote no offset-table entry at all and the decoder
|
||||
/// read "pixels" from inside the header — the test only ever checked the
|
||||
/// dimensions, so it passed anyway.)
|
||||
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 64];
|
||||
let mut b = vec![0u8; 0x2c];
|
||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table → rect at 0x30
|
||||
for v in [0u32, 0, w, h] {
|
||||
b.extend_from_slice(&v.to_be_bytes()); // dst X, dst Y, width, height
|
||||
}
|
||||
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
|
||||
b
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,81 @@
|
||||
//! The movie manifest: the game's authoritative movie → subtitle → **voice**
|
||||
//! index (statically reversed).
|
||||
//! The movie manifest: the game's authoritative **mission → phase → cutscene**
|
||||
//! table (statically reversed). Beyond the movie→voice binding, it defines the
|
||||
//! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay,
|
||||
//! + voice) plays at each mission phase, and of what kind (story intro, in-mission
|
||||
//! phase cutscene, resupply, …).
|
||||
//!
|
||||
//! `dat/tables.pak` holds one `IDXD` table (a `Z1`+zlib block) whose string pool
|
||||
//! lists every cutscene in play order. Each movie contributes a run of ASCII
|
||||
//! strings, always led by `<movie>.wmv` and optionally followed by:
|
||||
//! - `<pak>+….prt` — an overlay/print-art reference,
|
||||
//! - `<pak>+SUBTITLE_<movie>.tbl` — its caption timing track,
|
||||
//! - `VOICE_<token>` — the **voice track basename**.
|
||||
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
|
||||
//!
|
||||
//! The voice token is the payoff: it is the real bank name to look up in
|
||||
//! `sounds.tbl`, and it is **not** always `VOICE_<movie>`. Most story/radio
|
||||
//! movies use `VOICE_<movie>` (in `<lang>\Movie\`), but several `hokyu_*`
|
||||
//! (resupply) movies bind to in-mission radio clips like `VOICE_D_450` (in
|
||||
//! `<lang>\etc\`), and many `hokyu_*` movies + the boot logos have **no** voice
|
||||
//! token at all — i.e. the game plays no voice-over for them. Guessing
|
||||
//! `VOICE_<movie>` therefore both misses the `VOICE_D_*` cases and invents a
|
||||
//! non-existent track for the silent ones; this manifest is the ground truth.
|
||||
//! One `IDXD` table (`Z1`+zlib block), schema `0x067025b9`. Unlike a flat IDXD
|
||||
//! object, this one is an **array of records**, and its string pool is laid out
|
||||
//! as **two consecutive arrays**:
|
||||
//! 1. the **slot KEYS** — `LOGO1`, `ADVERTISE_MOVIE`, `STAFF_ROLL`, then one
|
||||
//! per cutscene: `MS<NN><X>`, `STAGE<NN>_PHASE0<N>`, `STAGE<NN>_PHASE_END…`,
|
||||
//! `S<NN>_SUPPLY_<ACROPOLIS|TANKER>`, in play order;
|
||||
//! 2. the **value RECORDS**, in the same order — each a run led by `<movie>.wmv`
|
||||
//! and (optionally) a `<pak>+….prt` overlay (field `TELOP`), a
|
||||
//! `<pak>+SUBTITLE_<movie>.tbl` (field `SUBTITLE`), and a `VOICE_<token>`
|
||||
//! (field `VOICETRACK`). A few slot keys are **valueless** (e.g. stages 5 and
|
||||
//! 12 have no resupply video) — those slots have no record and are skipped.
|
||||
//!
|
||||
//! We read the manifest by grouping the string pool on `.wmv` (the pool is
|
||||
//! emitted in the same order as the records), which is robust without decoding
|
||||
//! the `IDXD` record structure.
|
||||
//! The **slot key is the semantic role** — this is how the game separates a
|
||||
//! cutscene voice from in-mission radio chatter (structurally, by which slot/bank,
|
||||
//! not one boolean): `MS*` = story intro (`VOICE_S*`), `STAGE*_PHASE*` = radio-box
|
||||
//! cutscene (`VOICE_RT*`), `S*_SUPPLY_*` = resupply (`VOICE_D_45x` or none).
|
||||
//! Resupply videos are **reused across stages** (`S04_SUPPLY_ACROPOLIS` →
|
||||
//! `hokyu_DS_s02A`), so the mapping is genuinely a table, not derivable from names.
|
||||
//!
|
||||
//! The voice token is likewise **not** always `VOICE_<movie>`: most movies use
|
||||
//! `VOICE_<movie>` (in `<lang>\Movie\`), but the resupply cutscenes bind to
|
||||
//! in-mission radio clips like `VOICE_D_450` (in `<lang>\etc\`), and some have no
|
||||
//! voice at all. This manifest is the ground truth.
|
||||
//!
|
||||
//! ## Alignment
|
||||
//!
|
||||
//! Slot keys and value records would be a 1:1 positional zip but for the valueless
|
||||
//! slots. We recover the gaps without decoding the binary node/index region by
|
||||
//! using the fully-derivable **`MS<NN><X>` → `S<NN><X>` anchors**: a non-`MS` slot
|
||||
//! whose record would be an `MS` target actually belongs to the upcoming `MS`
|
||||
//! slot, so the non-`MS` slot is a gap. (Decoding the node region would make this
|
||||
//! exact and also unlock the defaulted numeric fields in other IDXD tables.)
|
||||
|
||||
use crate::slb::VoiceLang;
|
||||
|
||||
/// One movie's manifest row.
|
||||
/// The role a cutscene slot plays in the mission flow, from its slot key.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MovieKind {
|
||||
/// `LOGO*`, `ADVERTISE_MOVIE`, `STAFF_ROLL` — boot/system movies.
|
||||
System,
|
||||
/// `MS<NN><X>` — story intro movie (camera on the speaker; stereo `VOICE_S*`).
|
||||
Intro,
|
||||
/// `STAGE<NN>_PHASE0<N>` — in-mission phase cutscene (radio box; `VOICE_RT*`).
|
||||
Phase,
|
||||
/// `STAGE<NN>_PHASE_END[_0N]` — phase-end cutscene.
|
||||
PhaseEnd,
|
||||
/// `S<NN>_SUPPLY_<ACROPOLIS|TANKER>` — resupply (hokyu) cutscene.
|
||||
Supply,
|
||||
}
|
||||
|
||||
/// One cutscene slot's manifest row: its semantic slot plus the bound assets.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovieEntry {
|
||||
/// Movie basename without extension, e.g. `S13A`, `hokyu_DS_s13A`.
|
||||
/// The slot key, e.g. `STAGE01_PHASE01`, `MS00A`, `S02_SUPPLY_ACROPOLIS`.
|
||||
/// Empty when parsed from a blob without the slot-key array (fallback).
|
||||
pub slot: String,
|
||||
/// The slot's role in the mission flow (intro vs. phase cutscene vs. supply …).
|
||||
pub kind: MovieKind,
|
||||
/// Mission/stage number (`STAGE<NN>` / `MS<NN>` / `S<NN>_SUPPLY`), if any.
|
||||
pub mission: Option<u8>,
|
||||
/// Phase number for [`MovieKind::Phase`]/[`MovieKind::PhaseEnd`], if any.
|
||||
pub phase: Option<u8>,
|
||||
/// Movie basename without extension, e.g. `S13A`, `RT01A`, `hokyu_DS_s13A`.
|
||||
pub movie: String,
|
||||
/// Voice bank basename (e.g. `VOICE_S13A`, `VOICE_D_450`), or `None` when the
|
||||
/// game assigns no voice-over to this movie.
|
||||
/// Voice bank basename (`VOICE_S13A`, `VOICE_D_450`), or `None` for no voice.
|
||||
pub voice_token: Option<String>,
|
||||
/// Subtitle table basename (`SUBTITLE_S13A.tbl`), pak prefix stripped, if any.
|
||||
pub subtitle: Option<String>,
|
||||
/// On-screen text-overlay (`TELOP`) basename (`pwrt01.prt`), if any.
|
||||
pub telop: Option<String>,
|
||||
}
|
||||
|
||||
/// Does this blob look like the movie manifest? (`IDXD` whose string pool
|
||||
@@ -44,25 +89,154 @@ pub fn is_manifest(bytes: &[u8]) -> bool {
|
||||
&& contains(bytes, b"SUBTITLE_")
|
||||
}
|
||||
|
||||
/// Parse the manifest's string pool into per-movie rows, in play order.
|
||||
/// The field-type keys that appear once in the value region as a schema template.
|
||||
const FIELD_KEYS: [&str; 4] = ["MOVIE", "VOICETRACK", "SUBTITLE", "TELOP"];
|
||||
|
||||
/// Parse the manifest into per-slot cutscene rows, in play order.
|
||||
///
|
||||
/// Each returned [`MovieEntry`] carries a movie; valueless slots (e.g. stage 5's
|
||||
/// absent resupply) are omitted. When the two-array slot/record layout is present
|
||||
/// (the real manifest) every row is tagged with its authoritative slot/kind/
|
||||
/// mission/phase; otherwise (a blob without the key array) the kind is inferred
|
||||
/// from the movie name and `slot` is empty.
|
||||
pub fn parse(bytes: &[u8]) -> Vec<MovieEntry> {
|
||||
let mut entries: Vec<MovieEntry> = Vec::new();
|
||||
for run in ascii_runs(bytes, 3) {
|
||||
if let Some(name) = run.strip_suffix(".wmv") {
|
||||
entries.push(MovieEntry {
|
||||
let toks = ascii_runs(bytes, 3);
|
||||
|
||||
// Locate the slot-key array (`LOGO1` … first `<movie>.wmv`) and the value
|
||||
// region (from the first `.wmv` onward). Fall back to a keyless scrape.
|
||||
let first_wmv = toks.iter().position(|t| t.ends_with(".wmv"));
|
||||
let key_start = toks.iter().position(|t| t == "LOGO1");
|
||||
let (slot_keys, value_toks): (Vec<&str>, &[String]) = match (key_start, first_wmv) {
|
||||
(Some(k), Some(w)) if k < w => (
|
||||
toks[k..w].iter().map(String::as_str).collect(),
|
||||
&toks[w..],
|
||||
),
|
||||
_ => (Vec::new(), toks.as_slice()),
|
||||
};
|
||||
|
||||
// Build the value records (drop the schema-template field keys).
|
||||
let mut records: Vec<MovieEntry> = Vec::new();
|
||||
for t in value_toks.iter().filter(|t| !FIELD_KEYS.contains(&t.as_str())) {
|
||||
if let Some(name) = t.strip_suffix(".wmv") {
|
||||
records.push(MovieEntry {
|
||||
slot: String::new(),
|
||||
kind: kind_from_movie(name),
|
||||
mission: None,
|
||||
phase: None,
|
||||
movie: name.to_string(),
|
||||
voice_token: None,
|
||||
subtitle: None,
|
||||
telop: None,
|
||||
});
|
||||
} else if run.starts_with("VOICE_") {
|
||||
// Belongs to the movie record currently being built.
|
||||
if let Some(last) = entries.last_mut() {
|
||||
if last.voice_token.is_none() {
|
||||
last.voice_token = Some(run.clone());
|
||||
} else if let Some(rec) = records.last_mut() {
|
||||
if let Some(v) = t.strip_prefix("VOICE_") {
|
||||
if rec.voice_token.is_none() {
|
||||
rec.voice_token = Some(format!("VOICE_{v}"));
|
||||
}
|
||||
} else if t.ends_with(".tbl") {
|
||||
rec.subtitle.get_or_insert_with(|| after_plus(t));
|
||||
} else if t.ends_with(".prt") {
|
||||
rec.telop.get_or_insert_with(|| after_plus(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
entries
|
||||
|
||||
// Attach slot keys to records, recovering valueless-slot gaps via MS anchors.
|
||||
if !slot_keys.is_empty() {
|
||||
let ms_targets: std::collections::HashSet<String> = slot_keys
|
||||
.iter()
|
||||
.filter_map(|k| ms_target(k))
|
||||
.collect();
|
||||
let mut j = 0usize;
|
||||
for slot in &slot_keys {
|
||||
if j >= records.len() {
|
||||
break;
|
||||
}
|
||||
// A gap: this slot's "record" is actually the next MS slot's movie.
|
||||
let would_be = &records[j].movie;
|
||||
let is_gap = match ms_target(slot) {
|
||||
// MS slot: only binds if its record is the expected `S<NN><X>`.
|
||||
Some(expected) => *would_be != expected,
|
||||
// Non-MS slot: a gap iff the record belongs to an upcoming MS slot.
|
||||
None => ms_targets.contains(would_be),
|
||||
};
|
||||
if is_gap {
|
||||
continue;
|
||||
}
|
||||
let (kind, mission, phase) = classify_slot(slot);
|
||||
let rec = &mut records[j];
|
||||
rec.slot = (*slot).to_string();
|
||||
rec.kind = kind;
|
||||
rec.mission = mission;
|
||||
rec.phase = phase;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
/// If `slot` is an `MS<NN><X>` intro slot, the movie it must bind to (`S<NN><X>`).
|
||||
fn ms_target(slot: &str) -> Option<String> {
|
||||
let rest = slot.strip_prefix("MS")?;
|
||||
// `<NN><X>`: two digits then a letter suffix.
|
||||
if rest.len() >= 3 && rest[..2].bytes().all(|b| b.is_ascii_digit()) {
|
||||
Some(format!("S{rest}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic role + mission/phase from a slot key.
|
||||
fn classify_slot(slot: &str) -> (MovieKind, Option<u8>, Option<u8>) {
|
||||
if let Some(rest) = slot.strip_prefix("MS") {
|
||||
if rest.len() >= 2 {
|
||||
return (MovieKind::Intro, rest[..2].parse().ok(), None);
|
||||
}
|
||||
}
|
||||
if let Some(rest) = slot.strip_prefix("STAGE") {
|
||||
let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(m) = nn.parse::<u8>() {
|
||||
if slot.contains("PHASE_END") {
|
||||
// trailing `_NN` if present (`STAGE12_PHASE_END_02`)
|
||||
let phase = slot.rsplit('_').next().and_then(|x| x.parse().ok());
|
||||
return (MovieKind::PhaseEnd, Some(m), phase);
|
||||
}
|
||||
// `STAGE<NN>_PHASE0<N>`
|
||||
let phase = rest[nn.len()..]
|
||||
.strip_prefix("_PHASE")
|
||||
.and_then(|p| p.parse().ok());
|
||||
return (MovieKind::Phase, Some(m), phase);
|
||||
}
|
||||
}
|
||||
if slot.contains("_SUPPLY") {
|
||||
if let Some(rest) = slot.strip_prefix('S') {
|
||||
let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
return (MovieKind::Supply, nn.parse().ok(), None);
|
||||
}
|
||||
}
|
||||
(MovieKind::System, None, None)
|
||||
}
|
||||
|
||||
/// Fallback kind from a movie basename (used when no slot-key array is present).
|
||||
fn kind_from_movie(movie: &str) -> MovieKind {
|
||||
if movie.starts_with("hokyu_") {
|
||||
MovieKind::Supply
|
||||
} else if movie.starts_with("RT") {
|
||||
MovieKind::Phase
|
||||
} else if movie.len() >= 3
|
||||
&& movie.starts_with('S')
|
||||
&& movie[1..3].bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
MovieKind::Intro
|
||||
} else {
|
||||
MovieKind::System
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a `<pak>+<name>` reference after the `+` (or the whole string).
|
||||
fn after_plus(s: &str) -> String {
|
||||
s.rsplit('+').next().unwrap_or(s).to_string()
|
||||
}
|
||||
|
||||
/// The voice bank basename bound to `movie`, if the manifest lists one.
|
||||
@@ -151,12 +325,82 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn groups_movies_and_binds_voice() {
|
||||
// Keyless fallback path: movie + voice + subtitle + inferred kind.
|
||||
let m = parse(&synth());
|
||||
assert_eq!(m.len(), 3);
|
||||
assert_eq!(m[0].movie, "S13A");
|
||||
assert_eq!(m[0].voice_token.as_deref(), Some("VOICE_S13A"));
|
||||
assert_eq!(m[0].kind, MovieKind::Intro);
|
||||
assert_eq!(m[0].subtitle.as_deref(), Some("SUBTITLE_S13A.tbl"));
|
||||
assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450"));
|
||||
assert_eq!(m[1].kind, MovieKind::Supply);
|
||||
assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over");
|
||||
assert_eq!(m[2].kind, MovieKind::Supply);
|
||||
}
|
||||
|
||||
/// Build a two-array (keys-then-records) manifest like the real one.
|
||||
fn synth_two_array(keys: &[&str], records: &[&[&str]]) -> Vec<u8> {
|
||||
let mut b = b"IDXD".to_vec();
|
||||
b.extend_from_slice(&[0, 0, 0, 0x05, 0, 0]);
|
||||
for k in keys {
|
||||
b.extend_from_slice(k.as_bytes());
|
||||
b.push(0);
|
||||
}
|
||||
for rec in records {
|
||||
for s in *rec {
|
||||
b.extend_from_slice(s.as_bytes());
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_array_slots_kind_mission_phase() {
|
||||
let blob = synth_two_array(
|
||||
&["LOGO1", "MS01A", "STAGE01_PHASE01", "STAGE01_PHASE_END_02"],
|
||||
&[
|
||||
&["logo1.wmv", "MOVIE"],
|
||||
&["S01A.wmv", "eng.pak+SUBTITLE_S01A.tbl", "VOICE_S01A"],
|
||||
&["RT01A.wmv", "eng.pak+pwrt01.prt", "VOICE_RT01A", "VOICETRACK"],
|
||||
&["RT01C_2.wmv", "VOICE_RT01C_2"],
|
||||
],
|
||||
);
|
||||
let m = parse(&blob);
|
||||
assert_eq!(m.len(), 4);
|
||||
assert_eq!((m[0].slot.as_str(), m[0].kind), ("LOGO1", MovieKind::System));
|
||||
assert_eq!(
|
||||
(m[1].slot.as_str(), m[1].kind, m[1].mission),
|
||||
("MS01A", MovieKind::Intro, Some(1))
|
||||
);
|
||||
assert_eq!(
|
||||
(m[2].slot.as_str(), m[2].kind, m[2].mission, m[2].phase),
|
||||
("STAGE01_PHASE01", MovieKind::Phase, Some(1), Some(1))
|
||||
);
|
||||
assert_eq!(m[2].telop.as_deref(), Some("pwrt01.prt"));
|
||||
assert_eq!(
|
||||
(m[3].slot.as_str(), m[3].kind, m[3].phase),
|
||||
("STAGE01_PHASE_END_02", MovieKind::PhaseEnd, Some(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valueless_supply_slot_is_skipped_via_ms_anchor() {
|
||||
// S05_SUPPLY has no record; its "record" (S06A) belongs to the next MS slot.
|
||||
let blob = synth_two_array(
|
||||
&["LOGO1", "MS05A", "S05_SUPPLY_ACROPOLIS", "MS06A"],
|
||||
&[
|
||||
&["logo1.wmv"],
|
||||
&["S05A.wmv", "VOICE_S05A"],
|
||||
&["S06A.wmv", "VOICE_S06A"],
|
||||
],
|
||||
);
|
||||
let m = parse(&blob);
|
||||
assert_eq!(m.len(), 3, "the valueless supply slot yields no entry");
|
||||
assert_eq!((m[1].slot.as_str(), m[1].movie.as_str()), ("MS05A", "S05A"));
|
||||
// S06A binds to MS06A, NOT to the intervening valueless supply slot.
|
||||
assert_eq!((m[2].slot.as_str(), m[2].movie.as_str()), ("MS06A", "S06A"));
|
||||
assert!(m.iter().all(|e| e.slot != "S05_SUPPLY_ACROPOLIS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -162,6 +162,43 @@ pub fn track_demo_ids(lang_pak: &PakArchive, movie_basename: &str) -> Vec<u32> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-line `(demo id, start seconds)` for a movie's timing track — the radio-box
|
||||
/// voice schedule. Each `MSG_DEMO_<id>` reference takes the start time of the
|
||||
/// timing that follows it (a caption may list two demo lines under one timing;
|
||||
/// both then share that start). Empty for inline-text / absent tracks (those are
|
||||
/// story movies whose voice is the single manifest `VOICETRACK`, not per-line).
|
||||
pub fn track_voice_cues(lang_pak: &PakArchive, movie_basename: &str) -> Vec<(u32, f32)> {
|
||||
let Some(Ok(track)) = lang_pak.read_by_hash(track_key(movie_basename)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !is_ixud(&track) {
|
||||
return Vec::new();
|
||||
}
|
||||
let toks = utf16le_tokens(&track);
|
||||
let start = toks
|
||||
.iter()
|
||||
.position(|t| t.ends_with("SUBTITLE"))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let mut out = Vec::new();
|
||||
let mut pending: Vec<u32> = Vec::new();
|
||||
for tok in &toks[start..] {
|
||||
match parse_timing(tok) {
|
||||
Some((s, _)) => {
|
||||
for d in pending.drain(..) {
|
||||
out.push((d, s));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(d) = demo_ref(tok) {
|
||||
pending.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build `demo id → ordered caption lines` from a `GP_MAIN_GAME_<L>` pack.
|
||||
///
|
||||
/// Scans every IXUD block, pairing each text token with the immediately
|
||||
|
||||
142
crates/sylpheed-formats/src/movie_voice.rs
Normal file
142
crates/sylpheed-formats/src/movie_voice.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Cutscene-voice resolution: movie → sound-id → continuous-stream byte region.
|
||||
//!
|
||||
//! Reverse-engineered 2026-07-22 (Canary file-I/O trace, user-confirmed by ear).
|
||||
//! The movie voices form **one continuous XMA stream**, physically chunked into
|
||||
//! `<lang>\Movie\VOICE_*.slb` `sound.pak` TOC entries whose boundaries do **not**
|
||||
//! align with the cutscene cues — a single cue routinely spans two `.slb` chunks,
|
||||
//! so a `.slb` does not necessarily hold the track its name claims. Each cue's
|
||||
//! audio ends at an inline **trailer descriptor** `(sound_id: u32be, 0x11, …)`
|
||||
//! whose id repeats at +0x800. Cue N's audio = the stream bytes
|
||||
//! `[descriptor(N-1) .. descriptor(N)]`, read continuously across chunk
|
||||
//! boundaries. Verified: decoded voice length matches each movie within ~0.3 s,
|
||||
//! including cross-boundary cues (RT01B/RT01C span `VOICE_ADV`→`VOICE_RT01A`→…).
|
||||
//!
|
||||
//! Resolution chain:
|
||||
//! 1. movie → cue name (`ADVERTISE_MOVIE` manifest `VOICETRACK`, e.g. `VOICE_RT01A`)
|
||||
//! 2. cue name → sound-id (master sound registry, [`registry_voice_ids`], → 1601)
|
||||
//! 3. sound-id → `[start, end]` global offsets via [`find_descriptor`] over the
|
||||
//! `sound.pNN` stream (the caller supplies the bytes + the physical anchor).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Parse the master sound registry — the large per-language `tables.pak` IDXD
|
||||
/// entry (schema `0x13cb84ba`) that carries the `<lang>\Movie\VOICE_*.slb` paths —
|
||||
/// into `cue-name → sound-id` from its `NAME\0 ID\0` string pool
|
||||
/// (e.g. `VOICE_ADV`→1600, `VOICE_RT01A`→1601).
|
||||
pub fn registry_voice_ids(registry: &[u8]) -> HashMap<String, u32> {
|
||||
let mut toks: Vec<&[u8]> = Vec::new();
|
||||
let mut start = 0usize;
|
||||
for i in 0..registry.len() {
|
||||
if registry[i] == 0 {
|
||||
if i > start {
|
||||
toks.push(®istry[start..i]);
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
for w in toks.windows(2) {
|
||||
let (name, num) = (w[0], w[1]);
|
||||
if name.starts_with(b"VOICE_")
|
||||
&& !num.is_empty()
|
||||
&& num.iter().all(u8::is_ascii_digit)
|
||||
{
|
||||
if let (Ok(n), Ok(id)) = (
|
||||
std::str::from_utf8(name),
|
||||
std::str::from_utf8(num).unwrap().parse::<u32>(),
|
||||
) {
|
||||
out.entry(n.to_string()).or_insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The constant marker following a cue's sound-id in its inline trailer.
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
/// The trailer repeats its id this many bytes later; used to reject a coincidental
|
||||
/// `(id, 0x11)` pair inside XMA audio (probability of a false match is ~2⁻⁶⁴).
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
|
||||
/// Byte offset (within `buf`, a slice of the continuous voice stream) of cue
|
||||
/// `id`'s trailer descriptor: the first big-endian `(id, 0x11)` pair whose id
|
||||
/// repeats at `+0x800`. `None` if absent in the slice.
|
||||
pub fn find_descriptor(buf: &[u8], id: u32) -> Option<usize> {
|
||||
if buf.len() < DESC_REPEAT + 4 {
|
||||
return None;
|
||||
}
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
if be(o) == id && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
return Some(o);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Plausible sound-id range for a trailer (all real cue ids are well under this).
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
/// Offset of the nearest cue trailer strictly **before** `before` — any plausible
|
||||
/// id. Used to bound a cue's START when its `id-1` predecessor doesn't exist
|
||||
/// (the sound-id sequence has gaps, e.g. `VOICE_D_453`=7142 → `VOICE_D_454`=7188).
|
||||
/// Scans downward and returns the first (largest-offset) valid `(id, 0x11)` with
|
||||
/// the `+0x800` id-repeat. `None` if there is no trailer below `before`.
|
||||
pub fn find_descriptor_before(buf: &[u8], before: usize) -> Option<usize> {
|
||||
if before < 4 {
|
||||
return None;
|
||||
}
|
||||
let cap = buf.len().checked_sub(DESC_REPEAT + 4)?;
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
// Start strictly below `before` (4-aligned) so the target's own trailer is
|
||||
// never returned as its predecessor.
|
||||
let mut o = (before - 1).min(cap) & !3;
|
||||
loop {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
return Some(o);
|
||||
}
|
||||
if o < 4 {
|
||||
return None;
|
||||
}
|
||||
o -= 4;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_parses_name_id_pairs() {
|
||||
let mut b = Vec::new();
|
||||
for tok in [
|
||||
b"VOICE_ADV".as_slice(),
|
||||
b"1600",
|
||||
b"VOICE_RT01A",
|
||||
b"1601",
|
||||
b"NOT_A_VOICE",
|
||||
b"9",
|
||||
] {
|
||||
b.extend_from_slice(tok);
|
||||
b.push(0);
|
||||
}
|
||||
let ids = registry_voice_ids(&b);
|
||||
assert_eq!(ids.get("VOICE_ADV"), Some(&1600));
|
||||
assert_eq!(ids.get("VOICE_RT01A"), Some(&1601));
|
||||
assert_eq!(ids.get("NOT_A_VOICE"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_descriptor_matches_repeat() {
|
||||
let mut b = vec![0u8; 0x900];
|
||||
b[0..4].copy_from_slice(&1601u32.to_be_bytes());
|
||||
b[4..8].copy_from_slice(&0x11u32.to_be_bytes());
|
||||
b[DESC_REPEAT..DESC_REPEAT + 4].copy_from_slice(&1601u32.to_be_bytes());
|
||||
assert_eq!(find_descriptor(&b, 1601), Some(0));
|
||||
assert_eq!(find_descriptor(&b, 1600), None);
|
||||
}
|
||||
}
|
||||
687
crates/sylpheed-formats/src/ship.rs
Normal file
687
crates/sylpheed-formats/src/ship.rs
Normal file
@@ -0,0 +1,687 @@
|
||||
//! Whole-ship assembly from XBG7 part families.
|
||||
//!
|
||||
//! Project Sylpheed's capital ships are never stored as a single mesh. A ship is
|
||||
//! modelled, then **split into destructible parts** — hull segments, bridge,
|
||||
//! engines, integral turrets — each shipped as its own XBG7 resource inside a
|
||||
//! `hidden/resource3d/Stage_SNN.xpr` container. The split lets the game hide a
|
||||
//! part when the player destroys it (bridge, shield generator, thruster) and swap
|
||||
//! in a `_break` variant.
|
||||
//!
|
||||
//! Each part's vertices are authored around the origin; the world placement lives
|
||||
//! in the **primary composite scene graph** `e_rou_<id>` (see [`assemble_ship`]).
|
||||
//! Its node hierarchy (`rou_<id>_root`→`…_front`/`…_rear`→`rou_<id>_bdy_NN`) names
|
||||
//! the separate hull resources and carries their world TRS — the cross-resource
|
||||
//! analogue of how [`mesh::node_transforms`] poses one model's sub-parts. The
|
||||
//! `GN_*` nodes in the same composite are the Vessel hardpoint frames (bridge /
|
||||
//! shield / engine / turret mounts, matching the IDXD recipe `Frame` names).
|
||||
//!
|
||||
//! Resource naming (learned from the retail stage containers):
|
||||
//! - `<id>` is `[a-z]NNN` (`e106`, `f105`, `n050`). The leading letter is the
|
||||
//! faction: `e` = ADAN (enemy), `f` = TCAF (player side), `n` = neutral /
|
||||
//! structures / debris.
|
||||
//! - A base part is `<id>_<name>` (`e106_bdy_01`, `e106_brg_01`, `f105_sld_02`).
|
||||
//! - `_l` / `_m` / `_d`(`NN`) suffixes are lower level-of-detail copies; `_bNN` /
|
||||
//! `_dead` / `_break` are destruction geometry; `_joint` / `_open` / `_Near` are
|
||||
//! logic/animation nodes. All are skipped.
|
||||
//! - `e_rou_<id>` is the primary composite; `e_rou_<id>_eng` / `_wep_*` are LOCAL
|
||||
//! detail rigs (their nodes are in their own frame, not ship-space) and are NOT
|
||||
//! used for placement.
|
||||
|
||||
use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Which side a ship belongs to, from the first letter of its resource id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Faction {
|
||||
/// `e…` — ADAN (the enemy).
|
||||
Adan,
|
||||
/// `f…` — TCAF (the player's coalition).
|
||||
Tcaf,
|
||||
/// `n…` — neutral: stations, structures, asteroids, debris.
|
||||
Neutral,
|
||||
/// Anything else (`j…`, `s…`, …).
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Faction {
|
||||
fn from_id(id: &str) -> Faction {
|
||||
match id.as_bytes().first() {
|
||||
Some(b'e') => Faction::Adan,
|
||||
Some(b'f') => Faction::Tcaf,
|
||||
Some(b'n') => Faction::Neutral,
|
||||
_ => Faction::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short human label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Faction::Adan => "ADAN",
|
||||
Faction::Tcaf => "TCAF",
|
||||
Faction::Neutral => "Neutral",
|
||||
Faction::Other => "Other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ship reconstructed from one XBG7 part family in a stage container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShipModel {
|
||||
/// The family id, e.g. `e106` — matches a [`crate::game_data::Vessel`] whose
|
||||
/// `model` is `rou_e106`.
|
||||
pub id: String,
|
||||
/// Faction inferred from the id's leading letter.
|
||||
pub faction: Faction,
|
||||
/// Base geometry resource names to draw together, in directory order.
|
||||
pub parts: Vec<String>,
|
||||
/// Whether a composite scene graph (`e_rou_<id>`) exists — i.e. this is a
|
||||
/// real assembled ship. Families without one (fighter morph sets, shared
|
||||
/// turret/prop parts) have no placement data and would stack at the origin.
|
||||
pub has_composite: bool,
|
||||
}
|
||||
|
||||
/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
|
||||
/// → `e106`). Returns `None` for scene/logic resources (`e_rou_e106`) and
|
||||
/// anything not starting with a letter + three digits.
|
||||
pub fn ship_id_of(resource: &str) -> Option<&str> {
|
||||
let b = resource.as_bytes();
|
||||
if b.len() >= 4
|
||||
&& b[0].is_ascii_lowercase()
|
||||
&& b[1].is_ascii_digit()
|
||||
&& b[2].is_ascii_digit()
|
||||
&& b[3].is_ascii_digit()
|
||||
{
|
||||
// Reject a longer alnum run (`e1234…`): the id is exactly letter+3 digits,
|
||||
// followed by end-of-string or a non-alphanumeric (`_`).
|
||||
if b.get(4).is_none_or(|c| !c.is_ascii_alphanumeric()) {
|
||||
return Some(&resource[..4]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a resource is a base geometry part (not a LOD copy, destruction
|
||||
/// variant, or scene/logic node) that should be drawn in a whole-ship render.
|
||||
pub fn is_base_part(resource: &str) -> bool {
|
||||
if ship_id_of(resource).is_none() {
|
||||
return false;
|
||||
}
|
||||
// Level-of-detail copies of a base part: a trailing `_l`/`_m`/`_d` token,
|
||||
// optionally with a number (`_d01`, `_l02`).
|
||||
if let Some(last) = resource.rsplit('_').next() {
|
||||
let mut cs = last.chars();
|
||||
if matches!(cs.next(), Some('l' | 'm' | 'd')) && cs.all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Destruction / logic geometry.
|
||||
if resource.contains("break")
|
||||
|| resource.contains("dead")
|
||||
|| resource.contains("_joint")
|
||||
|| resource.contains("_open")
|
||||
|| resource.contains("_Near")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Break-piece geometry carries a bare `b` / `bNN` token (`e106_brg_01_b_02`,
|
||||
// `e108_eng_01_b01`). `bdy` etc. are safe — only an exact `b`-then-digits token
|
||||
// counts.
|
||||
if resource.split('_').skip(1).any(|t| {
|
||||
t == "b" || (t.starts_with('b') && t.len() >= 2 && t[1..].bytes().all(|c| c.is_ascii_digit()))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Group every base part in an XPR2 container into whole ships, keyed by id.
|
||||
///
|
||||
/// Ships are returned sorted by id. A stage container also holds shared turret /
|
||||
/// prop models as their own ids — every group with at least one base part is
|
||||
/// returned; the caller can filter by [`ShipModel::parts`] length or faction.
|
||||
pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
||||
let names = xbg7_resource_names(bytes);
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut ships: std::collections::HashMap<String, ShipModel> = std::collections::HashMap::new();
|
||||
for n in &names {
|
||||
if !is_base_part(n) {
|
||||
continue;
|
||||
}
|
||||
let id = ship_id_of(n).unwrap().to_string();
|
||||
let entry = ships.entry(id.clone()).or_insert_with(|| {
|
||||
ids.push(id.clone());
|
||||
let has_composite = !composites_for(&names, &id).is_empty();
|
||||
ShipModel {
|
||||
id: id.clone(),
|
||||
faction: Faction::from_id(&id),
|
||||
parts: Vec::new(),
|
||||
has_composite,
|
||||
}
|
||||
});
|
||||
entry.parts.push(n.clone());
|
||||
}
|
||||
ids.sort();
|
||||
ids.into_iter().map(|id| ships.remove(&id).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Map a composite scene-graph node name to the geometry resource it draws.
|
||||
/// Hull nodes are `rou_<id>_<part>[_root|_mov|_NN]`; the geometry resource is the
|
||||
/// `<id>_<part>` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to
|
||||
/// nothing and only pose their children.
|
||||
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
||||
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
|
||||
let try_stem = |s: &str| -> Option<String> {
|
||||
if resources.contains(s) {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
// Squeezed digit run (`wep02` names the `wep_02_01` resource): insert an
|
||||
// underscore before the digits and try the `_01` first-part too.
|
||||
if let Some(pos) = s.rfind(|c: char| c.is_ascii_digit()) {
|
||||
let mut start = pos;
|
||||
while start > 0 && s.as_bytes()[start - 1].is_ascii_digit() {
|
||||
start -= 1;
|
||||
}
|
||||
if start > 0 && s.as_bytes()[start - 1] != b'_' {
|
||||
let split = format!("{}_{}", &s[..start], &s[start..]);
|
||||
if resources.contains(&split) {
|
||||
return Some(split);
|
||||
}
|
||||
let first = format!("{split}_01");
|
||||
if resources.contains(&first) {
|
||||
return Some(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
if let Some(r) = try_stem(stem) {
|
||||
return Some(r);
|
||||
}
|
||||
for suf in ["_root", "_mov"] {
|
||||
if let Some(s) = stem.strip_suffix(suf) {
|
||||
if let Some(r) = try_stem(s) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
|
||||
if let Some(pos) = stem.rfind('_') {
|
||||
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
|
||||
if let Some(r) = try_stem(&stem[..pos]) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The trailing digit run of a name (`GN_Bridge_01` → `01`, `sld_02` → `02`).
|
||||
fn trailing_index(name: &str) -> &str {
|
||||
let bytes = name.as_bytes();
|
||||
let mut start = bytes.len();
|
||||
while start > 0 && bytes[start - 1].is_ascii_digit() {
|
||||
start -= 1;
|
||||
}
|
||||
&name[start..]
|
||||
}
|
||||
|
||||
/// Which stage-container resources are the composite scene graphs for ship `id`
|
||||
/// (`e_rou_e106`, `e_rou_e106_eng`, …). Excludes destruction (`_break`) scenes.
|
||||
fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> {
|
||||
let needle = format!("rou_{id}");
|
||||
names
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
n.contains(&needle)
|
||||
&& ship_id_of(n).is_none()
|
||||
&& !n.contains("break")
|
||||
&& !n.contains("dead")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
|
||||
/// ship-local frame — the placement the game builds at runtime.
|
||||
///
|
||||
/// Fully static and EXACT (validated part-for-part against an e106 runtime
|
||||
/// capture — see `docs/re/ship-placement-runtime-capture.md`):
|
||||
/// 1. **Composite nodes** — every `rou_*` node in the primary composite
|
||||
/// (`e_rou_<id>`) places a geometry resource, INCLUDING repeated instances
|
||||
/// and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull).
|
||||
/// 2. **Detail rigs** — `e_rou_<id>_eng` is the engine cluster rig; its nodes
|
||||
/// (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary
|
||||
/// composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`.
|
||||
/// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at
|
||||
/// their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS).
|
||||
/// 4. **Mirrored twins** — a port/starboard pair (`bdy_01`/`bdy_02`) shares one
|
||||
/// geometry; the instance whose lateral offset points the geometry's dominant
|
||||
/// side inboard is drawn X-reflected (capture-verified; det < 0 → the caller
|
||||
/// reverses winding).
|
||||
///
|
||||
/// `include_external` keeps tiers 2–4 (off = bare composite-node hull).
|
||||
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
|
||||
let names = xbg7_resource_names(bytes);
|
||||
let resources: HashSet<String> = names.iter().cloned().collect();
|
||||
|
||||
// The PRIMARY composite carries the ship-space layout (hull bodies, turret
|
||||
// mounts + every `GN_*` hardpoint frame): the one with the most nodes.
|
||||
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
|
||||
.into_iter()
|
||||
.filter(|c| !c.ends_with("_joint"))
|
||||
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
|
||||
.collect();
|
||||
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
|
||||
// No composite at all → raw fallback below.
|
||||
let mut placed = Vec::new();
|
||||
const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
|
||||
placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
|
||||
}
|
||||
return placed;
|
||||
};
|
||||
|
||||
let mut placed: Vec<ScenePart> = Vec::new();
|
||||
let mut placed_res: HashSet<String> = HashSet::new();
|
||||
// GN hardpoint frames, world-space, from the primary composite.
|
||||
let mut frames: Vec<ScenePart> = Vec::new();
|
||||
|
||||
// Tier 1: every composite node placement, instances included. Cross-id
|
||||
// resources (shared e303/e4NN turret models the composite mounts) count too.
|
||||
for node in nodes {
|
||||
if node.resource.starts_with("GN_") {
|
||||
frames.push(node.clone());
|
||||
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
|
||||
if ship_id_of(&res) != Some(id) && !include_external {
|
||||
continue; // hull-only view: own parts only
|
||||
}
|
||||
// Distinct INSTANCES keep their own placement; a nested alias of the
|
||||
// same resource at the same transform (`bdy_01_mov` + its identity
|
||||
// `bdy_01` child) collapses to one draw.
|
||||
let dup = placed.iter().any(|p| {
|
||||
p.resource == res
|
||||
&& (p.t[0] - node.t[0]).abs() < 0.01
|
||||
&& (p.t[1] - node.t[1]).abs() < 0.01
|
||||
&& (p.t[2] - node.t[2]).abs() < 0.01
|
||||
});
|
||||
if !dup {
|
||||
placed_res.insert(res.clone());
|
||||
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes
|
||||
// are in the FRAME's local space (two mirrored `eng_01` nacelles + centre
|
||||
// `eng_02` for e106) — capture-verified to 0.1 units.
|
||||
if include_external {
|
||||
let rig_name = format!("e_rou_{id}_eng");
|
||||
if names.contains(&rig_name) {
|
||||
if let Some(frame) = frames.iter().find(|f| f.resource.starts_with("GN_Engine")) {
|
||||
for rn in scene_world_nodes(bytes, &rig_name) {
|
||||
let Some(res) = resolve_hull_resource(&rn.resource, &resources) else {
|
||||
continue;
|
||||
};
|
||||
// world = frame ∘ rig_local
|
||||
let m = m3_mul_pub(frame.m, rn.m);
|
||||
let rt = m3_vec_pub(frame.m, rn.t);
|
||||
let t = [rt[0] + frame.t[0], rt[1] + frame.t[1], rt[2] + frame.t[2]];
|
||||
placed_res.insert(res.clone());
|
||||
placed.push(ScenePart { resource: res, m, t, s: rn.s });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: remaining external parts at their exact GN frame (full TRS).
|
||||
const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")];
|
||||
for part in names
|
||||
.iter()
|
||||
.filter(|_| include_external)
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id))
|
||||
{
|
||||
if placed_res.contains(part) {
|
||||
continue;
|
||||
}
|
||||
let rest = &part[(id.len() + 1).min(part.len())..]; // "brg_01"
|
||||
let mut toks = rest.split('_');
|
||||
let cat = toks.next().unwrap_or("");
|
||||
let idx = toks.next().unwrap_or("");
|
||||
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
|
||||
continue;
|
||||
};
|
||||
// An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
|
||||
// compare `"01" == ""` and fall through, so the bridge was silently
|
||||
// dropped from the assembly while the game draws it — caught by a runtime
|
||||
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
|
||||
// With no index to match on, take the lowest-numbered frame of the
|
||||
// category; an indexed part still matches its own index only.
|
||||
let mut cands: Vec<&ScenePart> = frames
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.resource.contains(gncat)
|
||||
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
|
||||
if let Some(frame) = cands.first().copied() {
|
||||
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
|
||||
placed_res.insert(part.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 4: mirror one of a shared-geometry twin pair. The engine uploads the
|
||||
// second of a port/starboard pair X-reflected (capture-verified on
|
||||
// bdy_01/bdy_02): for two same-geometry resources placed at ±TX, the
|
||||
// instance whose TX sign matches the geometry's dominant-X side draws
|
||||
// mirrored (so the pair ends up symmetric instead of twice the same side).
|
||||
apply_twin_mirrors(bytes, &mut placed);
|
||||
|
||||
// Fallback for a ship with no composite scene graph (a simple prop): draw its
|
||||
// base parts untransformed rather than nothing.
|
||||
if placed.is_empty() {
|
||||
const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
|
||||
placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
|
||||
}
|
||||
}
|
||||
|
||||
placed
|
||||
}
|
||||
|
||||
/// The ship's exhaust mount frames (`GN_Jet_*` / `GN_SJet_*`), world-space, from
|
||||
/// the primary composite. These are where the game renders the engine exhaust
|
||||
/// FX — the visible "thrusters". The engine GEOMETRY itself sits recessed in
|
||||
/// the hull (capture-verified across 43 e106 instances: the game draws
|
||||
/// `eng_01`/`eng_02` exactly where [`assemble_ship`] puts them); without the
|
||||
/// FX the assembled ship reads engine-less, so a viewer can draw exhaust
|
||||
/// markers at these frames.
|
||||
pub fn exhaust_frames(bytes: &[u8], id: &str) -> Vec<ScenePart> {
|
||||
let names = xbg7_resource_names(bytes);
|
||||
let scenes: Vec<Vec<ScenePart>> = composites_for(&names, id)
|
||||
.into_iter()
|
||||
.filter(|c| !c.ends_with("_joint"))
|
||||
.map(|c| scene_world_nodes(bytes, c))
|
||||
.collect();
|
||||
let Some(nodes) = scenes.into_iter().max_by_key(|n| n.len()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
nodes
|
||||
.into_iter()
|
||||
.filter(|n| n.resource.starts_with("GN_Jet") || n.resource.starts_with("GN_SJet"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect shared-geometry port/starboard twin RESOURCES placed at ±TX and mark
|
||||
/// the dominant-side instance as an X-reflection (negate the matrix X column).
|
||||
/// Twins are `<stem>_01`/`<stem>_02` pairs with equal vertex data; the reflected
|
||||
/// one is the instance whose TX sign equals the geometry's mean-X sign (the
|
||||
/// un-reflected drawing already covers that side's opposite).
|
||||
fn apply_twin_mirrors(bytes: &[u8], placed: &mut [ScenePart]) {
|
||||
use crate::mesh::Xbg7Model;
|
||||
// Candidate pairs: resources differing only in a trailing _01/_02, both
|
||||
// placed exactly once, at opposite-sign TX of equal magnitude.
|
||||
let mut pairs: Vec<(usize, usize)> = Vec::new();
|
||||
for i in 0..placed.len() {
|
||||
let a = &placed[i];
|
||||
let Some(stem) = a.resource.strip_suffix("_01") else { continue };
|
||||
let twin = format!("{stem}_02");
|
||||
let Some(j) = placed.iter().position(|p| p.resource == twin) else { continue };
|
||||
let b = &placed[j];
|
||||
if (a.t[0] + b.t[0]).abs() < 0.5 && a.t[0].abs() > 1.0 {
|
||||
pairs.push((i, j));
|
||||
}
|
||||
}
|
||||
if pairs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let want: HashSet<String> = pairs
|
||||
.iter()
|
||||
.flat_map(|&(i, j)| [placed[i].resource.clone(), placed[j].resource.clone()])
|
||||
.collect();
|
||||
let models = Xbg7Model::models_named(bytes, &want, &|| false);
|
||||
for (i, j) in pairs {
|
||||
let get = |r: &str| models.iter().find(|m| m.name == r);
|
||||
let (Some(ma), Some(mb)) = (get(&placed[i].resource), get(&placed[j].resource)) else {
|
||||
continue;
|
||||
};
|
||||
let first = |m: &Xbg7Model| -> Vec<[f32; 3]> {
|
||||
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).take(16).collect()
|
||||
};
|
||||
let (fa, fb) = (first(ma), first(mb));
|
||||
if fa.len() != fb.len()
|
||||
|| !fa.iter().zip(&fb).all(|(p, q)| {
|
||||
(p[0] - q[0]).abs() < 1e-3 && (p[1] - q[1]).abs() < 1e-3 && (p[2] - q[2]).abs() < 1e-3
|
||||
})
|
||||
{
|
||||
continue; // distinct (already-mirrored) geometries — nothing to do
|
||||
}
|
||||
let mean_x: f32 = ma
|
||||
.meshes
|
||||
.iter()
|
||||
.flat_map(|s| s.positions.iter())
|
||||
.map(|p| p[0])
|
||||
.sum::<f32>()
|
||||
/ ma.meshes.iter().map(|s| s.positions.len()).sum::<usize>().max(1) as f32;
|
||||
if mean_x.abs() < 1e-3 {
|
||||
continue; // symmetric geometry — mirroring is a no-op
|
||||
}
|
||||
// Reflect the instance whose lateral offset OPPOSES the geometry's
|
||||
// dominant side (drawn plain it would fold onto the centreline; the
|
||||
// capture shows the engine reflects exactly this one — for e106 the
|
||||
// −264 port instance draws the file data plain, the +264 one mirrored).
|
||||
let k = if (placed[i].t[0] > 0.0) == (mean_x > 0.0) { j } else { i };
|
||||
for row in &mut placed[k].m {
|
||||
row[0] = -row[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small helpers mirroring mesh.rs's private affine ops.
|
||||
fn m3_mul_pub(a: [[f32; 3]; 3], b: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
|
||||
let mut o = [[0.0f32; 3]; 3];
|
||||
for r in 0..3 {
|
||||
for c in 0..3 {
|
||||
o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
fn m3_vec_pub(a: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
|
||||
a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
|
||||
a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn id_extraction() {
|
||||
assert_eq!(ship_id_of("e106_bdy_01"), Some("e106"));
|
||||
assert_eq!(ship_id_of("f105_sld_02_l"), Some("f105"));
|
||||
assert_eq!(ship_id_of("e_rou_e106"), None);
|
||||
assert_eq!(ship_id_of("_rou_e106_break"), None);
|
||||
assert_eq!(ship_id_of("Base"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_part_filter() {
|
||||
assert!(is_base_part("e106_bdy_01"));
|
||||
assert!(is_base_part("e106_brg_01"));
|
||||
assert!(is_base_part("f105_wep_02_01"));
|
||||
assert!(!is_base_part("e106_bdy_01_l")); // LOD
|
||||
assert!(!is_base_part("e106_bdy_01_m")); // LOD
|
||||
assert!(!is_base_part("e106_brg_01_b_02")); // break piece
|
||||
assert!(!is_base_part("_rou_e106_break")); // logic
|
||||
assert!(!is_base_part("e_rou_e106")); // scene node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faction_from_id() {
|
||||
assert_eq!(Faction::from_id("e106"), Faction::Adan);
|
||||
assert_eq!(Faction::from_id("f105"), Faction::Tcaf);
|
||||
assert_eq!(Faction::from_id("n050"), Faction::Neutral);
|
||||
assert_eq!(Faction::from_id("j004"), Faction::Other);
|
||||
}
|
||||
|
||||
// Disc-gated: reconstruct the ADAN cruiser family from a real stage container.
|
||||
#[test]
|
||||
fn ships_from_real_stage() {
|
||||
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
|
||||
eprintln!("SYLPHEED_ISO unset — skipping");
|
||||
return;
|
||||
};
|
||||
let bytes = {
|
||||
use crate::xiso::open_iso;
|
||||
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
|
||||
r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap()
|
||||
})
|
||||
};
|
||||
let ships = ships_in_container(&bytes);
|
||||
assert!(!ships.is_empty(), "stage should hold ships");
|
||||
// The ADAN frigate e108 appears in S02 with hull + engine + weapon parts.
|
||||
let e108 = ships.iter().find(|s| s.id == "e108").expect("e108 present");
|
||||
assert_eq!(e108.faction, Faction::Adan);
|
||||
assert!(e108.parts.iter().any(|p| p.contains("_bdy_")));
|
||||
assert!(e108.parts.iter().all(|p| is_base_part(p)));
|
||||
}
|
||||
|
||||
// Disc-gated GROUND-TRUTH test: the fully-static assembly must reproduce the
|
||||
// runtime F10 capture (the baked e106 table) part-for-part — translations
|
||||
// AND the engine-rig rotation — plus the multi-instance parts the capture's
|
||||
// vbase-dedup could not see (two nacelles, two turrets).
|
||||
#[test]
|
||||
fn static_assembly_matches_runtime_capture() {
|
||||
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
|
||||
eprintln!("SYLPHEED_ISO unset — skipping");
|
||||
return;
|
||||
};
|
||||
let bytes = {
|
||||
use crate::xiso::open_iso;
|
||||
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
|
||||
r.read_file("hidden/resource3d/Stage_S01.xpr").await.unwrap()
|
||||
})
|
||||
};
|
||||
let placed = assemble_ship(&bytes, "e106", true);
|
||||
let cap = crate::ship_capture::embedded_placement("e106").expect("e106 baked");
|
||||
// Express static placements relative to bdy_04 (the capture's reference).
|
||||
let r4 = placed
|
||||
.iter()
|
||||
.find(|p| p.resource == "e106_bdy_04")
|
||||
.expect("bdy_04 placed")
|
||||
.clone();
|
||||
let rel = |p: &crate::mesh::ScenePart| -> [f32; 3] {
|
||||
[p.t[0] - r4.t[0], p.t[1] - r4.t[1], p.t[2] - r4.t[2]]
|
||||
};
|
||||
for want in &cap.parts {
|
||||
let best = placed
|
||||
.iter()
|
||||
.filter(|p| p.resource == want.part)
|
||||
.map(|p| {
|
||||
let t = rel(p);
|
||||
let d = (t[0] - want.t[0]).abs()
|
||||
+ (t[1] - want.t[1]).abs()
|
||||
+ (t[2] - want.t[2]).abs();
|
||||
(d, p)
|
||||
})
|
||||
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
|
||||
.unwrap_or_else(|| panic!("{} not placed statically", want.part));
|
||||
assert!(
|
||||
best.0 < 1.0,
|
||||
"{}: static rel-T {:?} != captured {:?} (Δ={:.2})",
|
||||
want.part,
|
||||
rel(best.1),
|
||||
want.t,
|
||||
best.0
|
||||
);
|
||||
// Rotations must match too (the engine rig is the interesting case).
|
||||
let m = &best.1.m;
|
||||
for r in 0..3 {
|
||||
for c in 0..3 {
|
||||
assert!(
|
||||
(m[r][c] - want.m[r][c]).abs() < 0.02,
|
||||
"{}: static M row{r} {:?} != captured {:?}",
|
||||
want.part,
|
||||
m[r],
|
||||
want.m[r]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Extras: the direction this test could not previously fail in. ──
|
||||
// The loop above walks the CAPTURE's parts and looks each up in ours, so
|
||||
// a static placement with no counterpart was invisible to it — which is
|
||||
// how a resource decoded 100x too large (`e303_wep_01`, 2026-08-12) sat
|
||||
// here unnoticed. Pin the set instead: the capture legitimately misses
|
||||
// repeated instances of a shared resource (vbase dedup), so `e303_wep_01`
|
||||
// is expected; anything else appearing only in the static assembly is a
|
||||
// regression.
|
||||
let captured: std::collections::BTreeSet<&str> =
|
||||
cap.parts.iter().map(|p| p.part.as_str()).collect();
|
||||
let extra: std::collections::BTreeSet<&str> = placed
|
||||
.iter()
|
||||
.map(|p| p.resource.as_str())
|
||||
.filter(|r| !captured.contains(r))
|
||||
.collect();
|
||||
let allowed: std::collections::BTreeSet<&str> = ["e303_wep_01"].into_iter().collect();
|
||||
assert_eq!(
|
||||
extra, allowed,
|
||||
"static placements with no counterpart in the runtime capture"
|
||||
);
|
||||
|
||||
// Multi-instance coverage the capture couldn't see (vbase dedup).
|
||||
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
|
||||
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
|
||||
assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
|
||||
// NEITHER hull reflects: the twins' geometry is mirrored on the disc,
|
||||
// so both placements are proper rotations. This flipped on 2026-08-12 —
|
||||
// while both twins decoded to one buffer, `apply_twin_mirrors` had to
|
||||
// synthesise the reflection here; a runtime capture showed the container
|
||||
// holds both halves, and distinct anchor assignment now hands each twin
|
||||
// its own (see docs/re/structures/xbg7-mesh.md).
|
||||
let det = |m: &[[f32; 3]; 3]| {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
|
||||
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
};
|
||||
let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
|
||||
assert!(det(&one("e106_bdy_02").m) > 0.0, "starboard hull plain (mirror is in the data)");
|
||||
assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
|
||||
}
|
||||
|
||||
// Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at
|
||||
// the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits
|
||||
// clearly aft of its forward hull body (a real ship layout, not a pile).
|
||||
#[test]
|
||||
fn assemble_spreads_parts() {
|
||||
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
|
||||
eprintln!("SYLPHEED_ISO unset — skipping");
|
||||
return;
|
||||
};
|
||||
let bytes = {
|
||||
use crate::xiso::open_iso;
|
||||
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
|
||||
r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap()
|
||||
})
|
||||
};
|
||||
let placed = assemble_ship(&bytes, "e106", true);
|
||||
assert!(placed.len() >= 5, "e106 should place its hull + external parts");
|
||||
// The forward hull body and the bridge must land at distinct fore/aft Z.
|
||||
let z = |res: &str| placed.iter().find(|p| p.resource == res).map(|p| p.t[2]);
|
||||
let bdy = z("e106_bdy_01").expect("bdy_01 placed");
|
||||
let brg = z("e106_brg_01").expect("brg_01 placed at its GN_Bridge frame");
|
||||
assert!(
|
||||
(bdy - brg).abs() > 200.0,
|
||||
"bridge ({brg}) and forward body ({bdy}) must be far apart, not stacked"
|
||||
);
|
||||
}
|
||||
}
|
||||
797
crates/sylpheed-formats/src/ship_capture.rs
Normal file
797
crates/sylpheed-formats/src/ship_capture.rs
Normal file
@@ -0,0 +1,797 @@
|
||||
//! Exact capital-ship part placement from a Canary **F10 ship-capture** log —
|
||||
//! the runtime ground truth that static [`crate::ship::assemble_ship`] only
|
||||
//! approximates for external parts.
|
||||
//!
|
||||
//! ## Why a capture is needed
|
||||
//!
|
||||
//! A captured part's vertex BUFFER holds **local** coordinates (byte-identical to
|
||||
//! the `.xpr`), so capital-ship parts are placed **entirely in the vertex shader**
|
||||
//! — the buffer carries no placement. The per-part transform lives in the ship
|
||||
//! shader's **vertex float constants**: `c0..c2` are the **WorldViewProjection**
|
||||
//! matrix rows. Each row's norm is `(sx, sy, 1)` (the projection x/y scales, with
|
||||
//! `sy/sx ≈ 1.78 = 16:9`); dividing a row by its norm yields the rigid
|
||||
//! **WorldView** row (verified orthonormal, `det = +1`) and `row[3]/norm` is that
|
||||
//! axis' view-space translation.
|
||||
//!
|
||||
//! The camera View cancels when every part is expressed relative to a **reference
|
||||
//! part**: `rel_p = WV_ref⁻¹ · WV_p = (Rᵀ_ref·R_p , Rᵀ_ref·(T_p − T_ref))` — a pure
|
||||
//! ship-space rigid transform. That is what [`correlate`] emits and what the
|
||||
//! checked-in [placement table](parse_table) stores, so the viewer can assemble a
|
||||
//! ship exactly without re-capturing.
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//!
|
||||
//! 1. Canary F10 → `xenia_ship_capture.log` (per-draw `vbase`/`vcount` + the first
|
||||
//! 48 VS float4 constants). [`parse_capture`] → [`CapturedDraw`]s.
|
||||
//! 2. [`correlate`] matches each ship base part to a draw **by vertex count**
|
||||
//! (unique per part) and expresses it in the reference part's frame →
|
||||
//! [`ShipPlacement`].
|
||||
//! 3. [`serialize_table`]/[`parse_table`] persist it as a checked-in data file
|
||||
//! (`data/ship_placements.txt`, embedded via [`embedded_placement`]); the viewer
|
||||
//! prefers it over the static assembler when present.
|
||||
|
||||
use crate::mesh::ScenePart;
|
||||
|
||||
type M3 = [[f64; 3]; 3];
|
||||
|
||||
/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space
|
||||
/// translation `t`, recovered from the `c0..c2` WVP constants.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CapturedDraw {
|
||||
/// Guest vertex-buffer base address (the draw's identity for de-duping).
|
||||
pub vbase: u32,
|
||||
/// Vertex count — the key that matches a draw to a decoded part.
|
||||
pub vcount: u32,
|
||||
/// WorldView rotation rows (orthonormal).
|
||||
pub r: M3,
|
||||
/// WorldView view-space translation.
|
||||
pub t: [f64; 3],
|
||||
/// First few LOCAL vertex positions dumped with the draw (buffer order).
|
||||
/// Used to disambiguate same-vcount twins (mirrored port/starboard parts).
|
||||
pub pos: Vec<[f32; 3]>,
|
||||
/// The draw's INDEX buffer, when the capture recorded one (`ib base=…`,
|
||||
/// added 2026-08-13): guest base address, index count, and the min/max index
|
||||
/// value the emulator read out of guest memory. `None` for older logs and
|
||||
/// for auto-index draws. This is ground truth for two things the offline
|
||||
/// decoder can only assume — where a block's index buffer lives relative to
|
||||
/// its vertex buffer, and how much of the vertex pool a draw really covers.
|
||||
pub ib: Option<CapturedIndexBuffer>,
|
||||
}
|
||||
|
||||
/// The index buffer a captured draw used. See [`CapturedDraw::ib`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CapturedIndexBuffer {
|
||||
/// Guest base address of the index data.
|
||||
pub ibase: u32,
|
||||
/// Number of indices the draw issued (== `VGT_DRAW_INITIATOR.num_indices`).
|
||||
pub icount: u32,
|
||||
/// Lowest index value in the buffer.
|
||||
pub imin: u32,
|
||||
/// Highest index value in the buffer — with `vcount` this says whether the
|
||||
/// draw covers its whole vertex pool or only a sub-range.
|
||||
pub imax: u32,
|
||||
/// The first indices, verbatim (the capture prints up to 24). Byte-level
|
||||
/// ground truth for the offline index decode: a matched block's decoded
|
||||
/// index prefix must equal this run.
|
||||
pub head: [u32; 24],
|
||||
/// How many of `head` the capture actually carried.
|
||||
pub head_len: u8,
|
||||
}
|
||||
|
||||
/// A ship part to match against the capture. `part` is the **base** part name
|
||||
/// (what goes in the placement table); `vcount`/`ref_pos` come from whichever
|
||||
/// resource variant is being tried (base or an `_m`/`_l` LOD copy — a LOD is the
|
||||
/// same part in the same local frame, so its captured transform is the part's).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PartKey {
|
||||
/// Base part name for the table, e.g. `e106_bdy_01`.
|
||||
pub part: String,
|
||||
/// The tried resource's vertex count (the draw match key).
|
||||
pub vcount: u32,
|
||||
/// The tried resource's decoded positions (any order — validation is
|
||||
/// set-based), used to validate a vcount hit and to route mirrored twins.
|
||||
/// Empty = match by vcount alone.
|
||||
pub ref_pos: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// A part's ship-relative rigid placement (in the reference part's frame).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PartPlacement {
|
||||
/// Geometry resource name, e.g. `e106_eng_01`.
|
||||
pub part: String,
|
||||
/// Rotation rows.
|
||||
pub m: [[f32; 3]; 3],
|
||||
/// Ship-relative translation.
|
||||
pub t: [f32; 3],
|
||||
}
|
||||
|
||||
/// A whole ship's captured placement: every part in a shared ship-local frame.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShipPlacement {
|
||||
/// Ship family id, e.g. `e106`.
|
||||
pub id: String,
|
||||
/// The reference part whose frame the placements are expressed in.
|
||||
pub reference: String,
|
||||
/// Ship-relative placement of each matched part.
|
||||
pub parts: Vec<PartPlacement>,
|
||||
}
|
||||
|
||||
/// Parse a Canary ship-capture log into per-draw rigid WorldView transforms.
|
||||
///
|
||||
/// Only draws that carry the `c0..c2` constants are returned (a culled/occluded
|
||||
/// part produces no draw and is simply absent). Robust to the exact spacing of
|
||||
/// the `DRAW …` / `vsconst …` lines.
|
||||
pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
|
||||
let mut out = Vec::new();
|
||||
let mut vbase = 0u32;
|
||||
let mut vcount = 0u32;
|
||||
let mut pos: Vec<[f32; 3]> = Vec::new();
|
||||
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||
let mut ib: Option<CapturedIndexBuffer> = None;
|
||||
|
||||
let flush = |vbase: u32,
|
||||
vcount: u32,
|
||||
pos: &mut Vec<[f32; 3]>,
|
||||
ib: &mut Option<CapturedIndexBuffer>,
|
||||
consts: &[(usize, [f64; 4])],
|
||||
out: &mut Vec<CapturedDraw>| {
|
||||
let pos = std::mem::take(pos);
|
||||
let ib = ib.take();
|
||||
if vbase == 0 {
|
||||
return;
|
||||
}
|
||||
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
|
||||
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else {
|
||||
return; // no WorldView for this draw — skip it
|
||||
};
|
||||
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
|
||||
out.push(CapturedDraw { vbase, vcount, r, t, pos, ib });
|
||||
}
|
||||
};
|
||||
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||||
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
|
||||
consts.clear();
|
||||
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
|
||||
vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0);
|
||||
vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
} else if let Some(rest) = l.strip_prefix("ib base=0x") {
|
||||
// `ib base=0x… count=N fmt=u16 endian=E len=L delta_vb=D min=a max=b idx: …`
|
||||
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
|
||||
let base = rest
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| u32::from_str_radix(s, 16).ok());
|
||||
if let (Some(ibase), Some(icount)) = (base, f("count=").and_then(|s| s.parse().ok())) {
|
||||
let mut head = [0u32; 24];
|
||||
let mut head_len = 0u8;
|
||||
if let Some((_, list)) = l.split_once("idx:") {
|
||||
for tok in list.split_whitespace() {
|
||||
let Ok(v) = tok.parse::<u32>() else { break };
|
||||
if head_len as usize >= head.len() {
|
||||
break;
|
||||
}
|
||||
head[head_len as usize] = v;
|
||||
head_len += 1;
|
||||
}
|
||||
}
|
||||
ib = Some(CapturedIndexBuffer {
|
||||
ibase,
|
||||
icount,
|
||||
imin: f("min=").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
imax: f("max=").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||||
head,
|
||||
head_len,
|
||||
});
|
||||
}
|
||||
} else if l.starts_with("pos:") || l.starts_with("positions:") {
|
||||
pos = parse_pos_line(l, 8);
|
||||
} else if l.starts_with("vsconst") {
|
||||
for cap in l.split('c').skip(1) {
|
||||
let Some((idx, rest)) = cap.split_once('=') else { continue };
|
||||
let Ok(i) = idx.trim().parse::<usize>() else { continue };
|
||||
let nums: Vec<f64> = rest
|
||||
.trim_start_matches('(')
|
||||
.split(')')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.filter_map(|x| x.trim().parse().ok())
|
||||
.collect();
|
||||
if nums.len() == 4 {
|
||||
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a `pos: (x,y,z) (x,y,z) …` dump line into up to `max` positions.
|
||||
fn parse_pos_line(l: &str, max: usize) -> Vec<[f32; 3]> {
|
||||
let mut out = Vec::new();
|
||||
for group in l.split('(').skip(1) {
|
||||
let Some(inner) = group.split(')').next() else { continue };
|
||||
let nums: Vec<f32> = inner.split(',').filter_map(|x| x.trim().parse().ok()).collect();
|
||||
if nums.len() == 3 {
|
||||
out.push([nums[0], nums[1], nums[2]]);
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The vertex shader capital ships (and the player fighter) are drawn with — the
|
||||
/// `c0..c2` WVP-row layout [`parse_capture`]/[`parse_drawlog`] rely on.
|
||||
pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
|
||||
|
||||
/// Parse the **draw-logger** format (`xenia_re_draws.log` / `mission_draws.log`,
|
||||
/// the `--log_draws` cvar) into per-part rigid transforms — the alternative to the
|
||||
/// F10 [`parse_capture`] snapshot. That log is what a normal instrumented run
|
||||
/// already produces, so a capital ship seen in-mission can be baked without a
|
||||
/// dedicated F10 capture.
|
||||
///
|
||||
/// A capital ship's parts each own a **distinct vertex buffer**, so we group by
|
||||
/// the `stream … base=…` address, take its vertex count as `size_words /
|
||||
/// stride_words`, and keep the first `c0..c2` WVP seen for that buffer. Only draws
|
||||
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
|
||||
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
|
||||
/// collapses to a single entry here — fine, capital ships are the target.)
|
||||
/// Split a capture into blocks that are guaranteed to share one camera.
|
||||
///
|
||||
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
|
||||
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
|
||||
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
|
||||
/// camera **only when both draws come from the same frame** — mix frames and
|
||||
/// the residual is the camera's motion between them. With a static ship and a
|
||||
/// static camera that error is invisible, which is how the single validated
|
||||
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
|
||||
/// units, and two frames of the same ship then disagree about where its parts
|
||||
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
|
||||
/// `[0, 0, -1090]`).
|
||||
///
|
||||
/// The split rule is the recurrence itself: a vertex buffer that appears again
|
||||
/// starts a new block. Splitting too eagerly is harmless (a block is still one
|
||||
/// camera, just with fewer parts in it) and it separates two instances of the
|
||||
/// same class as a bonus; failing to split is what corrupts the result.
|
||||
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
|
||||
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
|
||||
let mut cur: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for d in draws {
|
||||
if !seen.insert(d.vbase) {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
seen.clear();
|
||||
seen.insert(d.vbase);
|
||||
}
|
||||
cur.push(d.clone());
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut is_ship = false;
|
||||
let mut base = 0u32;
|
||||
let mut stride = 0u32;
|
||||
let mut size = 0u32;
|
||||
let mut pos: Vec<[f32; 3]> = Vec::new();
|
||||
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||
|
||||
let mut flush = |base: u32,
|
||||
size: u32,
|
||||
stride: u32,
|
||||
pos: &mut Vec<[f32; 3]>,
|
||||
consts: &[(usize, [f64; 4])],
|
||||
seen: &mut std::collections::HashSet<u32>,
|
||||
out: &mut Vec<CapturedDraw>| {
|
||||
let pos = std::mem::take(pos);
|
||||
if base == 0 || stride == 0 || !seen.insert(base) {
|
||||
return;
|
||||
}
|
||||
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
|
||||
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return };
|
||||
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
|
||||
// The draw-logger format carries an index base too, but it de-dups
|
||||
// by vertex declaration, so it never lines up per part — left None.
|
||||
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos, ib: None });
|
||||
}
|
||||
};
|
||||
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||||
flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
|
||||
consts.clear();
|
||||
base = 0;
|
||||
stride = 0;
|
||||
size = 0;
|
||||
is_ship = rest.contains(&format!("vs={SHIP_VS_HASH}"));
|
||||
} else if is_ship && l.starts_with("positions:") {
|
||||
pos = parse_pos_line(l, 8);
|
||||
} else if is_ship && l.starts_with("stream ") {
|
||||
let f = |k: &str| l.split_whitespace().find_map(|t| t.strip_prefix(k));
|
||||
if let Some(b) = f("base=0x").and_then(|s| u32::from_str_radix(s, 16).ok()) {
|
||||
base = b;
|
||||
}
|
||||
stride = f("stride_words=").and_then(|s| s.parse().ok()).unwrap_or(stride);
|
||||
size = f("size_words=").and_then(|s| s.parse().ok()).unwrap_or(size);
|
||||
} else if is_ship && l.starts_with('c') {
|
||||
// `c<idx> x y z w` — the vsconst rows (space-separated).
|
||||
let mut it = l.splitn(2, char::is_whitespace);
|
||||
let Some(tag) = it.next() else { continue };
|
||||
let Ok(i) = tag[1..].parse::<usize>() else { continue };
|
||||
let nums: Vec<f64> = it.next().unwrap_or("").split_whitespace().filter_map(|x| x.parse().ok()).collect();
|
||||
if nums.len() >= 4 {
|
||||
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Normalize the three `c0..c2` WVP rows to a rigid WorldView `(R rows, T)` by
|
||||
/// dividing each row by its (projection-scale) norm. `None` if any row is
|
||||
/// degenerate.
|
||||
fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> {
|
||||
let mut r = [[0.0; 3]; 3];
|
||||
let mut t = [0.0; 3];
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt();
|
||||
if n < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
r[i] = [row[0] / n, row[1] / n, row[2] / n];
|
||||
t[i] = row[3] / n;
|
||||
}
|
||||
Some((r, t))
|
||||
}
|
||||
|
||||
/// Validate a vcount hit by the draw's dumped positions against the part's
|
||||
/// decoded position SET (order-independent — decode order can differ from buffer
|
||||
/// order). Returns `Some((hits, mirrored))` when at least half the dumped
|
||||
/// positions are found among the part's vertices, either directly or **all
|
||||
/// X-negated** — the engine uploads the second of a mirrored port/starboard pair
|
||||
/// as an X-reflection of the shared file geometry, so the guest buffer disagrees
|
||||
/// in X sign with every decoded copy. `None` = the dump belongs to a different
|
||||
/// model (a coincidental vcount).
|
||||
fn pos_validate(draw: &CapturedDraw, ref_pos: &[[f32; 3]]) -> Option<(usize, bool)> {
|
||||
if draw.pos.is_empty() || ref_pos.is_empty() {
|
||||
return Some((0, false)); // no data to validate with — accept neutrally
|
||||
}
|
||||
let near = |a: &[f32; 3], b: &[f32; 3]| {
|
||||
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
|
||||
};
|
||||
let mut direct = 0usize;
|
||||
let mut mirror = 0usize;
|
||||
for p in &draw.pos {
|
||||
if ref_pos.iter().any(|v| near(p, v)) {
|
||||
direct += 1;
|
||||
}
|
||||
let pm = [-p[0], p[1], p[2]];
|
||||
if ref_pos.iter().any(|v| near(&pm, v)) {
|
||||
mirror += 1;
|
||||
}
|
||||
}
|
||||
let need = draw.pos.len().div_ceil(2);
|
||||
if direct >= need && direct >= mirror {
|
||||
Some((direct, false))
|
||||
} else if mirror >= need {
|
||||
Some((mirror, true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Correlate captured draws to a ship's parts and express each in the reference
|
||||
/// part's frame.
|
||||
///
|
||||
/// Each [`PartKey`]'s `vcount` is the match key; when several draws share the
|
||||
/// vcount (mirrored port/starboard twins) the draw whose dumped positions match
|
||||
/// the part's `ref_pos` validates best is chosen. `ref_sub` selects the reference part by
|
||||
/// substring (e.g. `bdy_04`); the first matched part is used if none contains it.
|
||||
/// Parts with no matching captured draw (culled at that camera angle) are
|
||||
/// omitted. Returns `None` if nothing matched.
|
||||
pub fn correlate(
|
||||
id: &str,
|
||||
draws: &[CapturedDraw],
|
||||
parts: &[PartKey],
|
||||
ref_sub: &str,
|
||||
) -> Option<ShipPlacement> {
|
||||
let mut matched: Vec<(String, M3, [f64; 3], bool)> = Vec::new();
|
||||
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for key in parts {
|
||||
// Several PartKeys may carry the same part (one per LOD-variant vcount);
|
||||
// the first that validates wins, the rest are skipped.
|
||||
if matched.iter().any(|(p, ..)| p == &key.part) {
|
||||
continue;
|
||||
}
|
||||
// A vcount hit alone can be a coincidence (small LODs share counts across
|
||||
// unrelated models — a 51-vert draw once matched the bridge but was a
|
||||
// different mesh). Candidates failing position validation are REJECTED;
|
||||
// among validated candidates the best hit count wins (routes twins), and
|
||||
// a mirror-validated match records the X-reflection.
|
||||
let mut best: Option<(&CapturedDraw, usize, bool)> = None;
|
||||
for d in draws.iter().filter(|d| d.vcount == key.vcount && !used.contains(&d.vbase)) {
|
||||
let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else {
|
||||
continue; // positions disagree — not this part
|
||||
};
|
||||
if best.map_or(true, |(_, s, _)| score > s) {
|
||||
best = Some((d, score, mirrored));
|
||||
}
|
||||
}
|
||||
if let Some((d, _, mirrored)) = best {
|
||||
used.insert(d.vbase);
|
||||
matched.push((key.part.clone(), d.r, d.t, mirrored));
|
||||
}
|
||||
}
|
||||
let ref_idx = matched.iter().position(|(p, ..)| p.contains(ref_sub)).unwrap_or(0);
|
||||
let (ref_part, ref_r, ref_t, _) = matched.get(ref_idx)?.clone();
|
||||
let rt_ref = transpose(&ref_r);
|
||||
|
||||
let parts_out = matched
|
||||
.iter()
|
||||
.map(|(part, r, t, mirrored)| {
|
||||
let mut rel_r = mmul(&rt_ref, r);
|
||||
let dt = [t[0] - ref_t[0], t[1] - ref_t[1], t[2] - ref_t[2]];
|
||||
let rel_t = mat_vec(&rt_ref, dt);
|
||||
// The captured WorldView transforms the *uploaded* buffer; for the
|
||||
// mirrored twin that buffer is the X-reflection of the file geometry,
|
||||
// so the file-local placement is R·diag(−1,1,1) — negate column 0.
|
||||
if *mirrored {
|
||||
for row in &mut rel_r {
|
||||
row[0] = -row[0];
|
||||
}
|
||||
}
|
||||
PartPlacement {
|
||||
part: part.clone(),
|
||||
m: snap_m3(&rel_r),
|
||||
t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out })
|
||||
}
|
||||
|
||||
/// Snap near-axis rotation entries (float noise from the WV products) to exact
|
||||
/// 0/±1 so the checked-in table is clean; real rotations are untouched.
|
||||
fn snap_m3(m: &M3) -> [[f32; 3]; 3] {
|
||||
let snap = |v: f64| -> f32 {
|
||||
if v.abs() < 5e-4 {
|
||||
0.0
|
||||
} else if (v - 1.0).abs() < 5e-4 {
|
||||
1.0
|
||||
} else if (v + 1.0).abs() < 5e-4 {
|
||||
-1.0
|
||||
} else {
|
||||
v as f32
|
||||
}
|
||||
};
|
||||
[
|
||||
[snap(m[0][0]), snap(m[0][1]), snap(m[0][2])],
|
||||
[snap(m[1][0]), snap(m[1][1]), snap(m[1][2])],
|
||||
[snap(m[2][0]), snap(m[2][1]), snap(m[2][2])],
|
||||
]
|
||||
}
|
||||
|
||||
/// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale).
|
||||
pub fn to_scene_parts(ship: &ShipPlacement) -> Vec<ScenePart> {
|
||||
ship.parts
|
||||
.iter()
|
||||
.map(|p| ScenePart { resource: p.part.clone(), m: p.m, t: p.t, s: [1.0, 1.0, 1.0] })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The checked-in placement table, embedded at build time. Empty until captures
|
||||
/// are baked in with `correlate_capture --emit`.
|
||||
const EMBEDDED_TABLE: &str = include_str!("../data/ship_placements.txt");
|
||||
|
||||
/// The captured placement for ship `id` from the embedded table, if baked in.
|
||||
pub fn embedded_placement(id: &str) -> Option<ShipPlacement> {
|
||||
parse_table(EMBEDDED_TABLE).into_iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// Serialize a placement table to the checked-in text format (see [`parse_table`]).
|
||||
pub fn serialize_table(ships: &[ShipPlacement]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("# Capital-ship part placements — runtime-captured ground truth.\n");
|
||||
s.push_str("# Generated by: cargo run --release --example correlate_capture -- \\\n");
|
||||
s.push_str("# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit\n");
|
||||
s.push_str("# Per line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>\n");
|
||||
for ship in ships {
|
||||
s.push_str(&format!("\nship {} ref={}\n", ship.id, ship.reference));
|
||||
for p in &ship.parts {
|
||||
s.push_str(&format!(
|
||||
" {} {} {} {} {} {} {} {} {} {} {} {} {}\n",
|
||||
p.part,
|
||||
p.m[0][0], p.m[0][1], p.m[0][2],
|
||||
p.m[1][0], p.m[1][1], p.m[1][2],
|
||||
p.m[2][0], p.m[2][1], p.m[2][2],
|
||||
p.t[0], p.t[1], p.t[2],
|
||||
));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse the checked-in placement table. `#` comments and blank lines are ignored;
|
||||
/// a `ship <id> ref=<part>` line starts a block, and each following
|
||||
/// `<part> <9 rotation floats> <3 translation floats>` line is one placement.
|
||||
pub fn parse_table(text: &str) -> Vec<ShipPlacement> {
|
||||
let mut ships: Vec<ShipPlacement> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if l.is_empty() || l.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = l.strip_prefix("ship ") {
|
||||
let mut it = rest.split_whitespace();
|
||||
let id = it.next().unwrap_or("").to_string();
|
||||
let reference = it
|
||||
.next()
|
||||
.and_then(|s| s.strip_prefix("ref="))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
ships.push(ShipPlacement { id, reference, parts: Vec::new() });
|
||||
} else if let Some(ship) = ships.last_mut() {
|
||||
let mut it = l.split_whitespace();
|
||||
let part = it.next().unwrap_or("").to_string();
|
||||
let nums: Vec<f32> = it.filter_map(|x| x.parse().ok()).collect();
|
||||
if part.is_empty() || nums.len() != 12 {
|
||||
continue;
|
||||
}
|
||||
ship.parts.push(PartPlacement {
|
||||
part,
|
||||
m: [
|
||||
[nums[0], nums[1], nums[2]],
|
||||
[nums[3], nums[4], nums[5]],
|
||||
[nums[6], nums[7], nums[8]],
|
||||
],
|
||||
t: [nums[9], nums[10], nums[11]],
|
||||
});
|
||||
}
|
||||
}
|
||||
ships
|
||||
}
|
||||
|
||||
fn transpose(m: &M3) -> M3 {
|
||||
[
|
||||
[m[0][0], m[1][0], m[2][0]],
|
||||
[m[0][1], m[1][1], m[2][1]],
|
||||
[m[0][2], m[1][2], m[2][2]],
|
||||
]
|
||||
}
|
||||
fn mmul(a: &M3, b: &M3) -> M3 {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
fn mat_vec(m: &M3, v: [f64; 3]) -> [f64; 3] {
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A `vsconst` line for an already-unit WorldView (norm 1) so R = rows and
|
||||
/// T = row[3]: identity rotation, translation `t`.
|
||||
fn draw_line(vbase: u32, vcount: u32, t: [f64; 3]) -> String {
|
||||
format!(
|
||||
"DRAW vbase=0x{vbase:X} stride=32 vcount={vcount} indices=0 prim=tri vs=0x1\n \
|
||||
vsconst base=0: c0=(1,0,0,{}) c1=(0,1,0,{}) c2=(0,0,1,{})\n",
|
||||
t[0], t[1], t[2]
|
||||
)
|
||||
}
|
||||
|
||||
fn key(part: &str, vcount: u32) -> PartKey {
|
||||
PartKey { part: part.to_string(), vcount, ref_pos: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_recovers_worldview() {
|
||||
let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]);
|
||||
let draws = parse_capture(&log);
|
||||
assert_eq!(draws.len(), 1);
|
||||
assert_eq!(draws[0].vcount, 3);
|
||||
assert_eq!(draws[0].t, [10.0, 0.0, 0.0]);
|
||||
assert_eq!(draws[0].r, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_normalizes_projection_scale() {
|
||||
// A row scaled by the projection sx=2 must normalize back to unit, and its
|
||||
// translation divides by the same norm.
|
||||
let log = "DRAW vbase=0x2000 vcount=4\n \
|
||||
vsconst base=0: c0=(2,0,0,20) c1=(0,2,0,0) c2=(0,0,1,0)\n";
|
||||
let d = &parse_capture(log)[0];
|
||||
assert!((d.r[0][0] - 1.0).abs() < 1e-9);
|
||||
assert!((d.t[0] - 10.0).abs() < 1e-9, "20/2 = 10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlate_expresses_parts_in_reference_frame() {
|
||||
// Two parts, identity rotation: ref at view (10,0,0), other at (10,0,50).
|
||||
// Relative to ref, the other must sit at (0,0,50).
|
||||
let log = format!(
|
||||
"{}{}",
|
||||
draw_line(0x1000, 3, [10.0, 0.0, 0.0]),
|
||||
draw_line(0x2000, 4, [10.0, 0.0, 50.0])
|
||||
);
|
||||
let draws = parse_capture(&log);
|
||||
let parts = vec![key("e106_bdy_04", 3), key("e106_eng_01", 4)];
|
||||
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||
assert_eq!(ship.reference, "e106_bdy_04");
|
||||
let refp = ship.parts.iter().find(|p| p.part == "e106_bdy_04").unwrap();
|
||||
assert_eq!(refp.t, [0.0, 0.0, 0.0]);
|
||||
let eng = ship.parts.iter().find(|p| p.part == "e106_eng_01").unwrap();
|
||||
assert_eq!(eng.t, [0.0, 0.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawlog_format_parses_capital_ship_parts() {
|
||||
// Two capital-ship parts, each its own vertex buffer (distinct base),
|
||||
// stride 6 → vcount = size_words/6. Identity WVP with known translations.
|
||||
let log = format!(
|
||||
"DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
|
||||
stream fc=0 base=0x12A60228 stride_words=6 size_words=9798 endian=2 type=3\n \
|
||||
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n\
|
||||
DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
|
||||
stream fc=0 base=0x12ABF2CC stride_words=6 size_words=4890 endian=2 type=3\n \
|
||||
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 50.0\n\
|
||||
DRAW prim=4 indices=3 src=0 index[...] vs=0xDEADBEEF00000000\n \
|
||||
stream fc=0 base=0x99990000 stride_words=6 size_words=18 endian=2 type=3\n \
|
||||
vsconst:\n c0 1.0 0.0 0.0 0.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n"
|
||||
);
|
||||
let draws = parse_drawlog(&log);
|
||||
// Two ship-shader buffers; the non-ship shader draw is ignored.
|
||||
assert_eq!(draws.len(), 2);
|
||||
assert_eq!(draws[0].vcount, 1633); // 9798/6
|
||||
assert_eq!(draws[1].vcount, 815); // 4890/6
|
||||
// Correlate: part B sits 50 along Z from reference part A.
|
||||
let parts = vec![key("e106_bdy_04", 1633), key("e106_bdy_03", 815)];
|
||||
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||
let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap();
|
||||
assert_eq!(b.t, [0.0, 0.0, 50.0]);
|
||||
}
|
||||
|
||||
/// The real e106 bdy_01/bdy_02 case: both twin resources decode to
|
||||
/// IDENTICAL file geometry; the engine uploads the second instance as an
|
||||
/// X-reflection, so one captured buffer disagrees in X sign with the file.
|
||||
/// Both draws must be placed, and the mirrored one must bake the X-flip
|
||||
/// (negated first matrix column) so file-local geometry lands port-side.
|
||||
#[test]
|
||||
fn runtime_mirrored_twin_placed_with_reflection() {
|
||||
let log = "DRAW vbase=0x1000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \
|
||||
pos: (134.4215,133.8319,-118.1757) (178.8384,85.3847,238.0463)\n \
|
||||
vsconst base=0: c0=(1,0,0,264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
|
||||
DRAW vbase=0x2000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \
|
||||
pos: (-134.4215,133.8319,-118.1757) (-178.8384,85.3847,238.0463)\n \
|
||||
vsconst base=0: c0=(1,0,0,-264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
|
||||
DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \
|
||||
vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
|
||||
let draws = parse_capture(log);
|
||||
assert_eq!(draws.len(), 3);
|
||||
assert_eq!(draws[0].pos.len(), 2);
|
||||
// Both twins carry the SAME (file) positions — +X side geometry.
|
||||
let file_pos = vec![[134.4215, 133.8319, -118.1757], [178.8384, 85.3847, 238.0463]];
|
||||
let parts = vec![
|
||||
PartKey { part: "e106_bdy_01".to_string(), vcount: 426, ref_pos: file_pos.clone() },
|
||||
PartKey { part: "e106_bdy_02".to_string(), vcount: 426, ref_pos: file_pos },
|
||||
key("e106_bdy_04", 558),
|
||||
];
|
||||
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||
assert_eq!(ship.parts.len(), 3, "both twins + reference placed");
|
||||
let get = |p: &str| ship.parts.iter().find(|x| x.part == p).unwrap().clone();
|
||||
// bdy_01 validated directly → the +264 draw, identity rotation.
|
||||
let a = get("e106_bdy_01");
|
||||
assert_eq!(a.t, [264.0, 0.0, 0.0]);
|
||||
assert_eq!(a.m[0][0], 1.0);
|
||||
// bdy_02 validated as the MIRROR → the −264 draw, X-flip baked in.
|
||||
let b = get("e106_bdy_02");
|
||||
assert_eq!(b.t, [-264.0, 0.0, 0.0]);
|
||||
assert_eq!(b.m[0][0], -1.0, "mirrored twin must negate the X column");
|
||||
assert_eq!(b.m[1][1], 1.0);
|
||||
}
|
||||
|
||||
/// A draw whose vcount matches but whose position dump disagrees must be
|
||||
/// REJECTED, not placed — the real false-positive: a foreign 51-vert model
|
||||
/// matched `e106_brg_01_l` by count alone and put the bridge 2 km off-hull.
|
||||
#[test]
|
||||
fn vcount_coincidence_rejected_by_positions() {
|
||||
let log = "DRAW vbase=0x1000 stride=24 vcount=51 indices=36 prim=4 vs=0x1\n \
|
||||
pos: (-0.0000,46.2359,-12.9454) (-0.0000,-6.1936,264.8687)\n \
|
||||
vsconst base=0: c0=(1,0,0,1975) c1=(0,1,0,0) c2=(0,0,1,0)\n\
|
||||
DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \
|
||||
vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
|
||||
let draws = parse_capture(log);
|
||||
let parts = vec![
|
||||
PartKey {
|
||||
part: "e106_brg_01".to_string(),
|
||||
vcount: 51,
|
||||
// The REAL bridge LOD's vertices — disagree with the dump.
|
||||
ref_pos: vec![[35.2480, 26.0376, 49.2504], [6.0, 55.9632, 41.1370]],
|
||||
},
|
||||
key("e106_bdy_04", 558),
|
||||
];
|
||||
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||
assert!(
|
||||
!ship.parts.iter().any(|p| p.part == "e106_brg_01"),
|
||||
"coincidental vcount match must not place the bridge"
|
||||
);
|
||||
assert_eq!(ship.parts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_round_trips() {
|
||||
let ship = ShipPlacement {
|
||||
id: "e106".to_string(),
|
||||
reference: "e106_bdy_04".to_string(),
|
||||
parts: vec![
|
||||
PartPlacement {
|
||||
part: "e106_bdy_04".to_string(),
|
||||
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
t: [0.0, 0.0, 0.0],
|
||||
},
|
||||
PartPlacement {
|
||||
part: "e106_eng_01".to_string(),
|
||||
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
t: [131.0, -133.0, -132.0],
|
||||
},
|
||||
],
|
||||
};
|
||||
let text = serialize_table(std::slice::from_ref(&ship));
|
||||
let back = parse_table(&text);
|
||||
assert_eq!(back, vec![ship]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_table_parses() {
|
||||
// The checked-in data file must always parse (even if empty).
|
||||
let _ = parse_table(EMBEDDED_TABLE);
|
||||
}
|
||||
|
||||
/// The baked e106 capture (2026-07-26 F10, Stage_S01): all 8 parts placed,
|
||||
/// port/starboard pair symmetric with the mirror on bdy_02, bridge on the
|
||||
/// centreline. Guards the checked-in data against accidental edits.
|
||||
#[test]
|
||||
fn embedded_e106_is_complete() {
|
||||
let e106 = embedded_placement("e106").expect("e106 baked in");
|
||||
assert_eq!(e106.reference, "e106_bdy_04");
|
||||
assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed");
|
||||
let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap();
|
||||
// Port/starboard hull pair: X = ∓264, **both plain**. The mirror is
|
||||
// baked into the disc data, not into the placement: a runtime capture
|
||||
// shows the container carrying two 119-vertex buffers whose contents are
|
||||
// exact X-reflections, each drawn from its own address (see
|
||||
// docs/re/structures/xbg7-mesh.md). Until 2026-08-12 both twins decoded
|
||||
// to ONE buffer and this row carried diag(-1,1,1) to compensate; with
|
||||
// distinct anchor assignment they decode to their own, and re-emitting
|
||||
// from the capture produces identity here.
|
||||
assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1);
|
||||
assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1);
|
||||
assert_eq!(get("e106_bdy_02").m[0][0], 1.0);
|
||||
assert_eq!(get("e106_bdy_01").m[0][0], 1.0);
|
||||
// Bridge: centreline, above and aft of the hull reference.
|
||||
let brg = get("e106_brg_01");
|
||||
assert!(brg.t[0].abs() < 0.1 && brg.t[1] > 150.0 && brg.t[2] < -160.0);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,12 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
|
||||
}
|
||||
if i - start >= 6 {
|
||||
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
|
||||
if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") {
|
||||
// Every spoken-line category, so the standalone player covers them
|
||||
// all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices
|
||||
// (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in
|
||||
// `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name).
|
||||
let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\");
|
||||
if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice {
|
||||
if seen.insert(s.to_string()) {
|
||||
out.push(parse_voice_clip(s));
|
||||
}
|
||||
@@ -113,8 +118,58 @@ fn parse_voice_clip(name: &str) -> VoiceClip {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in
|
||||
/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves
|
||||
/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`,
|
||||
/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be
|
||||
/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller
|
||||
/// decodes each to PCM, concatenates in order, and clamps to the movie length —
|
||||
/// that yields the full track for segment banks while the clamp drops the
|
||||
/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O
|
||||
/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.)
|
||||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
if find(slb, b"RIFF", 0).is_none() {
|
||||
// Headerless single-stream bank.
|
||||
if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) {
|
||||
if !data.is_empty() {
|
||||
out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut pos = 0usize;
|
||||
while let Some(ri) = find(slb, b"RIFF", pos) {
|
||||
// Parse this sub-wave's fmt + data (declared size is honest per sub-wave).
|
||||
let Some(fi) = find(slb, b"fmt ", ri) else { break };
|
||||
let Some(fsz) = le32(slb, fi + 4) else { break };
|
||||
let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else {
|
||||
break;
|
||||
};
|
||||
if fmt_end > slb.len() {
|
||||
break;
|
||||
}
|
||||
let Some(di) = find(slb, b"data", fi) else { break };
|
||||
let Some(dsz) = le32(slb, di + 4) else { break };
|
||||
let Some(ds) = di.checked_add(8) else { break };
|
||||
let de = ds
|
||||
.checked_add(dsz as usize)
|
||||
.unwrap_or(slb.len())
|
||||
.min(slb.len());
|
||||
if let Some(data) = slb.get(ds..de) {
|
||||
if !data.is_empty() {
|
||||
out.push(build_riff(&slb[fi..fmt_end], data));
|
||||
}
|
||||
}
|
||||
// Advance past this sub-wave's data to find the next RIFF.
|
||||
pos = de.max(ri + 4);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's
|
||||
/// `xma1`. Returns `None` if the bank is too small / malformed.
|
||||
/// `xma1`. Returns `None` if the bank is too small / malformed. This is the
|
||||
/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks.
|
||||
pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(ri) = find(slb, b"RIFF", 0) {
|
||||
// RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each
|
||||
@@ -145,6 +200,38 @@ pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a standalone XMA1 `RIFF/WAVE` from a single-stream `.slb`, robust to
|
||||
/// the layout variants seen in `<lang>\etc\` radio clips. Unlike [`to_xma_riff`]
|
||||
/// (which assumes `RIFF → fmt → data` in order), this picks the **largest `data`
|
||||
/// chunk anywhere** in the bank — some radio banks store the audio *before* the
|
||||
/// trailing `RIFF`/`fmt` metadata (an empty post-`RIFF` `data` chunk), which the
|
||||
/// ordered scan misses. Pairs it with the first `fmt ` chunk; falls back to the
|
||||
/// headerless layout. Returns `None` only when no usable audio can be found.
|
||||
pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
// Largest usable `data` chunk (bounded by its declared size and the buffer).
|
||||
let mut best: Option<(usize, usize)> = None; // (data offset, usable payload len)
|
||||
let mut i = 0;
|
||||
while let Some(di) = find(slb, b"data", i) {
|
||||
let declared = le32(slb, di + 4).unwrap_or(0) as usize;
|
||||
let usable = declared.min(slb.len().saturating_sub(di + 8));
|
||||
if best.map_or(true, |(_, b)| usable > b) {
|
||||
best = Some((di, usable));
|
||||
}
|
||||
i = di + 4;
|
||||
}
|
||||
if let (Some(fi), Some((di, sz))) = (find(slb, b"fmt ", 0), best) {
|
||||
if sz > 512 {
|
||||
let fsz = le32(slb, fi + 4)? as usize;
|
||||
let fmt_end = (fi + 8 + fsz).min(slb.len());
|
||||
let data = slb.get(di + 8..di + 8 + sz)?;
|
||||
return Some(build_riff(slb.get(fi..fmt_end)?, data));
|
||||
}
|
||||
}
|
||||
// Headerless fallback: fixed data offset, synthesized XMA1 stereo/48k fmt.
|
||||
let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
|
||||
(!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data))
|
||||
}
|
||||
|
||||
/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
|
||||
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<u8> {
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
@@ -235,6 +322,40 @@ mod tests {
|
||||
assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_voice_clips_covers_movie_radio_and_briefing() {
|
||||
// The standalone player must enumerate every spoken-line category: bound
|
||||
// movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\,
|
||||
// whose BR<NN>_<MM> names lack "VOICE"). Music (BGM_*) must stay excluded.
|
||||
let mut tbl = Vec::new();
|
||||
for s in [
|
||||
"eng\\Movie\\VOICE_S13A.slb",
|
||||
"eng\\etc\\VOICE_D_450.slb",
|
||||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||||
"eng\\Briefing\\BR01_01.slb",
|
||||
"eng\\bgm\\BGM_001.slb",
|
||||
] {
|
||||
tbl.extend_from_slice(s.as_bytes());
|
||||
tbl.push(0);
|
||||
}
|
||||
let names: Vec<String> = list_voice_clips(&tbl, VoiceLang::English)
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect();
|
||||
for want in [
|
||||
"eng\\Movie\\VOICE_S13A.slb",
|
||||
"eng\\etc\\VOICE_D_450.slb",
|
||||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||||
"eng\\Briefing\\BR01_01.slb",
|
||||
] {
|
||||
assert!(names.iter().any(|n| n == want), "missing {want}");
|
||||
}
|
||||
assert!(
|
||||
!names.iter().any(|n| n.contains("BGM_")),
|
||||
"music must not be listed as a voice clip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilds_riff_from_headerless() {
|
||||
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
//! `T8aD` — the game's 2D UI/HUD texture format.
|
||||
//!
|
||||
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as **256×256 raster
|
||||
//! tiles in row-major order** — each tile prefixed by a 16-byte tile header, edge
|
||||
//! tiles clipped to the image bounds. Fully reversed 2026-07-17 from the file
|
||||
//! header and verified against the running game (title screen).
|
||||
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as a list of
|
||||
//! **arbitrary sub-rectangles**, each with its own destination origin and size.
|
||||
//! Reversed 2026-07-17 (verified against the running game's title screen) and
|
||||
//! **corrected 2026-08-11**, when the per-tile header turned out to carry the
|
||||
//! rectangle's placement rather than being opaque flags.
|
||||
//!
|
||||
//! ```text
|
||||
//! 0x00 4 Magic "T8aD"
|
||||
//! 0x14 4 width (BE u32)
|
||||
//! 0x14 4 width (BE u32) the full surface
|
||||
//! 0x18 4 height (BE u32)
|
||||
//! 0x1c 4 tile count (BE u32) = ceil(w/256) * ceil(h/256)
|
||||
//! 0x2c tiles*4 offset table: absolute byte offset of each row-major tile
|
||||
//! <off> 16 per-tile header (flags + tile w/h), then:
|
||||
//! <off+16> tile_w * tile_h * 4 bytes of A8R8G8B8 pixels, row-major
|
||||
//! 0x1c 4 rectangle count (BE u32) — NOT ceil(w/256)*ceil(h/256)
|
||||
//! 0x2c count*4 offset table: absolute byte offset of each rectangle
|
||||
//! <off> 16 rectangle header, four BE u32: dst X, dst Y, width, height
|
||||
//! <off+16> width * height * 4 bytes of A8R8G8B8 pixels, row-major
|
||||
//! ```
|
||||
//!
|
||||
//! Most surfaces happen to be stored as full-width 256-tall bands, which is why
|
||||
//! treating the file as a 256×256 grid decoded 96 % of the disc correctly. It is
|
||||
//! not the model, though: a dialogue strip declares 524×63 and stores **one**
|
||||
//! 173×25 rectangle at (175,20), and `pdmes010` stores two — (59,6,256,54) and
|
||||
//! (315,6,149,54), the second beginning exactly `16 + 256*54*4` bytes after the
|
||||
//! first. Anything the rectangles do not cover stays transparent.
|
||||
//!
|
||||
//! Surfaces ≤256px wide are a single tile column, so the first tile's pixels sit
|
||||
//! at `44 + tiles*4 + 16 = 64` — which is why the old "type→header size 64/84/…"
|
||||
//! rule (header = 44 + tiles*20) happened to decode small textures correctly: for
|
||||
@@ -43,18 +51,11 @@ fn be32(b: &[u8], off: usize) -> u32 {
|
||||
}
|
||||
|
||||
/// Side of the square storage tile, in texels, and the per-tile header size.
|
||||
const TILE: usize = 256;
|
||||
const TILE_HDR: usize = 16;
|
||||
/// Bytes of per-rectangle header before its pixels: dst X, dst Y, w, h.
|
||||
const RECT_HDR: usize = 16;
|
||||
|
||||
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
|
||||
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses).
|
||||
///
|
||||
/// Layout (reversed from the header + verified against the running game):
|
||||
/// a 44-byte base header, then a `tiles`-entry big-endian u32 **offset table** at
|
||||
/// `0x2c`, where `tiles` = the field at `0x1c` = `ceil(w/256) * ceil(h/256)`.
|
||||
/// Each entry is the absolute byte offset of a **row-major** 256×256 tile; every
|
||||
/// tile is a 16-byte tile header followed by `tile_w*tile_h*4` A8R8G8B8 pixels,
|
||||
/// edge tiles clipped to the image bounds.
|
||||
/// `None` if any rectangle fails to fit the surface or the file — never guesses.
|
||||
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
if !is_t8ad(bytes) || bytes.len() < 0x40 {
|
||||
return None;
|
||||
@@ -64,41 +65,45 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
|
||||
return None;
|
||||
}
|
||||
let tiles = be32(bytes, 0x1c) as usize;
|
||||
let cols = width.div_ceil(TILE);
|
||||
let rows = height.div_ceil(TILE);
|
||||
// The field at 0x1c must be the tile count; otherwise it's a variant we don't
|
||||
// decode (e.g. DXT / palettized) — defer rather than misdecode.
|
||||
if tiles == 0 || tiles != cols * rows {
|
||||
let rects = be32(bytes, 0x1c) as usize;
|
||||
if rects == 0 || rects > 4096 {
|
||||
return None;
|
||||
}
|
||||
const TABLE: usize = 0x2c;
|
||||
if bytes.len() < TABLE + tiles * 4 {
|
||||
if bytes.len() < TABLE + rects * 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Anything no rectangle covers stays transparent.
|
||||
let mut rgba = vec![0u8; width * height * 4];
|
||||
for ty in 0..rows {
|
||||
for tx in 0..cols {
|
||||
let tile = ty * cols + tx;
|
||||
let pixels = be32(bytes, TABLE + tile * 4) as usize + TILE_HDR;
|
||||
let tw = TILE.min(width - tx * TILE);
|
||||
let th = TILE.min(height - ty * TILE);
|
||||
if pixels + tw * th * 4 > bytes.len() {
|
||||
return None; // truncated / not the layout we expect
|
||||
}
|
||||
for row in 0..th {
|
||||
let mut s = pixels + row * tw * 4;
|
||||
let mut d = ((ty * TILE + row) * width + tx * TILE) * 4;
|
||||
for _ in 0..tw {
|
||||
// A8R8G8B8 → RGBA8.
|
||||
rgba[d] = bytes[s + 1];
|
||||
rgba[d + 1] = bytes[s + 2];
|
||||
rgba[d + 2] = bytes[s + 3];
|
||||
rgba[d + 3] = bytes[s];
|
||||
s += 4;
|
||||
d += 4;
|
||||
}
|
||||
for r in 0..rects {
|
||||
let at = be32(bytes, TABLE + r * 4) as usize;
|
||||
if at + RECT_HDR > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let dx = be32(bytes, at) as usize;
|
||||
let dy = be32(bytes, at + 4) as usize;
|
||||
let rw = be32(bytes, at + 8) as usize;
|
||||
let rh = be32(bytes, at + 12) as usize;
|
||||
// Never guess: a rectangle must fit the surface and its pixels the file.
|
||||
if rw == 0 || rh == 0 || dx + rw > width || dy + rh > height {
|
||||
return None;
|
||||
}
|
||||
let pixels = at + RECT_HDR;
|
||||
if pixels + rw * rh * 4 > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
for row in 0..rh {
|
||||
let mut s = pixels + row * rw * 4;
|
||||
let mut d = ((dy + row) * width + dx) * 4;
|
||||
for _ in 0..rw {
|
||||
// A8R8G8B8 → RGBA8.
|
||||
rgba[d] = bytes[s + 1];
|
||||
rgba[d + 1] = bytes[s + 2];
|
||||
rgba[d + 2] = bytes[s + 3];
|
||||
rgba[d + 3] = bytes[s];
|
||||
s += 4;
|
||||
d += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,7 +128,11 @@ mod tests {
|
||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // 1 tile
|
||||
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table: tile 0 @ 0x30
|
||||
b.extend_from_slice(&[0u8; 16]); // 16-byte tile header → pixels at 0x40
|
||||
// rectangle header: dst (0,0), size w×h
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
for i in 0..(w * h) {
|
||||
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
|
||||
}
|
||||
@@ -143,8 +152,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembles_row_major_tiles_via_offset_table() {
|
||||
// 300×1 → 2 tiles: (0,0)=256×1 red, (1,0)=44×1 blue, each +16-byte header.
|
||||
fn assembles_rectangles_via_offset_table() {
|
||||
// 300×1 → 2 rectangles: (0,0) 256×1 red, then (256,0) 44×1 blue.
|
||||
let (w, h): (u32, u32) = (300, 1);
|
||||
let mut b = vec![0u8; 0x2c];
|
||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||
@@ -155,9 +164,9 @@ mod tests {
|
||||
let off1 = off0 + 16 + 256 * 4; // tile-0 header + its 256 pixels
|
||||
b.extend_from_slice(&(off0 as u32).to_be_bytes());
|
||||
b.extend_from_slice(&(off1 as u32).to_be_bytes());
|
||||
b.extend_from_slice(&[0u8; 16]);
|
||||
for v in [0u32, 0, 256, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(0,0) 256×1
|
||||
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
|
||||
b.extend_from_slice(&[0u8; 16]);
|
||||
for v in [256u32, 0, 44, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(256,0) 44×1
|
||||
b.extend_from_slice(&[0xFF, 0, 0, 0xFF].repeat(44)); // A,R,G,B blue
|
||||
let img = parse(&b).expect("decodes");
|
||||
assert_eq!((img.width, img.height), (300, 1));
|
||||
@@ -166,10 +175,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_tilecount_and_short() {
|
||||
// tile-count field that isn't ceil(w/256)*ceil(h/256) → None
|
||||
fn rejects_out_of_range_rect_and_short() {
|
||||
// a rectangle that does not fit the declared surface → None, never guess
|
||||
let mut b = synth(2, 2);
|
||||
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
|
||||
let at = 0x2c + 4;
|
||||
b[at + 8..at + 12].copy_from_slice(&99u32.to_be_bytes()); // width 99 > 2
|
||||
assert!(parse(&b).is_none());
|
||||
// truncated pixel data → None
|
||||
let b = synth(64, 64);
|
||||
|
||||
105
crates/sylpheed-formats/src/unit_layout.rs
Normal file
105
crates/sylpheed-formats/src/unit_layout.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! The runtime layout of a unit / vessel definition object.
|
||||
//!
|
||||
//! The game parses an [`crate::idxd`] record into a fixed 880-byte object whose
|
||||
//! field offsets are **not** guessable from the disc data: the record is a
|
||||
//! reflective key/value pool, and the loader assigns each key to a member by
|
||||
//! name. This table is that assignment, read out of the loader itself
|
||||
//! (`sub_82341A20` — every key is built as `addi r4, r30, -N`, so the field name
|
||||
//! for each store is a string in the executable image), and verified against
|
||||
//! objects dumped from a running mission: **406 values agree with the disc
|
||||
//! records, 0 disagree**, over 11 objects covering both schemas.
|
||||
//!
|
||||
//! Why a reimplementation wants it:
|
||||
//!
|
||||
//! - it names the field behind every word of a live definition object, so a
|
||||
//! memory snapshot can be read directly;
|
||||
//! - it says which fields a record leaves **defaulted**, and what the loader
|
||||
//! leaves there — the float accessor returns `0.0` on a pool miss;
|
||||
//! - it carries two conventions that are invisible on disc: **angles are degrees
|
||||
//! in the data and radians in the object**, and **`Size_Y` takes `Size_X`**
|
||||
//! when omitted.
|
||||
//!
|
||||
//! See `docs/re/live-unit-definitions.md` for the derivation and the measured
|
||||
//! default values.
|
||||
|
||||
/// How a field is stored in the definition object.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// IEEE-754 single, big-endian.
|
||||
F32,
|
||||
/// Pointer to a string.
|
||||
Str,
|
||||
/// 32-bit word (bool / enum / count / id).
|
||||
Word,
|
||||
}
|
||||
|
||||
/// One field of the definition object.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Field {
|
||||
/// Byte offset from the start of the object.
|
||||
pub offset: usize,
|
||||
/// Field name, exactly as the disc record spells it.
|
||||
pub name: &'static str,
|
||||
/// Storage kind.
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
const TABLE: &str = include_str!("../data/unit_definition_layout.txt");
|
||||
|
||||
/// Every mapped field, in offset order.
|
||||
pub fn fields() -> Vec<Field> {
|
||||
TABLE
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
|
||||
.filter_map(|l| {
|
||||
let mut it = l.split_whitespace();
|
||||
let offset = it.next()?.parse().ok()?;
|
||||
let kind = match it.next()? {
|
||||
"f32" => Kind::F32,
|
||||
"str" => Kind::Str,
|
||||
_ => Kind::Word,
|
||||
};
|
||||
// `name` is a &'static str because TABLE is 'static.
|
||||
let name = it.next()?;
|
||||
let name: &'static str = TABLE.get(
|
||||
TABLE.find(name).map(|s| s..s + name.len())?,
|
||||
)?;
|
||||
Some(Field { offset, name, kind })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The field at `offset`, if one is mapped there.
|
||||
pub fn field_at(offset: usize) -> Option<Field> {
|
||||
fields().into_iter().find(|f| f.offset == offset)
|
||||
}
|
||||
|
||||
/// The offset of `name`, if it is mapped.
|
||||
pub fn offset_of(name: &str) -> Option<usize> {
|
||||
fields().into_iter().find(|f| f.name == name).map(|f| f.offset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_parses_and_is_ordered() {
|
||||
let f = fields();
|
||||
assert!(f.len() > 150, "expected the full map, got {}", f.len());
|
||||
assert!(f.windows(2).all(|w| w[0].offset < w[1].offset), "offsets must be strictly increasing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_fields_sit_where_the_loader_puts_them() {
|
||||
// Spot-checks from the verified map; these four also anchor the
|
||||
// identification of a live object (see docs/re/live-unit-definitions.md).
|
||||
assert_eq!(offset_of("Size_X"), Some(48));
|
||||
assert_eq!(offset_of("Size_Y"), Some(52));
|
||||
assert_eq!(offset_of("Size_Z"), Some(56));
|
||||
assert_eq!(offset_of("HP"), Some(84));
|
||||
assert_eq!(offset_of("HQRatio"), Some(88));
|
||||
assert_eq!(field_at(96).map(|f| f.name), Some("ThrusterRatio"));
|
||||
assert_eq!(field_at(48).map(|f| f.kind), Some(Kind::F32));
|
||||
}
|
||||
}
|
||||
144
crates/sylpheed-formats/tests/mesh_consistency_disc.rs
Normal file
144
crates/sylpheed-formats/tests/mesh_consistency_disc.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Cross-container consistency for XBG7 geometry.
|
||||
//!
|
||||
//! A geometry resource shared by several stage containers must decode to the
|
||||
//! same bounds in each. This needs no ground truth, and on 2026-08-11 it found
|
||||
//! **125 of 681** shared resources decoding to different bounds while reporting
|
||||
//! identical vertex and triangle counts — the anchor scan locating a different
|
||||
//! buffer of the same size (see `docs/re/structures/xbg7-mesh.md`).
|
||||
//!
|
||||
//! The test is `#[ignore]`d because the decoder does not satisfy it yet. It is
|
||||
//! written as the *target* state so that fixing the anchor scan makes it pass,
|
||||
//! rather than as a snapshot of the bug.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = Path::new(
|
||||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||||
);
|
||||
if default.join("dat").is_dir() {
|
||||
return Some(default.to_path_buf());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Rounded (w, h, d) of a model's own geometry.
|
||||
fn span(m: &Xbg7Model) -> Option<[i64; 3]> {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for s in &m.meshes {
|
||||
for q in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(q[k]);
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo[0] == f32::MAX {
|
||||
return None;
|
||||
}
|
||||
Some([
|
||||
(hi[0] - lo[0]).round() as i64,
|
||||
(hi[1] - lo[1]).round() as i64,
|
||||
(hi[2] - lo[2]).round() as i64,
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "known-failing: 62 of 714 shared resources decode inconsistently (was 125 of 681; distinct anchor assignment + the 0.42 connectivity cap fixed the rest). Note this metric is the WEAKER witness — a systematic mis-anchor is consistent — see docs/re/structures/xbg7-mesh.md"]
|
||||
fn shared_resources_decode_identically_in_every_container() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
let dir = root.join("hidden/resource3d");
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.expect("resource3d/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
// name -> (verts, tris) -> set of spans seen
|
||||
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize, String)>> = BTreeMap::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||
let Some(sp) = span(&m) else { continue };
|
||||
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
|
||||
seen.entry(m.name.clone()).or_default().push((sp, v, t, where_.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
for (name, list) in &seen {
|
||||
if list.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// Only compare decodes that agree on how much geometry they found;
|
||||
// a differing vertex/triangle count is a different question.
|
||||
if !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
|
||||
continue;
|
||||
}
|
||||
let spans: std::collections::BTreeSet<[i64; 3]> = list.iter().map(|e| e.0).collect();
|
||||
if spans.len() > 1 && bad.len() < 10 {
|
||||
bad.push(format!("{name}: {spans:?}"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
bad.is_empty(),
|
||||
"{} shared resources decode to different bounds; first: {bad:?}",
|
||||
bad.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Port/starboard twins must not decode to the *same* buffer.
|
||||
///
|
||||
/// A runtime capture showed the container stores both halves of the `e106` hull
|
||||
/// as separate X-reflected buffers, so a `…_01`/`…_02` pair of equal vertex
|
||||
/// count should come out mirrored (or related by another axis / vertex order) —
|
||||
/// never identical, which is the collapse distinct assignment fixes. Since that
|
||||
/// fix reached grouped pools too, this holds for **every** twin pair on the disc
|
||||
/// — including `n206`, which was the last exception.
|
||||
#[test]
|
||||
fn twin_pairs_do_not_share_a_buffer() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join("hidden/resource3d"))
|
||||
.expect("resource3d/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut collapsed: Vec<String> = Vec::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
|
||||
let by_name: BTreeMap<&str, &Xbg7Model> =
|
||||
models.iter().map(|m| (m.name.as_str(), m)).collect();
|
||||
for m in &models {
|
||||
let Some(stem) = m.name.strip_suffix("_01") else { continue };
|
||||
let Some(t) = by_name.get(format!("{stem}_02").as_str()) else { continue };
|
||||
let (a, b) = (m.meshes[0].vbuf_offset, t.meshes[0].vbuf_offset);
|
||||
if a.is_some() && a == b {
|
||||
collapsed.push(format!("{}/{stem}_02 in {}", m.name, f.file_name().unwrap().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(collapsed.is_empty(), "twin pairs sharing one buffer: {collapsed:?}");
|
||||
}
|
||||
@@ -17,7 +17,7 @@ fn res3d_dir() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
let default =
|
||||
PathBuf::from("/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d");
|
||||
PathBuf::from("/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d");
|
||||
default.is_dir().then_some(default)
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ fn hero_ship_grouped_pool_decodes() {
|
||||
fn stage_models_decode() {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
||||
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
"/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
});
|
||||
let path = format!("{dir}/Stage_S10.xpr");
|
||||
let bytes = std::fs::read(&path).expect("read Stage_S10");
|
||||
@@ -359,7 +359,7 @@ fn stage_models_sweep() {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use std::time::Instant;
|
||||
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
||||
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
"/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
});
|
||||
let mut names: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
@@ -386,7 +386,7 @@ fn stage_models_sweep() {
|
||||
fn stage_models_quality_audit() {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let dir = std::env::var("SYLPHEED_RES3D").unwrap_or_else(|_| {
|
||||
"/home/fabi/RE Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
"/home/fabi/RE - Project Sylpheed/sylph_extract/hidden/resource3d".to_string()
|
||||
});
|
||||
let bytes = std::fs::read(format!("{dir}/Stage_S07.xpr")).unwrap();
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
@@ -424,3 +424,59 @@ fn stage_models_quality_audit() {
|
||||
assert!(huge < models.len() / 20, "few huge (skybox-plane) models");
|
||||
assert!(worst_deg < 0.35, "no model should be mostly-degenerate");
|
||||
}
|
||||
|
||||
/// A correctly located index run has **no degenerate triangles**. That is the
|
||||
/// signature the 2026-08-13 pad-scoring fix keys on: an index list read one
|
||||
/// element late still passes every count-based gate but wires arbitrary
|
||||
/// vertices, which produces triangles with a repeated index. Before the fix
|
||||
/// **579** decoded runs on the disc carried such triangles (and a runtime
|
||||
/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it and
|
||||
/// the two follow-ups (pad scoring in the grouped path, and preferring a
|
||||
/// degenerate-free candidate over an earlier dirty one), exactly **one** does.
|
||||
/// Locking the
|
||||
/// number in, because the defect is invisible to coverage and to the anchor
|
||||
/// oracle: every count stays correct while the geometry is mis-wired.
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn decoded_index_runs_have_almost_no_degenerate_triangles() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.expect("resource3d/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut offenders: Vec<(String, String, usize)> = Vec::new();
|
||||
for f in &files {
|
||||
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
for m in Xbg7Model::stage_models(&bytes) {
|
||||
for sm in &m.meshes {
|
||||
let d = sm
|
||||
.indices
|
||||
.chunks_exact(3)
|
||||
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
|
||||
.count();
|
||||
if d > 0 {
|
||||
offenders.push((m.name.clone(), where_.clone(), d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The one at default settings: `_rou_f402_dead` in `Stage_S09`, whose
|
||||
// degenerate-free block is claimed by `e_rou_f003_Near` — both 24-vertex
|
||||
// bounding boxes, the identity class that needs descriptor-level data. (With
|
||||
// `XBG7_SUBMESH_DECLS=1` four newly decoded `ptc_pack` `.dat` composites join
|
||||
// it; that knob is off by default.) Anything else here is a regression.
|
||||
assert!(
|
||||
offenders.len() <= 1,
|
||||
"{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}",
|
||||
offenders.len(),
|
||||
&offenders[..offenders.len().min(20)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ fn iso_path() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
let default = PathBuf::from(
|
||||
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
|
||||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
|
||||
);
|
||||
default.is_file().then_some(default)
|
||||
}
|
||||
|
||||
152
crates/sylpheed-formats/tests/ui_surfaces_disc.rs
Normal file
152
crates/sylpheed-formats/tests/ui_surfaces_disc.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! Disc-wide guards for the 2D surface formats, locking in what was measured on
|
||||
//! 2026-08-11. Skipped (as no-ops) when the extracted disc is absent.
|
||||
//!
|
||||
//! These assert *disc-wide invariants* rather than one hand-picked file, because
|
||||
//! each of the findings they guard was originally missed by reasoning from a
|
||||
//! sample: T8aD's "~15 % unsupported variants" were a wrong model, and LSTA's
|
||||
//! "a few entries disagree with the count" was a miscount.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::{lsta, pak::PakArchive, ratc, t8ad};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = Path::new(
|
||||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||||
);
|
||||
if default.join("dat").is_dir() {
|
||||
return Some(default.to_path_buf());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
macro_rules! skip_without_disc {
|
||||
($root:ident) => {
|
||||
let Some($root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// Every entry of every pak, plus every RATC child, as raw bytes.
|
||||
fn for_each_blob(root: &Path, mut f: impl FnMut(&str, &str, &[u8])) {
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
for p in &paks {
|
||||
let pak_name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else { continue };
|
||||
f(&pak_name, &format!("{:08x}", e.name_hash), &bytes);
|
||||
if ratc::is_ratc(&bytes) {
|
||||
if let Some(kids) = ratc::parse(&bytes) {
|
||||
for k in &kids {
|
||||
if k.offset + k.size <= bytes.len() {
|
||||
f(&pak_name, &k.name, &bytes[k.offset..k.offset + k.size]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A T8aD surface is a list of sub-rectangles, and on this disc **every** one
|
||||
/// decodes. Regressing the rectangle model would show up here as a decode gap,
|
||||
/// which is exactly how the old 256-grid reading looked (96 %, not 100 %).
|
||||
#[test]
|
||||
fn every_t8ad_on_the_disc_decodes() {
|
||||
skip_without_disc!(root);
|
||||
let (mut total, mut ok) = (0usize, 0usize);
|
||||
let mut first_failure = None;
|
||||
for_each_blob(&root, |pak, name, b| {
|
||||
if !t8ad::is_t8ad(b) || b.len() < 0x40 {
|
||||
return;
|
||||
}
|
||||
total += 1;
|
||||
if t8ad::parse(b).is_some() {
|
||||
ok += 1;
|
||||
} else if first_failure.is_none() {
|
||||
first_failure = Some(format!("{pak}:{name}"));
|
||||
}
|
||||
});
|
||||
assert!(total > 19_000, "expected the disc's ~19 216 surfaces, saw {total}");
|
||||
assert_eq!(ok, total, "first failure: {first_failure:?}");
|
||||
}
|
||||
|
||||
/// An LSTA's header count is exact and counts **both** kinds of element: T8aD
|
||||
/// sprites and `PRMD` primitives. (It was long read as unreliable because the
|
||||
/// comparison ignored primitives.)
|
||||
#[test]
|
||||
fn lsta_count_equals_sprites_plus_primitives() {
|
||||
skip_without_disc!(root);
|
||||
let count_magic = |b: &[u8], magic: &[u8; 4]| {
|
||||
let (mut n, mut i) = (0usize, 4usize);
|
||||
while i + 4 <= b.len() {
|
||||
if &b[i..i + 4] == magic {
|
||||
n += 1;
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
};
|
||||
let (mut lists, mut exact) = (0usize, 0usize);
|
||||
let mut bad = Vec::new();
|
||||
for_each_blob(&root, |pak, name, b| {
|
||||
if !lsta::is_lsta(b) || b.len() < 8 {
|
||||
return;
|
||||
}
|
||||
lists += 1;
|
||||
let declared = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
let sprites = count_magic(b, b"T8aD");
|
||||
let prims = count_magic(b, b"PRMD");
|
||||
if declared == sprites + prims {
|
||||
exact += 1;
|
||||
} else if bad.len() < 4 {
|
||||
bad.push(format!("{pak}:{name} declared {declared} != {sprites}+{prims}"));
|
||||
}
|
||||
});
|
||||
assert!(lists >= 60, "expected the disc's 64 LSTA lists, saw {lists}");
|
||||
assert_eq!(exact, lists, "mismatches: {bad:?}");
|
||||
}
|
||||
|
||||
/// Nested RATC records are **leaves**: they carry no child list of their own.
|
||||
/// "One level deep" describes the data, not a parser limit.
|
||||
#[test]
|
||||
fn ratc_nesting_is_exactly_one_level() {
|
||||
skip_without_disc!(root);
|
||||
let mut grandchildren = 0usize;
|
||||
let mut bundles = 0usize;
|
||||
for_each_blob(&root, |_, _, b| {
|
||||
if !ratc::is_ratc(b) {
|
||||
return;
|
||||
}
|
||||
let Some(kids) = ratc::parse(b) else { return };
|
||||
bundles += 1;
|
||||
for k in &kids {
|
||||
if k.offset + k.size > b.len() {
|
||||
continue;
|
||||
}
|
||||
let sub = &b[k.offset..k.offset + k.size];
|
||||
if ratc::is_ratc(sub) {
|
||||
grandchildren += ratc::parse(sub).map(|g| g.len()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(bundles > 2_000, "expected thousands of RATC bundles, saw {bundles}");
|
||||
assert_eq!(grandchildren, 0, "a nested RATC record listed children");
|
||||
}
|
||||
96
crates/sylpheed-formats/tests/unit_layout_disc.rs
Normal file
96
crates/sylpheed-formats/tests/unit_layout_disc.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! The definition-object layout must keep agreeing with the disc records.
|
||||
//!
|
||||
//! `unit_layout` was read out of the game's loader and checked against objects
|
||||
//! dumped from a running Stage 02. This pins that check: every mapped float
|
||||
//! field of every identified object must equal the value its disc record sets.
|
||||
//! It needs no emulator — the dump is checked in under `docs/re/captures/`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::unit_layout::{fields, Kind};
|
||||
|
||||
/// Live objects identified in the dump, with the disc record each one is.
|
||||
/// `bf001` is here because full-record agreement is what identified it: the
|
||||
/// four-value signature also fitted `UN_be005_ADAN_SpaceFortress`, which
|
||||
/// disagrees on `Color_R`/`Color_G`.
|
||||
const IDENTIFIED: &[(&str, &str)] = &[
|
||||
("UN_f106_TCAF_Destroyer", "0xbd3ee300"),
|
||||
("UN_f105_TCAF_Cruiser", "0xbd40e200"),
|
||||
("UN_e105_ADAN_Cruiser", "0xbd3fd800"),
|
||||
("UN_e106_ADAN_Destroyer", "0xbd3e6f80"),
|
||||
("UN_f101_TCAF_Acropolis", "0xbd3e1f00"),
|
||||
("UN_bf001_TCAF_SchlosBase", "0xbd3b6a00"),
|
||||
("UN_e007_ADAN_Turret", "0xbd3dea80"),
|
||||
("UN_e010_ADAN_Attacker_S", "0xbd3faa80"),
|
||||
("UN_f003_TCAF_ArrowHead", "0xbd3dbd00"),
|
||||
("UN_f001_TCAF_DeltaSaber_T", "0xbd3dfc00"),
|
||||
("UN_f001_TCAF_DeltaSaber_T_Player", "0xbd3da100"),
|
||||
];
|
||||
|
||||
const DUMP: &str = include_str!("../../../docs/re/captures/stage02-live-unit-definitions-deep.txt");
|
||||
|
||||
fn disc_root() -> Option<String> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
if std::path::Path::new(&p).join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let d = "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)";
|
||||
std::path::Path::new(d).join("dat").is_dir().then(|| d.to_string())
|
||||
}
|
||||
|
||||
/// `va -> offset -> value`, from the dump's `addr +off hex u32 f32` columns.
|
||||
fn live() -> BTreeMap<String, BTreeMap<usize, f32>> {
|
||||
let mut out: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
|
||||
let mut cur = String::new();
|
||||
for line in DUMP.lines() {
|
||||
if let Some(rest) = line.strip_prefix("=== ") {
|
||||
cur = rest.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.len() >= 5 && f[1].starts_with('+') {
|
||||
if let (Ok(off), Ok(val)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>())
|
||||
{
|
||||
out.entry(cur.clone()).or_default().insert(off, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_fields_match_the_disc_records() {
|
||||
let Some(disc) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("main pak");
|
||||
let live = live();
|
||||
let floats: Vec<_> = fields().into_iter().filter(|f| f.kind == Kind::F32).collect();
|
||||
|
||||
let (mut agree, mut bad) = (0usize, Vec::new());
|
||||
for entry in pak.entries() {
|
||||
let Ok(bytes) = pak.read(entry) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||
let Some(id) = obj.get_raw("ID") else { continue };
|
||||
let Some((_, va)) = IDENTIFIED.iter().find(|(i, _)| *i == id) else { continue };
|
||||
let Some(words) = live.get(*va) else { continue };
|
||||
for f in &floats {
|
||||
let (Some(want), Some(got)) = (obj.get_f32(f.name), words.get(&f.offset)) else {
|
||||
continue;
|
||||
};
|
||||
// Angle fields are degrees on disc and radians in the object.
|
||||
let rad = want * std::f32::consts::PI / 180.0;
|
||||
if (want - got).abs() <= want.abs() * 1e-4 || (rad - got).abs() <= rad.abs() * 1e-4 {
|
||||
agree += 1;
|
||||
} else {
|
||||
bad.push(format!("{id}.{} disc {want} vs live +{} = {got}", f.name, f.offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{} fields disagree: {bad:?}", bad.len());
|
||||
assert!(agree >= 400, "expected ≥400 agreeing fields, got {agree}");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,11 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
|
||||
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
|
||||
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
|
||||
IsoLoaderSystemSet,
|
||||
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
||||
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
|
||||
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
|
||||
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
use sylpheed_formats::SubLang;
|
||||
@@ -25,6 +26,8 @@ impl Plugin for ViewerUiPlugin {
|
||||
app.insert_resource(FileBrowserState::default());
|
||||
// Run after the iso_loader chain so we see the frame's final state.
|
||||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +139,8 @@ struct UiEvents<'w> {
|
||||
subtitles: EventWriter<'w, RequestSubtitles>,
|
||||
audio: EventWriter<'w, RequestAudio>,
|
||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||
game_data: EventWriter<'w, RequestGameData>,
|
||||
ships: EventWriter<'w, RequestShipCatalog>,
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
@@ -193,6 +198,18 @@ fn draw_viewer_ui(
|
||||
}
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("🗃 Game Data…").clicked() {
|
||||
// The Game Data window (its own system) reads this event to
|
||||
// open + lazily decode the tables.
|
||||
events.game_data.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("🚀 Ships…").clicked() {
|
||||
// The Ships window (its own system) reads this event to open +
|
||||
// lazily scan the stage containers for assemblable ships.
|
||||
events.ships.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
@@ -227,37 +244,73 @@ fn draw_viewer_ui(
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut voice_lib.filter);
|
||||
});
|
||||
let f = voice_lib.filter.to_lowercase();
|
||||
// Group the thousands of entries as directory → speaker so the
|
||||
// list is navigable (e.g. browse `Voice` by character to find a
|
||||
// cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`.
|
||||
use std::collections::BTreeMap;
|
||||
let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> =
|
||||
BTreeMap::new();
|
||||
for c in &voice_lib.clips {
|
||||
if !f.is_empty() && !c.name.to_lowercase().contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
|
||||
groups
|
||||
.entry(dir)
|
||||
.or_default()
|
||||
.entry(c.speaker.as_str())
|
||||
.or_default()
|
||||
.push(c);
|
||||
}
|
||||
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} clips", voice_lib.clips.len()))
|
||||
egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len()))
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.separator();
|
||||
let f = voice_lib.filter.to_lowercase();
|
||||
let clips: Vec<_> = voice_lib
|
||||
.clips
|
||||
.iter()
|
||||
.filter(|c| f.is_empty() || c.name.to_lowercase().contains(&f))
|
||||
.take(2000)
|
||||
.cloned()
|
||||
.collect();
|
||||
let filtering = !f.is_empty();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for c in &clips {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("▶").on_hover_text(&c.name).clicked() {
|
||||
audio.generation = audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.name = c.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: c.name.clone(),
|
||||
display: c.display.clone(),
|
||||
movie: None,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&c.display);
|
||||
});
|
||||
for (dir, speakers) in &groups {
|
||||
let dtotal: usize = speakers.values().map(Vec::len).sum();
|
||||
egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})"))
|
||||
.id_salt(("vdir", *dir))
|
||||
.default_open(filtering)
|
||||
.show(ui, |ui| {
|
||||
for (speaker, clips) in speakers {
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"{speaker} ({})",
|
||||
clips.len()
|
||||
))
|
||||
.id_salt(("vspk", *dir, *speaker))
|
||||
.default_open(filtering || clips.len() <= 6)
|
||||
.show(ui, |ui| {
|
||||
for c in clips {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button("▶")
|
||||
.on_hover_text(&c.name)
|
||||
.clicked()
|
||||
{
|
||||
audio.generation =
|
||||
audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.name = c.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: c.name.clone(),
|
||||
display: c.display.clone(),
|
||||
movie: None,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&c.display);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1244,3 +1297,397 @@ fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Game Data browser ──────────────────────────────────────────────────────────
|
||||
|
||||
fn fnum(v: Option<f32>) -> String {
|
||||
match v {
|
||||
Some(x) if x == x.trunc() => format!("{}", x as i64),
|
||||
Some(x) => format!("{x}"),
|
||||
None => "·".into(),
|
||||
}
|
||||
}
|
||||
fn fint(v: Option<i64>) -> String {
|
||||
v.map(|x| x.to_string()).unwrap_or_else(|| "·".into())
|
||||
}
|
||||
|
||||
/// The Game Data browser — a floating window over the decoded IDXD tables
|
||||
/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so
|
||||
/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData`
|
||||
/// event (from the View menu) opens it and lazily kicks off decoding.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn draw_game_data_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut game_data: ResMut<GameData>,
|
||||
mut requests: EventReader<RequestGameData>,
|
||||
) {
|
||||
if requests.read().next().is_some() {
|
||||
game_data.open = true;
|
||||
if !game_data.loaded {
|
||||
game_data.loading = true;
|
||||
}
|
||||
}
|
||||
if !game_data.open {
|
||||
return;
|
||||
}
|
||||
let ctx = contexts.ctx_mut().clone();
|
||||
let mut open = true;
|
||||
egui::Window::new("🗃 Game Data")
|
||||
.default_width(700.0)
|
||||
.default_height(560.0)
|
||||
.open(&mut open)
|
||||
.show(&ctx, |ui| {
|
||||
if game_data.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Decoding game tables…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
if !game_data.loaded {
|
||||
ui.label("Open a game source first (File ▸ Open…).");
|
||||
return;
|
||||
}
|
||||
let GameData { category, filter, snapshot, .. } = &mut *game_data;
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for cat in GameCategory::ALL {
|
||||
ui.selectable_value(category, cat, cat.label());
|
||||
}
|
||||
});
|
||||
let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights);
|
||||
if filterable {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("🔍");
|
||||
ui.text_edit_singleline(filter);
|
||||
if !filter.is_empty() && ui.small_button("✖").clicked() {
|
||||
filter.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
let f = filter.to_lowercase();
|
||||
let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f);
|
||||
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category {
|
||||
GameCategory::Weapons => {
|
||||
egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for w in &snapshot.weapons {
|
||||
let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_");
|
||||
if !hit(name) {
|
||||
continue;
|
||||
}
|
||||
ui.label(name);
|
||||
ui.label(w.target_type.as_deref().unwrap_or("·"));
|
||||
ui.label(fnum(w.power));
|
||||
ui.label(fnum(w.velocity));
|
||||
ui.label(fnum(w.max_range));
|
||||
ui.label(fint(w.loading_count));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Craft => {
|
||||
egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for u in &snapshot.craft {
|
||||
let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
|
||||
if !hit(name) {
|
||||
continue;
|
||||
}
|
||||
ui.label(name);
|
||||
ui.label(fnum(u.hp));
|
||||
ui.label(fnum(u.cruising_velocity));
|
||||
ui.label(fnum(u.acceleration));
|
||||
ui.label(fnum(u.radar_range));
|
||||
ui.label(fint(u.turret_count));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Vessels => {
|
||||
egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for v in &snapshot.vessels {
|
||||
let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
|
||||
if !hit(name) {
|
||||
continue;
|
||||
}
|
||||
ui.label(name);
|
||||
ui.label(fnum(v.hp));
|
||||
ui.label(fnum(v.size_z));
|
||||
ui.label(fint(v.turret_count));
|
||||
ui.label(fint(v.bridge_count));
|
||||
ui.label(fint(v.shield_generator_count));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Characters => {
|
||||
egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| {
|
||||
for h in ["Name", "Faction", "Portraits"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for c in &snapshot.characters {
|
||||
if !hit(&c.name) && !hit(&c.faction) {
|
||||
continue;
|
||||
}
|
||||
ui.label(&c.name);
|
||||
let col = if c.faction == "TCAF" {
|
||||
egui::Color32::from_rgb(90, 160, 232)
|
||||
} else if c.faction == "ADAN" {
|
||||
egui::Color32::from_rgb(224, 86, 122)
|
||||
} else {
|
||||
egui::Color32::GRAY
|
||||
};
|
||||
ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction });
|
||||
ui.label(c.faces.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Missions => {
|
||||
for m in &snapshot.missions {
|
||||
if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) {
|
||||
continue;
|
||||
}
|
||||
let head = format!("{} · {} · {} phases", m.id, m.location, m.phases);
|
||||
egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| {
|
||||
if !m.objectives.is_empty() {
|
||||
ui.strong("Objectives");
|
||||
for o in &m.objectives {
|
||||
ui.label(format!("▸ {o}"));
|
||||
}
|
||||
}
|
||||
if !m.lose.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.strong("Fail conditions");
|
||||
for l in &m.lose {
|
||||
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l);
|
||||
}
|
||||
}
|
||||
if !m.enemies.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.strong("Enemy roster");
|
||||
for e in &m.enemies {
|
||||
ui.label(format!("• {e}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
GameCategory::Arsenal => {
|
||||
ui.columns(4, |cols| {
|
||||
for (i, (title, list)) in [
|
||||
("Nose", &snapshot.arsenal.nose),
|
||||
("Arm 1", &snapshot.arsenal.arm1),
|
||||
("Arm 2", &snapshot.arsenal.arm2),
|
||||
("Arm 3", &snapshot.arsenal.arm3),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
cols[i].strong(format!("{title} ({})", list.len()));
|
||||
for w in list {
|
||||
cols[i].label(w.replace('_', " "));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Flights => {
|
||||
ui.label(
|
||||
egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
for (n, lineup) in snapshot.flights.iter().enumerate() {
|
||||
egui::CollapsingHeader::new(format!("Line-up {}", n + 1))
|
||||
.id_salt(n)
|
||||
.default_open(n == 0)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| {
|
||||
for (cs, pilot) in lineup {
|
||||
ui.label(cs);
|
||||
ui.strong(pilot);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
game_data.open &= open;
|
||||
}
|
||||
|
||||
// ── Ships browser ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The Ships browser — a floating window over the capital ships reconstructed
|
||||
/// from XBG7 part families. Picking a ship assembles its parts and renders the
|
||||
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
|
||||
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
|
||||
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn draw_ships_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
mut requests: EventReader<RequestShipCatalog>,
|
||||
mut render: EventWriter<RequestShipRender>,
|
||||
) {
|
||||
if requests.read().next().is_some() {
|
||||
ships.open = true;
|
||||
}
|
||||
if !ships.open {
|
||||
return;
|
||||
}
|
||||
let ctx = contexts.ctx_mut().clone();
|
||||
let mut open = true;
|
||||
egui::Window::new("🚀 Ships")
|
||||
.default_width(560.0)
|
||||
.default_height(560.0)
|
||||
.open(&mut open)
|
||||
.show(&ctx, |ui| {
|
||||
if ships.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Scanning stage models for ships…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
if !ships.loaded {
|
||||
ui.label("Open a game source first (File ▸ Open…).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Faction filter chips.
|
||||
ui.horizontal(|ui| {
|
||||
for (label, val) in
|
||||
[("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
|
||||
{
|
||||
let sel = ships.faction == val;
|
||||
if ui.selectable_label(sel, label).clicked() {
|
||||
ships.faction = val.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("🔍");
|
||||
ui.text_edit_singleline(&mut ships.filter);
|
||||
if !ships.filter.is_empty() && ui.small_button("✖").clicked() {
|
||||
ships.filter.clear();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
|
||||
ui.label(
|
||||
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let ShipBrowser { rows, filter, faction, selected, show_external, .. } = &mut *ships;
|
||||
let show_external = *show_external;
|
||||
let needle = filter.to_lowercase();
|
||||
let mut to_render: Option<(String, String, String)> = None;
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let mut last_section = String::new();
|
||||
for row in rows.iter() {
|
||||
if !faction.is_empty() && &row.faction != faction {
|
||||
continue;
|
||||
}
|
||||
if !needle.is_empty()
|
||||
&& !row.name.to_lowercase().contains(&needle)
|
||||
&& !row.id.contains(&needle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Section header: capital ships first, then other assemblies.
|
||||
let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
|
||||
if section != last_section {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(section).strong().weak());
|
||||
last_section = section.to_string();
|
||||
}
|
||||
|
||||
let is_sel = selected.as_deref() == Some(row.id.as_str());
|
||||
let color = match row.faction.as_str() {
|
||||
"TCAF" => egui::Color32::from_rgb(120, 170, 235),
|
||||
"ADAN" => egui::Color32::from_rgb(235, 120, 120),
|
||||
_ => egui::Color32::from_rgb(180, 180, 180),
|
||||
};
|
||||
egui::CollapsingHeader::new(
|
||||
egui::RichText::new(format!("{} · {}", row.name, row.id)).color(color),
|
||||
)
|
||||
.id_salt(&row.id)
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
|
||||
ui.label("Faction");
|
||||
ui.strong(&row.faction);
|
||||
ui.end_row();
|
||||
if let Some(hp) = row.hp {
|
||||
ui.label("Hull HP");
|
||||
ui.strong(format!("{hp:.0}"));
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some((x, y, z)) = row.size {
|
||||
ui.label("Size (m)");
|
||||
ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
|
||||
ui.end_row();
|
||||
}
|
||||
if row.turrets.is_some() || row.shield_gens.is_some() {
|
||||
ui.label("Hardpoints");
|
||||
ui.strong(format!(
|
||||
"{} turrets · {} bridges · {} shield gens",
|
||||
fint(row.turrets),
|
||||
fint(row.bridges),
|
||||
fint(row.shield_gens),
|
||||
));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Model parts");
|
||||
ui.strong(format!("{}", row.parts.len()));
|
||||
ui.end_row();
|
||||
ui.label("Appears in");
|
||||
ui.strong(row.stages.join(", "));
|
||||
ui.end_row();
|
||||
});
|
||||
let btn = egui::Button::new(if is_sel {
|
||||
"● Showing in 3D view"
|
||||
} else {
|
||||
"▶ Assemble & view in 3D"
|
||||
});
|
||||
if ui.add(btn).clicked() {
|
||||
to_render = Some((
|
||||
row.id.clone(),
|
||||
row.stage_file.clone(),
|
||||
format!("{} ({})", row.name, row.id),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((id, file, label)) = to_render {
|
||||
let id2 = id.clone();
|
||||
*selected = Some(id);
|
||||
render.send(RequestShipRender { file, id: id2, external: show_external, label });
|
||||
}
|
||||
});
|
||||
ships.open &= open;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user