Compare commits
152 Commits
auto/re-mi
...
auto/re-ch
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f6fcf36dc | |||
| 8b03a52aa6 | |||
| 92d9683f3a | |||
| a3f14710a4 | |||
| 0a84c1358e | |||
| 6c7025851e | |||
| dc46339b63 | |||
| dfa769420d | |||
| 5f3c618b51 | |||
| 850b04c606 | |||
| 3d3d6726fa | |||
| 0b2f004ad0 | |||
| faa7b3d611 | |||
| a79a1da183 | |||
| 4cbce6bd21 | |||
| aac017dd0d | |||
| 3ef2c438ae | |||
| 0bc790de52 | |||
| 023bb71cfd | |||
| f490fecefb | |||
| 20299c05a1 | |||
| 90e0e66c7b | |||
| 33ae20896e | |||
| 8ecd70f1bc | |||
| 10a96844ba | |||
| 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 |
@@ -12,7 +12,12 @@
|
||||
|
||||
ship e106 ref=e106_bdy_04
|
||||
e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667
|
||||
e106_bdy_02 -1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
|
||||
# 2026-08-12: this rotation was diag(-1,1,1) - the X-flip `correlate` bakes in
|
||||
# when a draw's buffer is the mirror of the decoded one. With distinct anchor
|
||||
# assignment the twins now decode to their OWN buffers (the container carries
|
||||
# both halves), so the mirror lives in the data and the placement is plain
|
||||
# identity. Re-emitting from the capture agrees: `1 0 0 0 1 0 0 0 1 264.04343`.
|
||||
e106_bdy_02 1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
|
||||
e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878
|
||||
e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0
|
||||
e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748
|
||||
|
||||
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
|
||||
55
crates/sylpheed-formats/examples/achievements_map.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! RE probe: the `ACHIEVEMENTS_REQUIREMENTS` config list.
|
||||
//!
|
||||
//! `GamePart_Debriefing` (`0x8218CF38`..`0x82191B18`) walks this list after a
|
||||
//! mission (`sub_8218F9A8`): for each entry it takes the entry's **index** `n`,
|
||||
//! tests bit `n` of an awarded-mask, and if the bit is clear it evaluates the
|
||||
//! entry (`0x8218FAB0`) and sets the bit when satisfied. `GamePart_ChallengeMission`
|
||||
//! then gates each challenge mission on a bit of the same space via its own
|
||||
//! `REQUIREMENT` key. So this list *is* the achievement/bit numbering.
|
||||
//!
|
||||
//! Run: cargo run --release -p sylpheed-formats --example achievements_map -- <disc-root>
|
||||
|
||||
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> bool {
|
||||
h.windows(n.len()).any(|w| w == n)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).unwrap_or_else(|| {
|
||||
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
|
||||
});
|
||||
let dat = format!("{disc}/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dat)
|
||||
.expect("dat dir")
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
for p in &paks {
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for (i, e) in arc.entries().iter().enumerate() {
|
||||
let Ok(b) = arc.read(e) else { continue };
|
||||
if !find(&b, b"ACHIEVEMENTS_REQUIREMENTS") {
|
||||
continue;
|
||||
}
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
match IdxdObject::parse(&b) {
|
||||
Ok(o) => {
|
||||
let t = o.tokens();
|
||||
println!(
|
||||
"\n===== {name} entry #{i} schema {:08x} {} tokens =====",
|
||||
o.schema_hash,
|
||||
t.len()
|
||||
);
|
||||
for (j, tok) in t.iter().enumerate() {
|
||||
println!(" {j:3} {tok}");
|
||||
}
|
||||
}
|
||||
Err(err) => println!("\n===== {name} entry #{i}: not IDXD ({err}) ====="),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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}");
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
81
crates/sylpheed-formats/examples/challenge_map.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! RE probe: what does the disc say about CHALLENGE / EXTRA missions?
|
||||
//!
|
||||
//! The title's stage-config reader (`0x82184b98`..`0x82184e94`) selects one of three
|
||||
//! config sections by a mode field at `obj+144`:
|
||||
//! mode == 3 -> "EXTRA"
|
||||
//! mode == 5 or 6 -> "CHALLENGE"
|
||||
//! otherwise -> "FILE"
|
||||
//! so challenge missions are a *mode*, not a separate stage numbering. This probe
|
||||
//! asks the disc which stage records exist and what GP_CHALLENGE.pak carries.
|
||||
//!
|
||||
//! Run: cargo run --release -p sylpheed-formats --example challenge_map -- <disc-root>
|
||||
|
||||
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).unwrap_or_else(|| {
|
||||
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
|
||||
});
|
||||
|
||||
println!("=== 1. StageResource records in GP_MAIN_GAME_E.pak ===");
|
||||
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||
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 stage = t
|
||||
.iter()
|
||||
.find_map(|s| s.strip_prefix("EnumUnit_").map(|x| x.trim_end_matches(".tbl").to_string()))
|
||||
.unwrap_or("?".into());
|
||||
let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string();
|
||||
rows.push((stage, bg, t.len()));
|
||||
}
|
||||
rows.sort();
|
||||
println!(" {} stage records", rows.len());
|
||||
for (s, bg, n) in &rows {
|
||||
println!(" {s:8} bg={bg:16} tokens={n}");
|
||||
}
|
||||
|
||||
println!("\n=== 2. GP_CHALLENGE.pak contents ===");
|
||||
match PakArchive::open(format!("{disc}/dat/GP_CHALLENGE.pak")) {
|
||||
Ok(ch) => {
|
||||
let mut by_schema: std::collections::BTreeMap<u32, usize> = Default::default();
|
||||
let mut all_tokens: Vec<(u32, Vec<String>)> = vec![];
|
||||
for e in ch.entries() {
|
||||
let Ok(b) = ch.read(e) else { continue };
|
||||
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||
*by_schema.entry(o.schema_hash).or_default() += 1;
|
||||
all_tokens.push((o.schema_hash, o.tokens().iter().map(|s| s.to_string()).collect()));
|
||||
}
|
||||
println!(" {} entries, {} IDXD objects", ch.entries().len(), all_tokens.len());
|
||||
for (h, n) in &by_schema {
|
||||
println!(" schema {h:08x} x{n}");
|
||||
}
|
||||
for (h, t) in all_tokens.iter().take(12) {
|
||||
println!(" -- {h:08x}: {:?}", &t[..t.len().min(60)]);
|
||||
}
|
||||
}
|
||||
Err(e) => println!(" open failed: {e}"),
|
||||
}
|
||||
|
||||
println!("\n=== 3. tokens mentioning Challenge / EX across the main pak ===");
|
||||
let mut hits: std::collections::BTreeSet<String> = Default::default();
|
||||
for e in arc.entries() {
|
||||
let Ok(b) = arc.read(e) else { continue };
|
||||
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||
for t in o.tokens() {
|
||||
let l = t.to_ascii_lowercase();
|
||||
if l.contains("challenge") || t.ends_with("_EX") || t.contains("_EX4") || t.contains("_EX5") {
|
||||
hits.insert(format!("{:08x} {t}", o.schema_hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
for h in hits.iter().take(120) {
|
||||
println!(" {h}");
|
||||
}
|
||||
println!(" ({} distinct)", hits.len());
|
||||
}
|
||||
78
crates/sylpheed-formats/examples/challenge_screen.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! RE probe: the GamePart_ChallengeMission screen config.
|
||||
//!
|
||||
//! The class's code range (`0x82187E60`..`0x8218CF10`, bounded by the factory
|
||||
//! creator thunks either side) references these config keys:
|
||||
//! MISSIONS, MISSION_ID, NEW_STAGE, REQUIREMENT, REQUIREMENT_DESC, "Always",
|
||||
//! GRAY_BUTTON, NORMAL_BUTTON, THUMBNAIL, STAGE_DESC, TEXT_STAGE,
|
||||
//! RECORD_TYPE, "Time", TEXT_RECORD, BASE_INFO
|
||||
//! i.e. the challenge list is a *config record* with a per-mission REQUIREMENT.
|
||||
//! It lives in `tables.pak` (one copy per language), not in GP_CHALLENGE.pak.
|
||||
//!
|
||||
//! Run: cargo run --release -p sylpheed-formats --example challenge_screen -- <disc-root>
|
||||
|
||||
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
|
||||
|
||||
const KEYS: &[&str] = &[
|
||||
"MISSIONS",
|
||||
"MISSION_ID",
|
||||
"REQUIREMENT",
|
||||
"REQUIREMENT_DESC",
|
||||
"NEW_STAGE",
|
||||
"GRAY_BUTTON",
|
||||
"NORMAL_BUTTON",
|
||||
"RECORD_TYPE",
|
||||
"THUMBNAIL",
|
||||
"STAGE_DESC",
|
||||
];
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> bool {
|
||||
h.windows(n.len()).any(|w| w == n)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args().nth(1).unwrap_or_else(|| {
|
||||
std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC")
|
||||
});
|
||||
|
||||
let dat = format!("{disc}/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dat)
|
||||
.expect("dat dir")
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
for p in &paks {
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for (i, e) in arc.entries().iter().enumerate() {
|
||||
let Ok(b) = arc.read(e) else { continue };
|
||||
if KEYS.iter().filter(|k| find(&b, k.as_bytes())).count() < KEYS.len() {
|
||||
continue;
|
||||
}
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
match IdxdObject::parse(&b) {
|
||||
Ok(o) => {
|
||||
let toks = o.tokens();
|
||||
// language tag: the PATH value, e.g. "dat\GP_CHALLENGE.pak+eng\"
|
||||
let lang = toks
|
||||
.iter()
|
||||
.find(|t| t.contains("GP_CHALLENGE.pak+"))
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"\n===== {name} entry #{i} schema {:08x} {} tokens [{lang}] =====",
|
||||
o.schema_hash,
|
||||
toks.len()
|
||||
);
|
||||
// Print the pool in order; the record is key/value interleaved and
|
||||
// IDXD dedupes repeats, so pairing is read by eye, not asserted.
|
||||
for (j, t) in toks.iter().enumerate() {
|
||||
println!(" {j:3} {t}");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("\n===== {name} entry #{i}: IDXD parse failed: {e} ====="),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
//! Usage:
|
||||
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
|
||||
//! e.g. SYLPHEED_ISO="/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of
|
||||
//! e.g. SYLPHEED_ISO="/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of
|
||||
//! Deception (USA, Europe) (En,Ja).iso" \
|
||||
//! cargo run --release --example correlate_capture -- \
|
||||
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit
|
||||
|
||||
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
@@ -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}");
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
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
@@ -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}")}}
|
||||
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
@@ -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
@@ -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() });
|
||||
}
|
||||
}
|
||||
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
@@ -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[..]
|
||||
);
|
||||
}
|
||||
}
|
||||
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(", "));
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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));
|
||||
}
|
||||
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(", "));
|
||||
}
|
||||
}
|
||||
18
crates/sylpheed-formats/examples/screen_configs.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! RE probe: dump tables.pak screen-config records that mention a given token.
|
||||
//! Usage: screen_configs <disc-root> <substring>
|
||||
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
|
||||
fn main(){
|
||||
let a:Vec<String>=std::env::args().collect();
|
||||
let needle=a.get(2).cloned().unwrap_or_else(||"CHALLENGE".into());
|
||||
let arc=PakArchive::open(format!("{}/dat/tables.pak",a[1])).unwrap();
|
||||
for (i,e) in arc.entries().iter().enumerate(){
|
||||
let Ok(b)=arc.read(e) else{continue};
|
||||
let Ok(o)=IdxdObject::parse(&b) else{continue};
|
||||
let t=o.tokens();
|
||||
if !t.iter().any(|s|s.to_lowercase().contains(&needle.to_lowercase())){continue}
|
||||
let path=t.iter().find(|s|s.contains(".pak+")).cloned().unwrap_or_default();
|
||||
if !path.is_empty() && !path.contains("+eng"){continue}
|
||||
println!("\n=== entry #{i} schema {:08x} [{path}] {} tokens ===",o.schema_hash,t.len());
|
||||
for (j,tok) in t.iter().enumerate(){println!(" {j:3} {tok}");}
|
||||
}
|
||||
}
|
||||
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
@@ -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(" ") }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,10 @@ pub mod localization;
|
||||
// Whole-ship assembly from XBG7 part families (capital ships as split parts).
|
||||
pub mod ship;
|
||||
|
||||
/// The runtime layout of a unit / vessel definition object, read out of the
|
||||
/// title's loader and verified against a live mission (see the module docs).
|
||||
pub mod unit_layout;
|
||||
|
||||
// Exact capital-ship placement from a runtime F10 ship-capture (ground truth).
|
||||
pub mod ship_capture;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
||||
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(frame) =
|
||||
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx)
|
||||
{
|
||||
// An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
|
||||
// compare `"01" == ""` and fall through, so the bridge was silently
|
||||
// dropped from the assembly while the game draws it — caught by a runtime
|
||||
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
|
||||
// With no index to match on, take the lowest-numbered frame of the
|
||||
// category; an indexed part still matches its own index only.
|
||||
let mut cands: Vec<&ScenePart> = frames
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.resource.contains(gncat)
|
||||
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
|
||||
if let Some(frame) = cands.first().copied() {
|
||||
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
|
||||
placed_res.insert(part.clone());
|
||||
}
|
||||
@@ -603,18 +615,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Extras: the direction this test could not previously fail in. ──
|
||||
// The loop above walks the CAPTURE's parts and looks each up in ours, so
|
||||
// a static placement with no counterpart was invisible to it — which is
|
||||
// how a resource decoded 100x too large (`e303_wep_01`, 2026-08-12) sat
|
||||
// here unnoticed. Pin the set instead: the capture legitimately misses
|
||||
// repeated instances of a shared resource (vbase dedup), so `e303_wep_01`
|
||||
// is expected; anything else appearing only in the static assembly is a
|
||||
// regression.
|
||||
let captured: std::collections::BTreeSet<&str> =
|
||||
cap.parts.iter().map(|p| p.part.as_str()).collect();
|
||||
let extra: std::collections::BTreeSet<&str> = placed
|
||||
.iter()
|
||||
.map(|p| p.resource.as_str())
|
||||
.filter(|r| !captured.contains(r))
|
||||
.collect();
|
||||
let allowed: std::collections::BTreeSet<&str> = ["e303_wep_01"].into_iter().collect();
|
||||
assert_eq!(
|
||||
extra, allowed,
|
||||
"static placements with no counterpart in the runtime capture"
|
||||
);
|
||||
|
||||
// Multi-instance coverage the capture couldn't see (vbase dedup).
|
||||
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
|
||||
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
|
||||
assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
|
||||
// The mirrored starboard hull reflects (det < 0), the port one doesn't.
|
||||
// NEITHER hull reflects: the twins' geometry is mirrored on the disc,
|
||||
// so both placements are proper rotations. This flipped on 2026-08-12 —
|
||||
// while both twins decoded to one buffer, `apply_twin_mirrors` had to
|
||||
// synthesise the reflection here; a runtime capture showed the container
|
||||
// holds both halves, and distinct anchor assignment now hands each twin
|
||||
// its own (see docs/re/structures/xbg7-mesh.md).
|
||||
let det = |m: &[[f32; 3]; 3]| {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
|
||||
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
};
|
||||
let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
|
||||
assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored");
|
||||
assert!(det(&one("e106_bdy_02").m) > 0.0, "starboard hull plain (mirror is in the data)");
|
||||
assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,33 @@ pub struct CapturedDraw {
|
||||
/// 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
|
||||
@@ -100,13 +127,16 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
|
||||
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;
|
||||
}
|
||||
@@ -115,18 +145,47 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
|
||||
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 });
|
||||
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, &consts, &mut out);
|
||||
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") {
|
||||
@@ -147,7 +206,7 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(vbase, vcount, &mut pos, &consts, &mut out);
|
||||
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -183,6 +242,41 @@ pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
|
||||
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
|
||||
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
|
||||
/// collapses to a single entry here — fine, capital ships are the target.)
|
||||
/// Split a capture into blocks that are guaranteed to share one camera.
|
||||
///
|
||||
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
|
||||
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
|
||||
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
|
||||
/// camera **only when both draws come from the same frame** — mix frames and
|
||||
/// the residual is the camera's motion between them. With a static ship and a
|
||||
/// static camera that error is invisible, which is how the single validated
|
||||
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
|
||||
/// units, and two frames of the same ship then disagree about where its parts
|
||||
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
|
||||
/// `[0, 0, -1090]`).
|
||||
///
|
||||
/// The split rule is the recurrence itself: a vertex buffer that appears again
|
||||
/// starts a new block. Splitting too eagerly is harmless (a block is still one
|
||||
/// camera, just with fewer parts in it) and it separates two instances of the
|
||||
/// same class as a bonus; failing to split is what corrupts the result.
|
||||
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
|
||||
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
|
||||
let mut cur: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for d in draws {
|
||||
if !seen.insert(d.vbase) {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
seen.clear();
|
||||
seen.insert(d.vbase);
|
||||
}
|
||||
cur.push(d.clone());
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
@@ -207,7 +301,9 @@ pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
||||
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
|
||||
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return };
|
||||
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
|
||||
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos });
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -682,10 +778,17 @@ mod tests {
|
||||
assert_eq!(e106.reference, "e106_bdy_04");
|
||||
assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed");
|
||||
let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap();
|
||||
// Port/starboard hull pair: X = ∓264, the starboard copy mirrored.
|
||||
// Port/starboard hull pair: X = ∓264, **both plain**. The mirror is
|
||||
// baked into the disc data, not into the placement: a runtime capture
|
||||
// shows the container carrying two 119-vertex buffers whose contents are
|
||||
// exact X-reflections, each drawn from its own address (see
|
||||
// docs/re/structures/xbg7-mesh.md). Until 2026-08-12 both twins decoded
|
||||
// to ONE buffer and this row carried diag(-1,1,1) to compensate; with
|
||||
// distinct anchor assignment they decode to their own, and re-emitting
|
||||
// from the capture produces identity here.
|
||||
assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1);
|
||||
assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1);
|
||||
assert_eq!(get("e106_bdy_02").m[0][0], -1.0);
|
||||
assert_eq!(get("e106_bdy_02").m[0][0], 1.0);
|
||||
assert_eq!(get("e106_bdy_01").m[0][0], 1.0);
|
||||
// Bridge: centreline, above and aft of the hull reference.
|
||||
let brg = get("e106_brg_01");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
@@ -3677,6 +3677,7 @@ fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh {
|
||||
uvs: Vec::new(),
|
||||
indices,
|
||||
name: Some("exhaust".to_string()),
|
||||
vbuf_offset: None, // procedural, not read from a container
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,43 @@ unknown, what evidence exists, and what the first step would be. Move an item in
|
||||
|
||||
## Capital ships assemble wrong in the viewer
|
||||
|
||||
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
|
||||
**Reported:** 2026-07-30, by the user. **Status:** ✅ **format-side cause found and
|
||||
fixed 2026-08-12** — see below for the 2026-08-10 diagnosis this supersedes.
|
||||
|
||||
The remaining format-side defect this entry pointed at (a shared turret decoding
|
||||
~100× too large in some containers) was real and is gone. `e303_wep_01` decoded
|
||||
as a 1600×2100×4800 block in `Stage_S02`, swallowing the `e106` hull; requiring an
|
||||
index buffer to cover its vertex pool **exactly** moved it to the block every
|
||||
other container agrees on, and it now decodes 49×23×42 everywhere and places at
|
||||
±179 on the hull. The same fix repaired `e106_bdy_03` (a 600×1600×998 slab) and
|
||||
moved 29 anchors disc-wide, 22 of which had been carrying **another resource's
|
||||
geometry under their own name**. See
|
||||
[`structures/xbg7-mesh.md`](structures/xbg7-mesh.md).
|
||||
|
||||
Two things are worth carrying forward rather than closing:
|
||||
- the assembler was **audited and exonerated** — every composite node carries
|
||||
scale 1.0 and an orthonormal matrix, so nothing on that side inflates a part;
|
||||
- **no metric caught this.** Coverage, cross-container consistency, the capture
|
||||
oracle and the twin invariant were all green while a 1 600-unit slab sat through
|
||||
the ship. It was found by *rendering the ship and looking at it*, and the
|
||||
numeric screens written afterwards to automate that check both failed.
|
||||
|
||||
The 2026-08-10 diagnosis follows, and its viewer-side pointers still stand.
|
||||
|
||||
**Status (2026-08-10):** 🔎 **the format layer is exonerated.** Runtime captures of three classes (`f105`, `e105`,
|
||||
`e106`) at controlled range reproduce `assemble_ship` to ≤0.43 units in translation
|
||||
and to 0.000 in rotation for every part that does not move; see
|
||||
[`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md)
|
||||
§4. So look at **the viewer**: first that it passes `include_external = true`
|
||||
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
|
||||
5 parts instead of 11), then its own transform stack.
|
||||
|
||||
One real format-side bug was found on the way and is **fixed**: index-less parts
|
||||
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
|
||||
— `e102`, `e104`, `e105` across Stages 02–29 — assembled without a bridge. The other
|
||||
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
|
||||
|
||||
The original report and its reasoning follow.
|
||||
|
||||
The reborn viewer builds capital ships from the split XBG7 parts via
|
||||
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
|
||||
@@ -44,3 +80,225 @@ class cannot hide behind `e106` passing.
|
||||
handedness, node-instance recursion) is not re-breaking a correct assembly — compare
|
||||
the viewer's placement against `assemble_ship`'s output directly before blaming the
|
||||
format layer.
|
||||
|
||||
---
|
||||
|
||||
## Viewer: `include_external` is already on — that hypothesis is dead
|
||||
|
||||
**Checked 2026-08-11.** The item above names "first that it passes
|
||||
`include_external = true` (`iso_loader.rs:4012`)" as the cheap first step. It
|
||||
does: `ShipBrowser::show_external` defaults to `true`
|
||||
(`iso_loader.rs:643`), the checkbox reads it (`ui.rs:1593`) and it is threaded
|
||||
through `RequestShipRender` → `build_ship_model` → `assemble_ship` unchanged
|
||||
(`ui.rs:1689`, `iso_loader.rs:4012`). So a ship rendered by the viewer is the
|
||||
full external assembly, not the bare hull.
|
||||
|
||||
The viewer also does not have a transform stack of its own to blame: it bakes
|
||||
`ScenePart::apply` straight into the vertices and rotates normals by the same
|
||||
`p.m` (`iso_loader.rs:4030-4062`), so its placement is `assemble_ship`'s output
|
||||
by construction. What remains unexcluded, in order of cheapness: the mirror
|
||||
handling (`det < 0` reverses triangle winding only — a reflected part keeps its
|
||||
reflected geometry), `Xbg7Model::models_named` resolving the wrong sub-model when
|
||||
a resource name repeats, and the exhaust cones. **Next step is a visual**: the
|
||||
diagnosis has run out of things it can settle by reading, so the viewer needs to
|
||||
be run against a known-good class (`e106`) and its render compared with
|
||||
`ship_render`'s.
|
||||
|
||||
---
|
||||
|
||||
## Viewer: the duplicate-resource-name hypothesis is dead too
|
||||
|
||||
**Checked 2026-08-11.** The diagnosis above left three candidates for why capital
|
||||
ships assemble wrong in the viewer: mirror handling, `Xbg7Model::models_named`
|
||||
resolving the wrong sub-model when a resource name repeats, and the exhaust
|
||||
cones. The second is now **refuted**, and comprehensively.
|
||||
|
||||
`build_ship_model` resolves each placement with
|
||||
`base.iter().find(|m| m.name == p.resource)` (`iso_loader.rs:4041`) — first match
|
||||
wins — so a repeated resource name inside a container would silently draw the
|
||||
wrong geometry. It cannot happen: decoding **every** XBG7 resource in **all 22
|
||||
stage containers** gives **4 603 resources and zero repeated names**.
|
||||
|
||||
```
|
||||
Stage_S01 62/62 Stage_S07 323/323 Stage_S13 290/290 Stage_S25 351/351
|
||||
Stage_S02 304/304 Stage_S08 388/388 Stage_S14 22/22 Stage_S26 318/318
|
||||
Stage_S03 214/214 Stage_S09 316/316 Stage_S15 386/386 Stage_S27 321/321
|
||||
Stage_S04 179/179 Stage_S10 7/7 Stage_S16 65/65 Stage_S28 118/118
|
||||
Stage_S05 92/92 Stage_S11 157/157 Stage_S24 162/162 Stage_S29 386/386
|
||||
Stage_S06 266/266 Stage_S12 376/376
|
||||
```
|
||||
|
||||
Per-ship it is tighter still: `e106` wants 9 distinct names and decodes exactly
|
||||
9 models for 11 placements; `e105` 9 for 9; `f105` 5 for 6. Every placement
|
||||
resolves to the one model it names.
|
||||
|
||||
**So two of the three candidates are gone** (this one and `include_external`),
|
||||
leaving **mirror handling** and **the exhaust cones** — and the still-untried
|
||||
visual comparison, which remains the right next step.
|
||||
|
||||
---
|
||||
|
||||
## Viewer: mirror handling and the exhaust cones are cleared too — the static avenue is exhausted
|
||||
|
||||
**Checked 2026-08-11.** Both remaining candidates were tested across every ship
|
||||
on the disc, and neither shows the reported signature.
|
||||
|
||||
**Mirror handling.** The concern was that `ScenePart::apply` bakes `R·(S·v)+T`
|
||||
while the viewer takes its winding-flip decision from `det(m)` alone and rotates
|
||||
normals by `m` alone — both ignoring `s`. A mirror encoded as a *negative scale*
|
||||
would then reflect geometry without flipping winding, drawing the part
|
||||
inside-out. It never happens: across **1 485 assembled parts** in all 22
|
||||
containers there are **22 mirrored parts, every one with `det(m) < 0`**, and
|
||||
**zero** parts with a negative scale or a non-uniform one. `apply_twin_mirrors`
|
||||
writes the reflection into `m` (negating its X column), so the viewer's flip
|
||||
always fires, and ignoring `s` for normals is harmless because `s` is always
|
||||
uniform.
|
||||
|
||||
**Exhaust cones.** These are the one piece of geometry the viewer *invents* — a
|
||||
cone at each `GN_Jet`/`GN_SJet` frame, because the real engine geometry is
|
||||
recessed and the game draws FX there instead. If they landed wrongly they would
|
||||
read exactly as "a part in the wrong place". Across **335 assembled ships, 192 of
|
||||
which have exhaust frames, not one cone sits outside its hull's bounding box**
|
||||
(tolerance 10 % of the axis span).
|
||||
|
||||
**Caveat, stated rather than glossed:** "inside the hull box" does not prove a
|
||||
cone is *right* — orientation and size are untested, and a cone could be wrong
|
||||
while still inside. What it does rule out is the reported symptom for that part.
|
||||
|
||||
So every mechanism this diagnosis proposed is now eliminated: `include_external`,
|
||||
duplicate resource names, mirror handling, and cones-in-the-wrong-place. The
|
||||
format and assembly layers pass every static test available, and **the visual
|
||||
comparison is no longer merely the next step — it is the only remaining one.**
|
||||
Render `e106` in the viewer beside `ship_render`'s output of the same
|
||||
`assemble_ship` result; if they agree, the bug is in neither and the original
|
||||
report needs re-grounding against a specific ship and a specific expectation.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ DIAGNOSED 2026-08-12 — a mis-decode; the locality fix was written, then withdrawn
|
||||
|
||||
> Resolution at the end of this entry. Kept in full because the two wrong turns
|
||||
> along the way (a "stray volume", then "monotonic anchoring") are the useful part.
|
||||
|
||||
## ⚠️ The format layer is NOT exonerated — but the cause is a MIS-DECODE, not a stray volume
|
||||
|
||||
**Found 2026-08-11 by finally doing the visual**, which the notes above kept
|
||||
naming as the next step. It overturns their conclusion.
|
||||
|
||||
Render `e106` from the static assembly and from the baked runtime capture and
|
||||
compare — `ship_render` does both:
|
||||
|
||||
| | placements | parts |
|
||||
|---|---|---|
|
||||
| runtime capture (ground truth) | **8** | `bdy_01…04`, `brg_01`, `eng_01`, `eng_02`, `wep_02_01` |
|
||||
| `assemble_ship(--static)` | **11** | the same 8, **plus `e303_wep_01` ×2** and a second `e106_eng_01` |
|
||||
|
||||
The render makes it obvious: the destroyer sits inside a white slab that dwarfs
|
||||
it ([capture](captures/e106-static-assembly-volume-bug.png)). That slab is
|
||||
`e303_wep_01`, and its own geometry is:
|
||||
|
||||
```
|
||||
e303_wep_01 172 verts, 110 tris bounds X[-1000, 600] Y[-1050, 1050] Z[-2400, 2400] 1600 x 2100 x 4800
|
||||
e106_wep_02_01 1002 verts, 772 tris 269 x 179 x 417 ← what a real e106 turret looks like
|
||||
e106_brg_01 202 verts, 202 tris 105 x 76 x 305
|
||||
```
|
||||
|
||||
**110 triangles, perfectly round axis-aligned bounds, and bigger than the ship it
|
||||
is mounted on.**
|
||||
|
||||
### CORRECTION (same day, one iteration later): it is not a volume — it is a bad decode
|
||||
|
||||
The first reading of this was that `e303_wep_01` is a collision/trigger volume
|
||||
the assembler wrongly draws. **That is wrong, and the evidence that settles it is
|
||||
decoding the same resource from every container that holds it:**
|
||||
|
||||
```
|
||||
Stage_S01 172 verts 110 tris X[-24.5, 24.5] Y[0.0, 23.4] Z[-20.8, 20.8] ← 49 × 23 × 42, a turret
|
||||
Stage_S02 172 verts 110 tris X[-1000, 600] Y[±1050] Z[±2400] ← 1600 × 2100 × 4800
|
||||
Stage_S03… 172 verts 110 tris 49 × 23 × 42 (correct)
|
||||
Stage_S08 … 1600 × 2100 × 4800
|
||||
Stage_S26 … 1600 × 2100 × 4800
|
||||
```
|
||||
|
||||
Same resource, same vertex and triangle count, **decoding correctly in eleven
|
||||
containers and wrongly in exactly three** (`Stage_S02`, `S08`, `S26`). So:
|
||||
|
||||
- the **placement is legitimate** — `e303_wep_01` is a small shared turret,
|
||||
cross-mounted on `e101` and `e106`, and at its true size it is unremarkable;
|
||||
- the original author's explanation of the capture's silence (**vbase dedup**)
|
||||
stands, and my "dedup would show one, not zero" objection does not survive:
|
||||
with the correct decode the turret is small, ordinary geometry;
|
||||
- **the defect is in the mesh decoder**, which resolved this resource's vertex
|
||||
data differently in three containers.
|
||||
|
||||
The render and the symptom are real; the cause named in the first version of this
|
||||
entry was not.
|
||||
|
||||
### The part that matters more than this one resource
|
||||
|
||||
**The decoder can produce wrong geometry without declining.** The
|
||||
[XBG7 audit](structures/xbg7-mesh.md) counted 814 resources it *refuses* — a
|
||||
visible, honest failure. This is the other kind: `e303_wep_01` decodes "fine" in
|
||||
`Stage_S02` and is silently 100× too large. Screening for the signature (bounds
|
||||
that are exact multiples of 50 with a span over 1000) flags 22–32 models in each
|
||||
of `S02`, `S03`, `S08`, `S26`, `S27` — **but that screen also catches legitimate
|
||||
`e_rou_*` composite proxies**, so it is a candidate list, not a count of bugs.
|
||||
|
||||
**Next:** diff the anchor scan's chosen `vb0` for `e303_wep_01` between
|
||||
`Stage_S01` (correct) and `Stage_S02` (wrong) — same resource, two outcomes, so
|
||||
the divergence is directly observable — then use whatever distinguishes them to
|
||||
add a post-decode sanity check, so a silent 100× mis-decode becomes a decline.
|
||||
|
||||
### Why this was missed
|
||||
|
||||
`assemble_ship` treats **every** `rou_*` node in the composite as a drawable
|
||||
part, and the doc comment states the cross-id mount as intended behaviour —
|
||||
`"INCLUDING repeated instances and cross-id turret mounts (rou_e303_wep_01_root
|
||||
×2 on the e106 hull)"` — with
|
||||
`ship::tests::static_assembly_matches_runtime_capture` asserting
|
||||
`count("e303_wep_01") == 2`. The absence from the capture was explained away as
|
||||
vbase dedup, but **dedup would show one instance, not zero**.
|
||||
|
||||
The test cannot catch it either: it walks the capture's parts and looks each up
|
||||
in the static output, so **extra** static placements are invisible to it. That is
|
||||
the same shape of gap as the earlier `include_external` hypothesis — a test that
|
||||
can only fail one way.
|
||||
|
||||
### Scope, stated carefully
|
||||
|
||||
Sweeping all 335 assembled ships for the signature *ship-scale span with under
|
||||
400 triangles* flags **20 ships and 58 placements** over 28 distinct resources
|
||||
(`e005_ant_*`, `f001_ant_*`, `f002_bdy_*`, `f301_barrel`, `f303_body`,
|
||||
`e303_wep_01`, …). **Only the `e106`/`e303_wep_01` case is proven** — by render,
|
||||
by capture absence, and by geometry. Some of the others may be legitimately large
|
||||
low-poly parts, and each needs the same three checks before being called a bug.
|
||||
|
||||
**Still true, and independent of the correction above:**
|
||||
`static_assembly_matches_runtime_capture` walks the capture's parts and looks each
|
||||
up in the static output, so **extra static placements can never fail it**. That is
|
||||
worth fixing regardless — it is the same one-way-test shape as the earlier
|
||||
`include_external` hypothesis.
|
||||
|
||||
Also unchanged: only **two** cross-id placements exist fleet-wide (`e303_wep_01`
|
||||
on `e101` ×24 and `e106` ×36, across 335 assembled ships), so cross-id mounting is
|
||||
a narrow, real feature rather than a systemic guess.
|
||||
|
||||
---
|
||||
|
||||
## Resolution (2026-08-12)
|
||||
|
||||
`anchor_pool_mesh` took the **first** candidate in file order from a
|
||||
container-global scan, so a resource could be handed another resource's block
|
||||
whenever both shared `(stride, vertex count, index count)`. Fixed by anchoring
|
||||
each resource near its **descriptor neighbours** (two-pass: learn, then re-anchor).
|
||||
|
||||
- it took inconsistency **125 → 51** with coverage unchanged, and made `e106`
|
||||
render correctly ([after](captures/e106-static-assembly-fixed.png))
|
||||
- **but it flipped the `e106` twin-mirror decision**, which
|
||||
`static_assembly_matches_runtime_capture` (ISO-gated, so it skips in a plain
|
||||
`cargo test`) catches against the runtime capture — so it was **reverted**
|
||||
- the user-reported "capital ships assemble wrong" is therefore **diagnosed, not
|
||||
yet fixed**; see [xbg7](structures/xbg7-mesh.md) for what the real fix needs
|
||||
|
||||
Still open from this entry: `static_assembly_matches_runtime_capture` walks only
|
||||
the capture's parts, so **extra** static placements still cannot fail it.
|
||||
|
||||
@@ -13,33 +13,37 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
|
||||
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
|
||||
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
|
||||
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) |
|
||||
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)); ~15% variants deferred |
|
||||
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
|
||||
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
||||
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
||||
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
|
||||
| T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
|
||||
| RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat` → `pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** |
|
||||
| LSTA sprite list | ✅ | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both** — `count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix |
|
||||
| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |
|
||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
||||
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
|
||||
| XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **582 → 1** (the grouped path had the same bug; and two resources were anchored on a degenerate lookalike earlier in file order), **590 of 8 850** sub-meshes re-wired with 10 vertex anchors moved, resources decoded unchanged at 6 209. Cross-container minority decodes 89 → 96 — *because* the decoder improved: `_rou_f402_dead` now has a majority (32×25×8) so its seven wrong copies are named instead of hidden. One dirty run remains, blocked by distinct assignment on a 24-vertex box. **Then the descriptor gave up its last structural secret**: it declares a vertex layout **per sub-mesh** (`n201_01` → strides 24/24/24/**28**, capture-confirmed), and grouped selection must prefer the candidate explaining the **whole** pool rather than the first whose pivot validates — together they take never-decoding resources **85 → 47** (**6 247 / 6 294 = 99.25 %** decode), put `n201_01` on all four capture-proven offsets and raise the stage-05 capture oracle to **128/128**. The residual 47 is 30 pose/proxy composites (0.010-unit marker boxes), 6 `.DAT` particle composites, 8 damage/LOD variants and 3 props — not a threshold away |
|
||||
| Capital-ship part placement | ✅ | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot fail it |
|
||||
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
|
||||
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — [values](captures/unit-runtime-fields.csv) |
|
||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
|
||||
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
|
||||
| Arsenal develop economy | ✅/❔ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000–350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed – / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |
|
||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. The **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
|
||||
| Save file (`savedata`) | ✅/❔ | [savegame-format](structures/savegame-format.md) + [`tools/re-capture/savegame.py`](../../tools/re-capture/savegame.py) | `GDHA` container, zlib payload, chunk stream (`GDAA` / phase name / `GHAD` 122 B progress block / 16×20 B slot table / trailer). **Container and layout read off the title's own serializer `0x822C00E8` and verified by a byte-identical round-trip**; the whole save is 545 B. Payload offsets are also the live save object's offsets (`save+8` GHAD, `save+136` slots). A second save made in-game names **Points** (+24), **flight time in ms** (+4) and **clear ratio %** (+8) off the game's own Details panel; the payload is a **pure function of game state** (same state saved twice = byte-identical, only the header FILETIME and its uninitialised pointer padding move), and the 16 `SHAB` records are **not** the UI's 20 save slots. Difficulty vs stage is undecided — three fields hold 2. **A third save, taken after developing exactly one Arsenal weapon** (Light Machine Gun MG I, 4000 P), moves exactly three things: `+24` Points 4101→101 (which **separates it from `+28`**, that did not move), `+8` clear ratio 5→6 (so the ratio counts *collection*, not only stages), and two entries of the 54-byte blob — `2→4` for the item bought and `0→2` for the successor the game announced as newly developable, giving the blob its alphabet ✅ *0 locked / 2 developable / 4 developed* (only the `4`s are stored — `2` is re-derived at load). **Saves can also be written back**: three derived header fields (length at `+0x30`, payload length at `+0x8c`, `adler32` at `+0x8e`) are all that stand between a parse and a hand-written save that the title loads, and [`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) re-wraps a real save byte-identically. That turned the blob's index space from blocked-on-story-progress into four probe saves — see the [economy note](arsenal-develop-economy.md) |
|
||||
|
||||
## Runtime / dynamic-capture technique
|
||||
|
||||
| Technique | Conf. | Spec | Notes |
|
||||
|-----------|-------|------|-------|
|
||||
| Scripted input without a virtual controller | ✅ | [`tools/re-capture/pad.py`](../../tools/re-capture/pad.py) + Canary `--hid=file` | The old `vgamepad` path created its device through `/dev/uinput`, which is **not namespaced** — a pad made inside the container registers with the HOST's input stack, so every scripted press leaked to the desktop. Canary now carries a header-only driver that reads pad state from a **text file** (`--hid=file --pad_file=…`): no kernel device, nothing leaves the container, and analogue values are exact. ⚠️ The trap: 360 menus poll **`XamInputGetKeystrokeEx`**, not `GetState` — with `GetKeystroke` stubbed the pad looks completely dead on a title screen while its own log shows the press arriving. Implemented edge-triggered, no auto-repeat (scripted input wants one event per press). Proven end to end: boot → title → main menu → EXTRAS from the file alone |
|
||||
| Live guest-memory write | ✅ | [`tools/re-capture/gpoke.py`](../../tools/re-capture/gpoke.py) | Write companion to `gmem.py`, same guest-VA → `/dev/shm` map; prints before/after per word. Used to confirm the challenge gate's cleared-stage mask on the running game |
|
||||
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
|
||||
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
|
||||
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
|
||||
| Mission / escort state, every entity's hull | ✅ | [`tools/re-capture/mission_state.py`](../../tools/re-capture/mission_state.py) · [escort state](mission-escort-state.md) | `hull = position + 0x154` is a property of the **entity class**, not of the player object: at t=0 it equals each entity's own definition `HP` across 7 classes and 5 distinct HP values (turret 100, fighter 500, destroyer 10000, cruiser 30000, **ACROPOLIS 25000**), falls under fire (780 damage events in 240 s), goes negative at death, and the object then leaves the heap. So an escort objective is scoreable live — `UN_f101_TCAF_Acropolis` measured at 25000 → 23038 over 240 s, attack starting only at t≈170 s. `REMAINING OB` counts objectives, not hostiles (012 on the HUD vs 118 live ADAN); its address is still ❔ |
|
||||
| In-flight control mapping | ✅/🟡 | [`tools/re-capture/fire_probe.sh`](../../tools/re-capture/fire_probe.sh) · [controls](flight-controls-runtime.md) | Measured by holding each pad input and photographing the HUD ammo counters: **`RB` = nose gun** (6000→5956 in 4 s, ~11 rounds/s, HEAT rises), **`Y` = main mount** (missiles, 300→299), d-pad = **tactical map** overlay, nothing else moves a counter. No target-cycle input exists — the `TARGET` marker is present with nothing pressed, so targeting is automatic and a missile lock is **time-on-target**. That, not target choice or ballistics, is what caps lethality at 2 kills per 98 missiles |
|
||||
| Throttle → speed law | ✅/🟡 | [flight-speed-law](flight-speed-law.md) | **The throttle selects a TARGET SPEED**, it does not add thrust: no input settles at ~420 (`CruisingVelocity` 350), `RT` at ~1 530 (`MaximumVelocity` 1200), `LT` at ~125 (`MinimumVelocity` 100), and releasing either returns to cruise. `Acceleration`/`Deceleration` govern the convergence rate (measured ~440–560 units/s² against 500/600). 🟡 measured world speeds run ≈1.2–1.3× the definition numbers while the HUD shows the definition value exactly, so world units are a constant (~1.25) multiple of the definition's velocity unit |
|
||||
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
|
||||
|
||||
## Functions / code paths
|
||||
|
||||
_None documented yet — populated during the dynamic-RE phase._
|
||||
|
||||
| Function | Conf. | Reimpl. | Summary |
|
||||
|----------|-------|---------|---------|
|
||||
| — | — | — | — |
|
||||
| Achievement table + XAM read path | ✅/🟡 | [achievements](structures/achievements.md) | The XEX's `XACH` resource (`.pe` `0x8FBCBC`, 36-byte records) defines **24 achievements summing to 1000G** — the retail total, which self-checks the stride and field offsets. `GamePart_Debriefing` (`0x8218CF38`–`0x82191B18`) does two things: it walks the on-disc `ACHIEVEMENTS_REQUIREMENTS` list (`tables.pak` #16, entries `ACHIEVEMENT01…24`, in achievement-id order — its `ShootDownAircrafts`/`ShootDownShips`/`ShootDownWeight`/`GetAllWeapons`/`GetAllAchievements` types line up with ids 19–24 exactly as `XACH` names them), evaluating each and setting a bit; **and it enumerates `XACHIEVEMENT_DETAILS` from XAM** — 36-byte records confirmed by the `0x38E38E39`+`srawi 3` divide-by-36, `dwId` at `+0`, `dwFlags & 0x00020000` (`…_ACHIEVED`) at `+32`, behind a waited-then-closed async handle. **So earned state comes from the console profile, not the 545-byte save** — and the masks it builds are `1 << dwId`, i.e. **bit = the 1-based id**. **The challenge missions do NOT gate on this** — an earlier claim here, refuted by finding `+80`'s sole writer: it is `GamePart_StageClear` setting `1 << stage`, so that word is a *cleared-stage* mask and the shared "24" was a coincidence. `GetAllAchievements`/`GetAllWeapons` are requirement **types**, not debug cheats |
|
||||
| Challenge-mission gate + stage-config switch | ✅ | [challenge-mission-gate](challenge-mission-gate.md) | **`GamePart_ChallengeMission` gates each of the six challenge missions on a CLEARED-STAGE bit**: `REQUIREMENT` absent or `"Always"` → available, else `n = atoi(v)` and it tests bit `n` of singleton `+80` (`n < 24`) or bit `n-24` of `+1956` (`n ≥ 24`) — which is the disc's own stage numbering (story 1–16 and tutorial 18–23 below 24, challenge 24–29 above). Word A's **sole writer** is `0x821C1820` in `GamePart_StageClear`, doing `1 << (this+84)` where `this+84` is the stage number (it also indexes a 20-byte per-stage record array and the debriefing `STAGE` sprite list). So `TimeAttack` needs **stage 16** — the last story mission — and the rest chain off challenge stages 25–29. The six-mission table is on disc in `tables.pak` (schema `54a10697`). Separately, the stage loader picks its config section from a **mission-kind field at `object+144`**: `3` → `EXTRA`, `5`/`6` → `CHALLENGE`, anything else → `FILE`; two further sites treat `{3,5,6}` as one class. Constructed as `0` (`sub_821783D8`) and only ever *cleared* inside the class, so the kind comes from the launching GamePart, **not** from the stage number — which is a mechanism (🟡, unproven) for why patching the save's stage field to a challenge stage kills the load. Same note carries the **GamePart id table** (`0x820A1630`, 29 ids, `GP_CHALLENGE` = 26, cross-checked against the image's own `RegisterToFactory<26, …>` text) and the disc's **three stage families** — `S01`–`S16` story, `S18`–`S23` tutorial, `S24`–`S29` challenge, plus `Test`, matching `weapon.tbl`'s 16 + 6 + 6 key set exactly. `GP_CHALLENGE.pak` holds **0 IDXD objects** — it is the menu screen; challenge missions reuse `GP_MAIN_GAME_E.pak`'s stage records |
|
||||
|
||||
69
docs/re/SESSION-2026-08-11.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Session log — 2026-08-11 (autonomous run)
|
||||
|
||||
What changed today, in the order a reviewer would want it, with the one item
|
||||
that needs **your decision** called out first.
|
||||
|
||||
## Needs a decision from you
|
||||
|
||||
**Editing save slot 01.** Save files can now be written back
|
||||
([savegame_edit.py](../../tools/re-capture/savegame_edit.py) re-wraps a real save
|
||||
**byte-identically**), and hand-written saves load. That unblocked several
|
||||
questions by probing throwaway slots. But the **title-menu screens read the
|
||||
auto-save (slot 01)**, so the remaining save-field questions (the difficulty
|
||||
enum, `SHAB.b`) need slot 01 itself edited — the only save with real progress.
|
||||
Not done. Recoverable (full content backup + slot 01's bytes archived), but it is
|
||||
your call. Everything else below was done in throwaway slots.
|
||||
|
||||
## Findings, by area
|
||||
|
||||
### Save format
|
||||
- **The develop differential**: buying one Arsenal weapon moves exactly three
|
||||
fields — Points `+24` (which **separates it from `+28`**), clear ratio `+8`
|
||||
(so the ratio counts *collection*, not stages), and two entries of the 54-byte
|
||||
blob. Blob alphabet: `0` locked / `2` developable / `4` owned; only the `4`s
|
||||
are stored, `2` is re-derived at load.
|
||||
- **The blob's index space is solved** — it follows `strings.tbl`'s item order,
|
||||
**cut items included**, pinned by four probe saves and closing exactly at 53.
|
||||
- **`SHAB` is the per-stage record table**: record 0 = Stage 01, and `c` is its
|
||||
best clear time in ms (`324773` = the `05:24.77` MISSION SELECT shows).
|
||||
- **Refuted**: every GHAD scalar as stage/difficulty (16 elements probed), and
|
||||
"stage = filled `SHAB` count + 1".
|
||||
|
||||
### Formats
|
||||
- **T8aD 96 % → 100 %** of the disc: a surface is a list of **sub-rectangles**
|
||||
(`dst X, dst Y, w, h` per rectangle), not a 256 grid. The "~15 % variants" were
|
||||
a wrong model.
|
||||
- **LSTA**: the count was never unreliable — it counts **sprites *and* `PRMD`
|
||||
primitives** (64/64 exact).
|
||||
- **RATC**: "one level deep" is not a limitation; nested records are **leaves
|
||||
that reference siblings by name** (10 144/10 148 resolve).
|
||||
- **UI screens are fully reconstructible from their bundle** — element list +
|
||||
order (declaration table, with a **parent index**) and placement/animation
|
||||
(keyframe region). **Validated against the running game to ±2 px.**
|
||||
- **Movie ↔ subtitle ↔ voice link solved statically** (101 movies) — `INDEX` had
|
||||
it filed as needing the running game.
|
||||
- **XPR2 channel order confirmed** against the game (hue 23.3° vs 9.3°).
|
||||
|
||||
### Meshes — the user-reported viewer bug
|
||||
- Root-caused, **after one wrong turn I corrected in place**: the giant slab on
|
||||
`e106` is not a stray volume but an **XBG7 mis-decode** — the same resource is a
|
||||
49×23×42 turret in 11 containers and 1600×2100×4800 in 3.
|
||||
- **New detector**: a resource shared across containers must decode to the same
|
||||
bounds. **125 of 681** shared resources fail that check — a measured lower
|
||||
bound on *silent* mis-decodes (distinct from the 814 honest declines).
|
||||
|
||||
## Two disc defects found
|
||||
`SUBTITLE_S12B.tbl` (referenced by the movie table, present in **no** language)
|
||||
and `pmbase.t32` (placed by `pmbase.rat`, present **nowhere**). A reimplementation
|
||||
should skip a missing asset rather than treat it as a decode failure.
|
||||
|
||||
## Process notes worth keeping
|
||||
- Several "the data is inconsistent" comments meant **our model was incomplete**
|
||||
(T8aD variants, the LSTA count). Re-test such notes rather than routing around
|
||||
them.
|
||||
- A test that walks the *oracle's* items and looks each up in our output **can
|
||||
only fail one way** — it never sees extras. That shape hid the ship bug and,
|
||||
earlier, `include_external`.
|
||||
- When a diagnosis has eliminated every hypothesis and the last step is *a
|
||||
visual*, do the visual first. Three iterations went by before a 30-second
|
||||
render showed it.
|
||||
255
docs/re/arsenal-develop-economy.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# The Arsenal develop economy, and what the save's blob indexes
|
||||
|
||||
**2026-08-11.** The save file's 54-byte blob was shown to be per-item development
|
||||
state by [a runtime differential](structures/savegame-format.md#the-develop-differential-one-weapon-three-fields) —
|
||||
one weapon bought, two entries moved, `2 → 4` for the item and `0 → 2` for the
|
||||
successor. That named the *values*. This note names the *index space*, and in
|
||||
passing recovers the whole develop economy from the disc.
|
||||
|
||||
## Where the Arsenal gets its data
|
||||
|
||||
`dat/GP_HANGAR_ARSENAL.pak` carries one screen-config record per language whose
|
||||
fields point at the rest:
|
||||
|
||||
```
|
||||
PATH = dat\GP_HANGAR_ARSENAL.pak+eng\
|
||||
WEAPON = weapon.tbl STRINGS = strings.tbl
|
||||
WEAPON_CATEGORIES = 8 Detail_Window_Known / Detail_Window_Unknown
|
||||
ConditionToDevelop = 757,228 WeaponDesc = 734,502 Range = 891,73 …
|
||||
```
|
||||
|
||||
`WEAPON_CATEGORIES = 8` is the eight tabs the screen cycles with LB/RB (GUN,
|
||||
BEAM, LASER, MULTIPURPOSE MISSILE, ANTI-SHIP MISSILE, BOMB/ROCKET, CANNON,
|
||||
SPECIAL). The `…= x,y` fields are screen positions for that screen's labels —
|
||||
`ConditionToDevelop` is *where the conditions text is drawn*, not a condition.
|
||||
|
||||
### `eng\weapon.tbl` — the item table (IDXD, schema `0x03c244b5`, 66 records)
|
||||
|
||||
Its string pool opens with a stage list, then the literal key `WEAPONS`
|
||||
followed by the item ids **in table order**:
|
||||
|
||||
```
|
||||
No_Equipment, Machiene_Cannon_MG1, Machiene_Cannon_MG2H, Machiene_Cannon_MG3,
|
||||
Machiene_Cannon_MG5H, Broad_Sword_SG1, Swing_Sword_SG2H, Twin_Sword_SG3II,
|
||||
Frail_GP37, Stiletto_BG1, Dagger_BG2, Rapier_BG4H, Pilum_BP, … , Wep_83,
|
||||
Wep_84, Wep_85, NullWeapon_Arm1, NullWeapon_Arm2, NullWeapon_Arm3
|
||||
```
|
||||
|
||||
The ids are grouped by the eight categories in the same order the screen shows
|
||||
them — verified item-for-item against the running game (see the all-owned probe
|
||||
below). Note one **coincidence that cost time**: the run `No_Equipment … Wep_83`
|
||||
is also 54 entries, the save blob's length, which invites the conclusion that this
|
||||
list *is* the blob's index space. It is not — the two agree only up to index 32.
|
||||
The blob follows `strings.tbl`'s order, which carries items this list omits.
|
||||
|
||||
Each item then has a record: `Size, Weight, Range, Power, Speed, Stage,
|
||||
MissionObjective, Dependency, Points`. For the **first** record all of it reads
|
||||
straight out, and it matches the running game exactly:
|
||||
|
||||
| field | disc | in-game DATA SHEET for Light Machine Gun MG I |
|
||||
|---|---|---|
|
||||
| Range | `D` | `Range Class D` |
|
||||
| Power | `E` | `Damage Class E` |
|
||||
| Speed | `-` | `Speed Class –` |
|
||||
| Weight | `0.3` | `Weight Class Light` |
|
||||
| Points | `4000` | `4000 P` on the develop row |
|
||||
|
||||
**Caveat, and it is a real one:** IDXD string pools store each distinct string
|
||||
*once*, so from the second record onward every value that repeats an earlier
|
||||
string is absent from the token stream. Record 2 (MG2H) shows only its unique
|
||||
values — `0.4` and `3000` — and record 6 shows no number at all because its cost
|
||||
had already been seen. So `weapon.tbl`'s per-item fields cannot be read from the
|
||||
pool alone; they need the binary node/index region, which is still undecoded
|
||||
(see the module docs in `idxd.rs`). The prose table below is the practical
|
||||
substitute.
|
||||
|
||||
### `eng\strings.tbl` — the economy in prose (IXUD, UTF-16BE)
|
||||
|
||||
The same pak's string table holds, per item: the internal id, the display name,
|
||||
the description, and a **"Conditions to obtain …"** block that spells out the
|
||||
gate and the price:
|
||||
|
||||
> Conditions to obtain Light Swivel Machine Gun MG2H — Completed development of
|
||||
> the Light Machine Gun MG1 — Must spend 3000 points to develop
|
||||
|
||||
All 60 of them are extracted to
|
||||
[`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv)
|
||||
with the stage gate, the cost and the prerequisite split out. The shape of the
|
||||
economy:
|
||||
|
||||
- gates are **stage completion** (`Complete Stage 1 … 15`), **a predecessor
|
||||
item** (`Completed development of the …`), or **an ace kill** (`Shoot down the
|
||||
ace pilot in Stage 11/13/14`);
|
||||
- costs run 3 000 → 350 000 points, and **twenty items have no cost at all**
|
||||
(`Tomahawk Alpha Rail Gun — Complete Stage 1`). Those are still *bought*, for
|
||||
**0 P** — see the probe below, which caught one at `0 P` — which is why they
|
||||
read as `Developed` in a list the player never spent points in;
|
||||
- two items are anomalies worth flagging: `Adhesive Mine B2A` says "Must spend
|
||||
points to develop" with **no number**, and ten late entries (`Ballista GSH`,
|
||||
`Designator LH`, `Smoke Marker Launcher`, `Bumble UV`, `Ballista 24 Rocket
|
||||
Launcher`, `Thrush 220AM`, `Divider L3GP`, `Mace GP25H`, `Thor Gun System`,
|
||||
`Spitfire BX`) have a conditions *header* and no conditions — ❔ cut content or
|
||||
non-player weapons.
|
||||
|
||||
## How the first 32 entries were confirmed
|
||||
|
||||
Predicting the save state from the conditions and comparing against the actual
|
||||
blob is a strong test, because it is made **before** looking at the blob. At the
|
||||
captured state (Stage 1 complete, Stage 02 at standby, 4101 P) the conditions say
|
||||
exactly six items are developable — Light Machine Gun MG1, Broad Sword SG1,
|
||||
Dagger BG2, Pilum BP, Hound SMH, Dart 23 Rocket — and the blob's **six** `2`s sit
|
||||
at WEAPONS-order indices **1, 5, 10, 12, 27, 31**, which are those six items, in
|
||||
order. Nothing had to be fitted.
|
||||
|
||||
Indices 0–32 are the same item in either order, so this table stands unchanged
|
||||
under the final mapping.
|
||||
|
||||
| blob | value | item | confirmed by |
|
||||
|---|---|---|---|
|
||||
| 0 | 4 | `No_Equipment` | always available |
|
||||
| 1 | 2→4 | `Machiene_Cannon_MG1` | the differential: bought for 4000 P |
|
||||
| 2 | 0→2 | `Machiene_Cannon_MG2H` | the differential: announced as newly developable |
|
||||
| 5 | 2 | `Broad_Sword_SG1` | `BROAD SWORD SG1 5000P`, **below the fold** in the GUN list |
|
||||
| 9 | 4 | `Stiletto_BG1` | `Developed`; "Initially Mounted"; mounted as NOSE WEAPON |
|
||||
| 10 | 2 | `Dagger_BG2` | `DAGGER BG2 5000P` |
|
||||
| 12 | 2 | `Pilum_BP` | `PILUM BP 6000P` |
|
||||
| 21 | 4 | `Falcon_9AM` | `Developed`; "Initially Mounted"; mounted as MAIN WEAPON 1 |
|
||||
| 22 | 4 | `Buzzard_10AM` | `Developed`; free at Stage 1 |
|
||||
| 26 | 4 | `Terrier_SMH` | `Developed`; free at Stage 1 |
|
||||
| 27 | 2 | `Hound_SMH` | `HOUND SMH 10000P` |
|
||||
| 31 | 2 | `Dirt_23_Rocket` | `DART 23 ROCKET 3500P` ("Dart" is the localised "Dirt") |
|
||||
|
||||
Twelve concordances, no contradiction, over indices 0–31.
|
||||
|
||||
## The full index space ✅ — it is the **localisation** order, cut items included
|
||||
|
||||
Written after the probes below, which settled it. The blob is **not** indexed by
|
||||
`weapon.tbl`'s `WEAPONS` id list. It is indexed by the order the items appear in
|
||||
`strings.tbl` — the same order, *plus* the handful of items that exist only there:
|
||||
`Adhesive Mine B2A` (a "Conditions to obtain" block whose cost is missing) and the
|
||||
four late entries with a conditions *header* and no conditions. Those never appear
|
||||
in the Arsenal, but they **hold slots in the save**, and that is the whole reason
|
||||
the tail looked broken.
|
||||
|
||||
| blob | block | items (display names, in order) |
|
||||
|---|---|---|
|
||||
| 0 | — | `No_Equipment` |
|
||||
| 1–8 | GUN | Light Machine Gun MG1, Light Swivel MG2H, Heavy MG3, Heavy Swivel MG5H, Broad Sword SG1, Swing Sword SG2H, Twin Sword SG3II, Flail GP37 |
|
||||
| 9–16 | BEAM | **Stiletto BG1** ⌾, Dagger BG2, Rapier BG4H, Pilum BP, Multi Pike BP, Spear HBP, Long Spear HBP, Gray Head T76H |
|
||||
| 17–20 | LASER | Saber LG1, Twin Saber LG2H, Needle L1GP, Fire Arrow L2GP |
|
||||
| 21–25 | MPM | **Falcon 9AM** ⌾, Buzzard 10AM, Hawk 75AM, Condor 105AM, Eagle 120AM |
|
||||
| 26–30 | ASM | Terrier SMH, Hound SMH, Pointer SM, Piranha T53, White Shark T53R |
|
||||
| 31–38 | BOMB/ROCKET | **Dart 23 Rocket** ⌾, Arrow 27 Rocket, *Adhesive Mine B2A — never displayed*, EMP Mine B8E, Laser Mine B9L, Cauldron 50 Rocket, Cluster Mine B10, Maelstrom Bomb |
|
||||
| 39–47 | CANNON | **Tomahawk Alpha Rail Gun** ⌾, Sling 75KG, Glaive 120mm Cannon, Arbalest 155KG, Monoceros Long Cannon, Grav Cannon XGS, *+ three never-displayed slots* |
|
||||
| 48–53 | SPECIAL | **Jamming System** ⌾, Shield Doubler, Booster, Fire Control System, Regenerator, Cartridge Holder |
|
||||
|
||||
⌾ = pinned by an anchor: `9`, `21`, `39` and `48` were each **written into a save by
|
||||
hand** and read back off the screen; `31` is the Dart 23 Rocket's price row in the
|
||||
untouched save. The array ends exactly at 53 with the last SPECIAL item, with
|
||||
nothing left over — 8+8+4+5+5+8+9+6 plus `No_Equipment` is 54.
|
||||
|
||||
The three never-displayed CANNON-block slots are three of `Ballista GSH`,
|
||||
`Designator LH`, `Smoke Marker Launcher`, `Bumble UV` — the four cut entries
|
||||
`strings.tbl` lists between Grav Cannon XGS and Jamming System. **Which three** is
|
||||
🟡 undetermined and does not matter for reading a save; nothing displays them.
|
||||
|
||||
**This explains every anomaly in the retail save.** Its `4`s at 33 and at 45/46/47
|
||||
are the cut items — `Adhesive Mine B2A` and three of the four above — shipped
|
||||
flagged as owned. They never render, so no player ever sees them, and they are
|
||||
exactly the five "unexplained" owned entries that made the tail look wrong. The
|
||||
Tomahawk reading `Developed` at `blob[39]` while `weapon.tbl` puts it at 38 was
|
||||
the same off-by-N, seen from the other side.
|
||||
|
||||
## How the tail was actually settled (and what was wrong first)
|
||||
|
||||
Past index 31 the `weapon.tbl` alignment fails, and it fails against the screen,
|
||||
not against a theory — this is the evidence that sent the investigation to the
|
||||
localisation order above:
|
||||
|
||||
- `Tomahawk_a_Rail_Gun` is WEAPONS index **38** and the CANNON list shows it
|
||||
`Developed` (it is free once Stage 1 is complete) — but **blob[38] = 0**.
|
||||
- The blob's remaining `4`s are at **33, 39, 45, 46, 47**. In WEAPONS order those
|
||||
are `EMP_Mine_B8E`, `Sling_75KG`, `Shield_Doubler`, `Booster`,
|
||||
`FireControlSystem` — and **every one of them is shown as not developed**:
|
||||
BOMB/ROCKET lists only `DART 23 ROCKET 3500P`, CANNON only the Tomahawk, and
|
||||
**the SPECIAL tab is entirely empty** (all rows dashed). Their conditions agree
|
||||
— Shield Doubler needs Stage 12, Booster an ace kill in Stage 11, FCS Stage 6.
|
||||
|
||||
So five `4`s in the tail correspond to nothing the Arsenal reports as owned, and
|
||||
one genuinely-owned item reads `0`. A single `+1` shift does not repair it
|
||||
either: `blob[39] = 4` would put the Tomahawk right, but then `blob[33] = 4`
|
||||
lands on `Arrow_27_Rocket` (Stage 2 + 5000 P, unreachable here) and 45/46/47
|
||||
still land on locked SPECIAL items.
|
||||
|
||||
### Tested actively, with a hand-written save
|
||||
|
||||
Waiting for a mission payout was not necessary. The save container's derived
|
||||
fields turned out to be reproducible (see
|
||||
[`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) — the whole
|
||||
container re-wraps **byte-identically**), so the blob could be *written* and the
|
||||
result read off the screen. A probe save was built in our own throwaway slot 03
|
||||
with the blob zeroed except `0, 9, 21, 33, 38, 45 = 4` and `39, 46 = 2`, and it
|
||||
**loaded**.
|
||||
|
||||
What it showed:
|
||||
|
||||
- **The controls rendered.** `9` → BEAM shows `STILETTO BG1 Developed`; `21` →
|
||||
MULTIPURPOSE MISSILE shows `FALCON 9AM Developed`. So a hand-written `4` does
|
||||
reach the screen, and indices 9 and 21 are those items — now by experiment,
|
||||
not by correlation.
|
||||
- **The tail probes rendered nothing.** `33` should have made a BOMB/ROCKET row
|
||||
`Developed` (row 3 of that list, on screen, not below the fold) — it stayed
|
||||
dashed. `45` should have made a SPECIAL row `Developed` — the SPECIAL tab
|
||||
stayed entirely empty. And `38` should have been the Tomahawk — instead the
|
||||
CANNON list showed `TOMAHAWK ALPHA RAIL GUN — 0 P`, i.e. *not owned*.
|
||||
- **Clearing the real save's tail `4`s cost the Tomahawk its status.** In the
|
||||
unedited save the Tomahawk reads `Developed` while `blob[38] = 0`; the probe
|
||||
cleared `{22, 26, 39, 46, 47}` and the Tomahawk stopped being owned — so its
|
||||
flag is one of the cleared entries, with **39** the positional candidate. A
|
||||
uniform `+1` shift past index 32 would explain that, but it fails elsewhere:
|
||||
under it the real save's `45/46/47` are Chaff Flare Dispencer / Shield Doubler
|
||||
/ Booster, and the SPECIAL tab is empty in that same save.
|
||||
|
||||
Three further probes settled it, and the reasoning is worth keeping because the
|
||||
first read of the same evidence was wrong.
|
||||
|
||||
1. **Every slot owned.** With all 54 entries set to `4`, every tab filled and the
|
||||
lists **wrap**, which gives exact counts: GUN 8, BEAM 8, LASER 4, MPM 5, ASM 5,
|
||||
BOMB/ROCKET **7**, CANNON 6, SPECIAL 6 — 43 items — and the display order is
|
||||
`weapon.tbl`'s order, item for item. Note what this probe *cannot* do: with
|
||||
everything owned, any surjective mapping looks the same. It bounds, it does not
|
||||
pin. It also killed the "gate-unmet flags are suppressed" idea outright —
|
||||
Maelstrom Bomb (Stage 6) and Grav Cannon XGS (Stage 12) both rendered
|
||||
`Developed` at a Stage-2 save.
|
||||
2. **One slot owned: `39`.** `blob = 0` except `9` (control) and `39` → BEAM showed
|
||||
`STILETTO BG1 Developed` **and CANNON showed `TOMAHAWK ALPHA RAIL GUN
|
||||
Developed`**. So the CANNON block starts at 39, not 38: exactly one slot more
|
||||
than the display between Dart 23 (31) and the Tomahawk.
|
||||
3. **One slot owned: `48`.** `blob = 0` except `39` and `48` → SPECIAL showed
|
||||
`JAMMING SYSTEM Developed`. That fixes the SPECIAL base, and with six SPECIAL
|
||||
items the array closes exactly at 53.
|
||||
|
||||
The earlier probe's `45 = 4` leaving SPECIAL empty — which had looked like the
|
||||
mapping failing — is simply a cut item: 45 is in the CANNON block's
|
||||
never-displayed tail, not in SPECIAL at all.
|
||||
|
||||
### Two behaviours the probe exposed
|
||||
|
||||
- **`2` is derived, not stored.** On load the title recomputes which items are
|
||||
developable from its own conditions and *announces the difference* — zeroing
|
||||
Light Machine Gun MG1 and Broad Sword SG1 produced "You can now develop Broad
|
||||
Sword …" and both came back priced in the list. Only the owned (`4`) entries
|
||||
are authoritative state; writing `2` is pointless, and writing `0` over an
|
||||
item whose conditions are met is undone at load.
|
||||
- **The free items are bought for zero, not granted.** `TOMAHAWK ALPHA RAIL GUN
|
||||
— 0 P` is what an unowned no-cost item looks like. That is the real reason a
|
||||
save where the player never spent points still shows items as `Developed`
|
||||
(this note previously said "granted" — the mechanism is a 0 P purchase).
|
||||
|
||||
## Evidence
|
||||
|
||||
- [`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv) — all 60 conditions blocks.
|
||||
- [`captures/arsenal-categories-all.png`](captures/arsenal-categories-all.png) — the eight category tabs.
|
||||
- [`captures/arsenal-gun-below-fold.png`](captures/arsenal-gun-below-fold.png) — Broad Sword SG1, the entry that is not visible without scrolling.
|
||||
- [`captures/arsenal-probe-save-result.png`](captures/arsenal-probe-save-result.png) — the hand-written save's result: controls at 9/21 render `Developed`, the tail probes render nothing, and the Tomahawk shows `0 P`.
|
||||
- [`captures/arsenal-gun-list-predevelop.png`](captures/arsenal-gun-list-predevelop.png) · [`captures/arsenal-mg1-developed.png`](captures/arsenal-mg1-developed.png) — the differential itself.
|
||||
76
docs/re/captures/achievements-xach.txt
Normal file
@@ -0,0 +1,76 @@
|
||||
XACH @0x8FBCBC 24 achievements (string table #5 of 7)
|
||||
|
||||
id 1 | bit 0 | 20G | Space Combat Award
|
||||
unlocked: Received after your first space battle in the Glasner Training Area.
|
||||
locked : Awarded for participating in fighter combat in outer space.
|
||||
id 2 | bit 1 | 20G | Schlos Base Defense Award
|
||||
unlocked: Received for stopping the missile attack on Schlos Base.
|
||||
locked : Awarded for stopping enemy attacks on Schlos Base.
|
||||
id 3 | bit 2 | 20G | Aegis of the People Medal
|
||||
unlocked: Received for escorting all 7 refugee ships to safety.
|
||||
locked : Awarded for escorting the 7 refugee ships to safety.
|
||||
id 4 | bit 3 | 20G | TCAF Luna Medal
|
||||
unlocked: Received for bravely helping the fleet escape during the invasion of the Matisse System.
|
||||
locked : Awarded for bravery beyond the call of duty to escort allied vessels to safety.
|
||||
id 5 | bit 4 | 40G | TCAF Mars Medal
|
||||
unlocked: Received for bravery beyond the call of duty during the escape from the Matisse System.
|
||||
locked : Awarded for bravery beyond the call of duty under fierce enemy attacks.
|
||||
id 6 | bit 5 | 50G | Soldier's Charm Amulet
|
||||
unlocked: Given to you by Raymond as a token of his trust.
|
||||
locked : Given to you by Raymond as a token of his trust.
|
||||
id 7 | bit 6 | 20G | White Griffons Patch
|
||||
unlocked: Received by the commander and pilots of the White Griffon Squadron when it is formed.
|
||||
locked : Awarded to the commander and pilots of the White Griffon Squadron when it is formed.
|
||||
id 8 | bit 7 | 30G | TCAF Jupiter Medal
|
||||
unlocked: Received for bravery during the enemy's attack on the Alberti System.
|
||||
locked : Awarded for bravery beyond the call of duty during withdrawal of the allied fleet.
|
||||
id 9 | bit 8 | 40G | Furious Pursuit Badge
|
||||
unlocked: Received for continuing attacks on the enemy and shooting down a large number of ships.
|
||||
locked : Awarded for diligently shooting down large numbers of enemy ships.
|
||||
id 10 | bit 9 | 30G | Solo Aerospace Combat Award
|
||||
unlocked: Received for descending into Acheron's atmosphere and engaging in combat alone.
|
||||
locked : Awarded for descending into the atmosphere and engaging in combat alone.
|
||||
id 11 | bit 10 | 30G | Operation Nebula Blaze Award
|
||||
unlocked: Received for completing the extremely hazardous Operation Nebula Blaze.
|
||||
locked : Awarded for fulfilling duty and fighting bravely in this difficult operation.
|
||||
id 12 | bit 11 | 40G | Guilty Roses Patch
|
||||
unlocked: Received for repelling the Guilty Roses Squadron.
|
||||
locked : Awarded for repelling the enemy Guilty Roses Squadron.
|
||||
id 13 | bit 12 | 30G | Super Battleship Slayer Patch
|
||||
unlocked: Received for shooting down the second S battleship.
|
||||
locked : Awarded for shooting down the second S battleship supporting the enemy fleet.
|
||||
id 14 | bit 13 | 40G | TCAF Terra Medal
|
||||
unlocked: Received for shielding the fleet and seeing that all ships safely fled the Ingres System.
|
||||
locked : Awarded for shielding the fleet to allow its safe escape.
|
||||
id 15 | bit 14 | 40G | Hellfires Patch
|
||||
unlocked: Received for repelling the enemy Hellfire Squadron.
|
||||
locked : Awarded for repelling the enemy Hellfire Squadron.
|
||||
id 16 | bit 15 | 50G | Night Ravens Patch
|
||||
unlocked: Received for challenging and eradicating the Night Raven Squadron.
|
||||
locked : Awarded for challenging and eradicating the enemy Night Raven Squadron.
|
||||
id 17 | bit 16 | 40G | Solar System Defense Award
|
||||
unlocked: Received for great achievements during the campaign to defend the Solar System.
|
||||
locked : Awarded for great achievement during the campaign to defend the Solar System.
|
||||
id 18 | bit 17 | 40G | Special Operations Medal
|
||||
unlocked: Received for heroically destroying the Prometheus Driver.
|
||||
locked : Awarded for heroism in destroying the enemy's main weapon.
|
||||
id 19 | bit 18 | 30G | 1,000 Units Destroyed Medal
|
||||
unlocked: Received for shooting down 1,000 enemy fighters and attackers in combat.
|
||||
locked : Awarded for shooting down 1,000 enemy fighters and attackers in space combat.
|
||||
id 20 | bit 19 | 70G | 10,000 Units Destroyed Medal
|
||||
unlocked: Received for shooting down 10,000 enemy fighters and attackers in combat.
|
||||
locked : Awarded for shooting down 10,000 enemy fighters and attackers in space combat.
|
||||
id 21 | bit 20 | 50G | Ship Hunter Award
|
||||
unlocked: Received for shooting down 100 enemy warships in combat.
|
||||
locked : Awarded for shooting down 100 warships in space combat.
|
||||
id 22 | bit 21 | 70G | Gigaton Club Patch
|
||||
unlocked: Received for shooting down enemy vessels with a total weight of one gigaton.
|
||||
locked : Awarded for downing several enemy vessels with a combined weight of one gigaton.
|
||||
id 23 | bit 22 | 80G | Weapon Lord Patch
|
||||
unlocked: Received after you collect all usable equipment for the Delta Saber.
|
||||
locked : Awarded for collecting all usable equipment for the Delta Saber.
|
||||
id 24 | bit 23 | 100G | TCAF Pilot's Commendation
|
||||
unlocked: Commemmorates you as one of the greatest pilots in history.
|
||||
locked : Awarded to the greatest pilots in the TCAF.
|
||||
|
||||
total gamerscore = 1000 [OK: retail total]
|
||||
BIN
docs/re/captures/arsenal-all-owned-br-cannon.png
Normal file
|
After Width: | Height: | Size: 590 KiB |
BIN
docs/re/captures/arsenal-categories-all.png
Normal file
|
After Width: | Height: | Size: 860 KiB |
61
docs/re/captures/arsenal-develop-conditions.csv
Normal file
@@ -0,0 +1,61 @@
|
||||
item,stage_required,points_cost,other_conditions,raw_text
|
||||
Light Machine Gun MG1,1,4000,,Conditions to obtain Light Machine Gun MG1 - Complete Stage 1 - Must spend 4000 points to develop
|
||||
Light Swivel Machine Gun MG2H,,3000,Completed development of the Light Machine Gun MG1,Conditions to obtain Light Swivel Machine Gun MG2H - Completed development of the Light Machine Gun MG1 - Must spend 3000 points to develop
|
||||
Heavy Machine Gun MG3,6,6000,,Conditions to obtain Heavy Machine Gun MG3 - Complete Stage 6 - Must spend 6000 points to develop
|
||||
Heavy Swivel Machine Gun MG5H,,10000,Completed development of the Heavy Machine Gun MG3,Conditions to obtain Heavy Swivel Machine Gun MG5H - Completed development of the Heavy Machine Gun MG3 - Must spend 10000 points to develop
|
||||
Broad Sword SG1,1,5000,,Conditions to obtain Broad Sword SG1 - Complete Stage 1 - Must spend 5000 points to develop
|
||||
Swing Sword SG2H,,10000,Completed development of the Twin Sword SG3II,Conditions to obtain Swing Sword SG2H - Completed development of the Twin Sword SG3II - Must spend 10000 points to develop
|
||||
Twin Sword SG3II,,8000,Completed development of the Broad Sword SG1,Conditions to obtain Twin Sword SG3II - Completed development of the Broad Sword SG1 - Must spend 8000 points to develop
|
||||
Flail GP37,6,,,Conditions to obtain Flail GP37 - Complete Stage 6
|
||||
Stiletto BG1,,,Initially Mounted,Conditions to obtain Stiletto BG1 - Initially Mounted
|
||||
Dagger BG2,,5000,Development Possible,Conditions to obtain Dagger BG2 - Development Possible - Must spend 5000 points to develop
|
||||
Rapier BG4H,,12000,Completed development of the Dagger BG2,Conditions to obtain Rapier BG4H - Completed development of the Dagger BG2 - Must spend 12000 points to develop
|
||||
Pilum BP,,6000,Development Possible,Conditions to obtain Pilum BP - Development Possible - Must spend 6000 points to develop
|
||||
Multi Pike BP,,30000,Completed development of the Pilum BP,Conditions to obtain Multi Pike BP - Completed development of the Pilum BP - Must spend 30000 points to develop
|
||||
Spear HBP,,10000,Completed development of the Multi Pike BP,Conditions to obtain Spear HBP - Completed development of the Multi Pike BP - Must spend 10000 points to develop
|
||||
Long Spear HBP,7,350000,Completed development of the Gray Head T76H,Conditions to obtain Long Spear HBP - Complete Stage 7 - Completed development of the Gray Head T76H - Must spend 350000 points to develop
|
||||
Gray Head T76H,,25000,Completed development of the Spear HBP,Conditions to obtain Gray Head T76H - Completed development of the Spear HBP - Must spend 25000 points to develop
|
||||
Saber LG1,14,,,Conditions to obtain Saber LG1 - Complete Stage 14
|
||||
Twin Saber LG2H,15,,,Conditions to obtain Twin Saber LG2H - Complete Stage 15
|
||||
Needle L1GP,6,10000,,Conditions to obtain Needle L1GP - Complete Stage 6 - Must spend 10000 points to develop
|
||||
Fire Arrow L2GP,,20000,Completed development of the Needle L1GP,Conditions to obtain Fire Arrow L2GP - Completed development of the Needle L1GP - Must spend 20000 points to develop
|
||||
Falcon 9AM,,,Initially Mounted,Conditions to obtain Falcon 9AM - Initially Mounted
|
||||
Buzzard 10AM,1,,,Conditions to obtain Buzzard 10AM - Complete Stage 1
|
||||
Hawk 75AM,6,10000,Possess Buzzard 10AM,Conditions to obtain Hawk 75AM - Complete Stage 6 - Possess Buzzard 10AM - Must spend 10000 points to develop
|
||||
Condor 105AM,6,,,Conditions to obtain Condor 105AM - Complete Stage 6
|
||||
Eagle 120AM,12,15000,Completed development of the Hawk 75AM,Conditions to obtain Eagle 120AM - Complete Stage 12 - Completed development of the Hawk 75AM - Must spend 15000 points to develop
|
||||
Terrier SMH,1,,,Conditions to obtain Terrier SMH - Complete Stage 1
|
||||
Hound SMH,,10000,Development Possible,Conditions to obtain Hound SMH - Development Possible - Must spend 10000 points to develop
|
||||
Pointer SM,,40000,Completed development of Piranha T53,Conditions to obtain Pointer SM - Completed development of Piranha T53 - Must spend 40000 points to develop
|
||||
Piranha T53,,25000,Obtain White Shark T53R,Conditions to obtain Piranha T53 - Obtain White Shark T53R - Must spend 25000 points to develop
|
||||
White Shark T53R,6,,,Conditions to obtain White Shark T53R - Complete Stage 6
|
||||
Dart 23 Rocket,1,3500,,Conditions to obtain Dart 23 Rocket - Complete Stage 1 - Must spend 3500 points to develop
|
||||
Arrow 27 Rocket,2,5000,,Conditions to obtain Arrow 27 Rocket - Complete Stage 2 - Must spend 5000 points to develop
|
||||
Adhesive Mine B2A,3,MISSING,,Conditions to obtain Adhesive Mine B2A - Complete Stage 3 - Must spend points to develop
|
||||
EMP Mine B8E,6,50000,,Conditions to obtain EMP Mine B8E - Complete Stage 6 - Must spend 50000 points to develop
|
||||
Laser Mine B9L,6,15000,,Conditions to obtain Laser Mine B9L - Complete Stage 6 - Must spend 15000 points to develop
|
||||
Cauldron 50 Rocket,14,,,Conditions to obtain Cauldron 50 Rocket - Complete Stage 14
|
||||
Cluster Mine B10,6,5000,,Conditions to obtain Cluster Mine B10 - Complete Stage 6 - Must spend 5000 points to develop
|
||||
Maelstrom Bomb,6,200000,,Conditions to obtain Maelstrom Bomb - Complete Stage 6 - Must spend 200000 points to develop
|
||||
Tomahawk Alpha Rail Gun,1,,,Conditions to obtain Tomahawk Alpha Rail Gun - Complete Stage 1
|
||||
Sling 75KG,2,5000,,Conditions to obtain Sling 75KG - Complete Stage 2 - Must spend 5000 points to develop
|
||||
Glaive 120mm Cannon,6,,,Conditions to obtain Glaive 120mm Cannon - Complete Stage 6
|
||||
Arbalest 155KG,6,25000,,Conditions to obtain Arbalest 155KG - Complete Stage 6 - Must spend 25000 points to develop
|
||||
Monoceros Long Cannon,,,Shoot down the ace pilot in Stage 14,Conditions to obtain Monoceros Long Cannon - Shoot down the ace pilot in Stage 14
|
||||
Grav Cannon XGS,12,60000,,Conditions to obtain Grav Cannon XGS - Complete Stage 12 - Must spend 60000 points to develop
|
||||
Ballista GSH,,,,Conditions to obtain Ballista GSH
|
||||
Designator LH,,,,Conditions to obtain Designator LH
|
||||
Smoke Marker Launcher,,,,Conditions to obtain Smoke Marker Launcher
|
||||
Bumble UV,,,,Conditions to obtain Bumble UV
|
||||
Jamming System,3,15000,,Conditions to obtain Jamming System - Complete Stage 3 - Must spend 15000 points to develop
|
||||
Shield Doubler,12,50000,,Conditions to obtain Shield Doubler - Complete Stage 12 - Must spend 50000 points to develop
|
||||
Booster,,,Shoot down the ace pilot in Stage 11,Conditions to obtain Booster - Shoot down the ace pilot in Stage 11
|
||||
Fire Control System,6,15000,,Conditions to obtain Fire Control System - Complete Stage 6 - Must spend 15000 points to develop
|
||||
Regenerator,12,100000,,Conditions to obtain Regenerator - Complete Stage 12 - Must spend 100000 points to develop
|
||||
Cartridge Holder,,,Shoot down the ace pilot in Stage 13,Conditions to obtain Cartridge Holder - Shoot down the ace pilot in Stage 13
|
||||
Ballista 24 Rocket Launcher,,,,Conditions to obtain Ballista 24 Rocket Launcher
|
||||
Thrush 220AM,,,,Conditions to obtain Thrush 220AM
|
||||
Divider L3GP,,,,Conditions to obtain Divider L3GP
|
||||
Mace GP25H,,,,Conditions to obtain Mace GP25H
|
||||
Thor Gun System,,,,Conditions to obtain Thor Gun System
|
||||
Spitfire BX,,,,Conditions to obtain Spitfire BX
|
||||
|
BIN
docs/re/captures/arsenal-gun-below-fold.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
docs/re/captures/arsenal-gun-list-predevelop.png
Normal file
|
After Width: | Height: | Size: 764 KiB |
BIN
docs/re/captures/arsenal-layout-validation.png
Normal file
|
After Width: | Height: | Size: 620 KiB |
50
docs/re/captures/arsenal-main-screen-layout.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
=== eng\prmain_scr.prt ===
|
||||
23 elements
|
||||
# element parent kind pivot kf placement
|
||||
0 preff01.t32 - 0x0 (164,156) 5 rest (256,214) t=6..22 [6:256,214 22:256,214 31:256,214 42:256,214 1:256,214]
|
||||
1 preff02.t32 - 0x0 (155,175) 7 rest (156,121) t=8..37 [4:305,240 6:193,150 7:165,128 8:156,121 37:155,120 44:155,120 2:305,240]
|
||||
2 preff03.t32 - 0x0 (155,175) 7 rest (374,269) t=8..37 [4:225,150 6:337,240 7:365,262 8:374,269 37:375,270 44:375,270 3:225,150]
|
||||
3 preff04.t32 - 0x0 (247,237) 6 rest (173,133) t=8..37 [4:173,133 6:173,133 8:173,133 37:173,133 44:173,133 4:173,133]
|
||||
4 prwinbase.t32 - 0x0 (136,130) 5 rest (284,240) t=14..35 [6:284,240 14:284,240 35:284,240 43:284,240 5:284,240]
|
||||
5 prbtn1.rat - 0x3002 (28,14) 5 rest (242,166) t=16..33 [10:242,166 16:242,166 33:242,166 37:242,166 6:242,166]
|
||||
6 prbtn2.rat - 0x3002 (43,14) 5 rest (242,220) t=17..33 [11:242,220 17:242,220 33:242,220 37:242,220 7:242,220]
|
||||
7 prbtn3.rat - 0x3002 (58,14) 5 rest (242,276) t=18..33 [12:242,276 18:242,276 33:242,276 37:242,276 8:242,276]
|
||||
8 prbtn4.rat - 0x3002 (89,14) 5 rest (242,331) t=19..33 [13:242,331 19:242,331 33:242,331 37:242,331 9:242,331]
|
||||
9 prbtn5.rat - 0x3002 (89,14) 5 rest (242,385) t=20..33 [14:242,385 20:242,385 33:242,385 37:242,385 10:242,385]
|
||||
10 prbtn6.rat - 0x3002 (104,14) 5 rest (242,441) t=21..33 [15:242,441 21:242,441 33:242,441 37:242,441 11:242,441]
|
||||
11 prbtn7.rat - 0x3002 (58,14) 5 rest (242,496) t=22..33 [16:242,496 22:242,496 33:242,496 37:242,496 12:242,496]
|
||||
12 prbtn8.rat - 0x3002 (30,14) 5 rest (242,551) t=23..33 [17:242,551 23:242,551 33:242,551 37:242,551 13:242,551]
|
||||
13 prexp1.t32 - 0x0 (277,30) 8 rest (726,143) t=31..37 [21:726,143 25:1286,143 27:866,143 28:756,143 29:731,143 31:726,143 37:726,143 14:1286,143]
|
||||
14 prexp1a.t32 13 0x1 (277,30) 3 rest (1126,143) t=31..37 [31:1126,143 37:1126,143 15:1126,143]
|
||||
15 prexp3.t32 - 0x0 (205,5) 5 rest (726,381) t=10..29 [10:726,381 29:726,381 31:726,381 39:726,381 16:726,381]
|
||||
16 prmsg.t32 - 0x0 (330,19) 5 rest (151,645) t=21..29 [21:151,645 29:151,645 31:151,645 37:151,645 17:151,645]
|
||||
17 prexp3.t32 - 0x4 (205,5) 5 rest (726,415) t=10..29 [10:726,415 29:726,415 31:726,415 39:726,415 18:726,415]
|
||||
18 prexp3.t32 - 0x4 (205,5) 5 rest (726,449) t=10..29 [10:726,449 29:726,449 31:726,449 39:726,449 19:726,449]
|
||||
19 prexp3.t32 - 0x4 (205,5) 5 rest (726,483) t=10..29 [10:726,483 29:726,483 31:726,483 39:726,483 20:726,483]
|
||||
20 prexp3.t32 - 0x4 (205,5) 5 rest (726,517) t=10..29 [10:726,517 29:726,517 31:726,517 39:726,517 21:726,517]
|
||||
21 prexp3.t32 - 0x4 (205,5) 5 rest (726,551) t=10..29 [10:726,551 29:726,551 31:726,551 39:726,551 22:726,551]
|
||||
22 prexp3.t32 - 0x4 (205,5) 5 rest (726,585) t=39..1869640736 [10:726,585 29:726,585 31:726,585 39:726,585 1869640736:726,585]
|
||||
=== eng\prselect_scr.prt ===
|
||||
8 elements
|
||||
# element parent kind pivot kf placement
|
||||
0 prselect_win1.t32 - 0x0 (256,200) 10 rest (127,155) t=8..23 [4:-516,155 6:-71,155 7:81,155 8:127,155 23:134,155 24:134,155 25:127,155 27:81,155 31:-71,155 1:-516,155]
|
||||
1 prselect_win2.t32 0 0x1 (256,200) 1 (135,560)
|
||||
2 prselect_win1eff.t32 0 0x1 (256,200) 5 rest (71,92) t=2..14 [2:71,92 14:71,92 21:71,92 27:71,92 3:71,92]
|
||||
3 prselect_win2eff.t32 0 0x1 (256,200) 5 rest (71,499) t=2..14 [2:71,499 14:71,499 21:71,499 27:71,499 4:71,499]
|
||||
4 prselect_sbar.sbo 0 0x1 (256,200) 1 (614,172)
|
||||
5 prselect_type_line.t32 - 0x0 (260,24) 5 rest (123,142) t=14..21 [8:123,142 14:123,142 21:123,142 25:123,142 6:123,142]
|
||||
6 prselect_msg.t32 - 0x0 (468,19) 5 rest (151,645) t=11..19 [11:151,645 19:151,645 21:151,645 27:151,645 7:151,645]
|
||||
7 prselect_msgbtn1.t32 - 0x2 (128,19) 5 rest (809,645) t=27..1869640736 [11:809,645 19:809,645 21:809,645 27:809,645 1869640736:809,645]
|
||||
=== eng\prselect_win3.prt ===
|
||||
2 elements
|
||||
# element parent kind pivot kf placement
|
||||
0 prselect_win3.t32 - 0x0 (245,300) 4 rest (704,39) t=10..16 [8:704,39 10:704,39 16:704,39 1:704,39]
|
||||
1 prselect_win3eff.t32 - 0x0 (309,352) 4 rest (640,0) t=16..1869640736 [8:640,0 10:640,0 16:640,0 1869640736:640,0]
|
||||
=== eng\prtitle.prt ===
|
||||
5 elements
|
||||
# element parent kind pivot kf placement
|
||||
0 preff11.t32 - 0x0 (221,6) 6 rest (-160,82) t=16..35 [12:-440,82 14:-440,82 16:-160,82 35:0,82 41:0,82 1:-440,82]
|
||||
1 preff12.t32 - 0x0 (75,3) 5 rest (0,72) t=20..34 [14:0,72 20:0,72 34:0,72 38:0,72 2:0,72]
|
||||
2 preff13.t32 - 0x0 (53,3) 5 rest (407,78) t=22..33 [16:407,78 22:407,78 33:407,78 37:407,78 3:407,78]
|
||||
3 preff14.t32 - 0x0 (52,3) 5 rest (427,68) t=24..32 [18:427,68 24:427,68 32:427,68 36:427,68 4:427,68]
|
||||
4 prtitle.t32 - 0x0 (94,35) 7 rest (162,26) t=37..1869640736 [20:142,26 23:142,26 25:157,26 26:161,26 31:162,26 37:162,26 1869640736:142,26]
|
||||
BIN
docs/re/captures/arsenal-mg1-developed.png
Normal file
|
After Width: | Height: | Size: 478 KiB |
BIN
docs/re/captures/arsenal-probe-blob39-tomahawk.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
docs/re/captures/arsenal-probe-blob48-jamming.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/re/captures/arsenal-probe-save-result.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
5
docs/re/captures/axis-probe-rows-pinned.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
input,roll_deg_s,yaw_deg_s,pitch_deg_s
|
||||
rx+,0.0,0.0,0.0
|
||||
ry+,0.0,0.0,0.0
|
||||
LB,0.0,0.0,0.0
|
||||
RB,0.0,0.0,0.0
|
||||
|
7
docs/re/captures/axis-probe-stage02.csv
Normal file
@@ -0,0 +1,7 @@
|
||||
input,roll_deg_s,yaw_deg_s,pitch_deg_s
|
||||
lx+,209.8,0.87,10.07
|
||||
ly+,0.0,154.12,0.0
|
||||
rx+,0.0,0.0,0.0
|
||||
ry+,161.07,86.98,37.06
|
||||
LB,0.0,0.0,0.0
|
||||
RB,0.0,0.0,0.0
|
||||
|
BIN
docs/re/captures/challenge-extras-after-poke.png
Normal file
|
After Width: | Height: | Size: 921 KiB |
96
docs/re/captures/challenge-map.txt
Normal file
@@ -0,0 +1,96 @@
|
||||
=== 1. StageResource records in GP_MAIN_GAME_E.pak ===
|
||||
29 stage records
|
||||
S01 bg=Lebendorf tokens=52
|
||||
S02 bg=Lebendorf tokens=51
|
||||
S03 bg=Planet_Lebendorf tokens=51
|
||||
S04 bg=Lebendorf_far tokens=54
|
||||
S05 bg=Lebendorf_far tokens=54
|
||||
S06 bg=Hargenteen tokens=51
|
||||
S07 bg=Hargenteen tokens=53
|
||||
S08 bg=Barch tokens=59
|
||||
S09 bg=Acheron tokens=53
|
||||
S10 bg=Acheron tokens=53
|
||||
S11 bg=Anastasis tokens=53
|
||||
S12 bg=Hargenteen tokens=53
|
||||
S13 bg=Hargenteen tokens=56
|
||||
S14 bg=Earth tokens=59
|
||||
S15 bg=Earth tokens=53
|
||||
S16 bg=PD tokens=53
|
||||
S18 bg=Original tokens=43
|
||||
S19 bg=Original tokens=43
|
||||
S20 bg=Original tokens=43
|
||||
S21 bg=Original tokens=43
|
||||
S22 bg=Original tokens=50
|
||||
S23 bg=Original tokens=43
|
||||
S24 bg=Anastasis tokens=53
|
||||
S25 bg=Hargenteen tokens=53
|
||||
S26 bg=Hargenteen tokens=53
|
||||
S27 bg=Planet_Lebendorf tokens=53
|
||||
S28 bg=Lebendorf tokens=54
|
||||
S29 bg=Earth tokens=53
|
||||
Test bg=Earth tokens=43
|
||||
|
||||
=== 2. GP_CHALLENGE.pak contents ===
|
||||
151 entries, 0 IDXD objects
|
||||
|
||||
=== 3. tokens mentioning Challenge / EX across the main pak ===
|
||||
033b5b7e pgmsg_challenge1.t32
|
||||
033b5b7e pgmsg_challenge2.t32
|
||||
033b5b7e pgmsg_challenge3.t32
|
||||
033b5b7e pgmsg_challenge4.t32
|
||||
033b5b7e pgmsg_challenge5.t32
|
||||
033b5b7e pgmsg_challenge6.t32
|
||||
35b8dc67 UN_e001_ADAN_Elan_EX4
|
||||
35b8dc67 UN_e007_ADAN_Turret_EX4
|
||||
35b8dc67 UN_e008_ADAN_TurretPlus_EX4
|
||||
35b8dc67 UN_e010_ADAN_Attacker_S_EX4
|
||||
35b8dc67 UN_e011_ADAN_Attacker_B_EX4
|
||||
35b8dc67 UN_e107_ADAN_AAFrigate_EX4
|
||||
35b8dc67 UN_f001_TCAF_DeltaSaber_T_EX5
|
||||
35b8dc67 UN_f001_TCAF_DeltaSaber_T_EX5_el
|
||||
35b8dc67 UN_f003_TCAF_ArrowHead_EX4
|
||||
35b8dc67 UN_f003_TCAF_ArrowHead_EX5
|
||||
35b8dc67 UN_f104_TCAF_Battleship_EX5
|
||||
35b8dc67 UN_f105_TCAF_Cruiser_EX5
|
||||
3c5b0549 UN_e107_ADAN_AAFrigate_EX4
|
||||
3c5b0549 UN_f104_TCAF_Battleship_EX5
|
||||
3c5b0549 UN_f105_TCAF_Cruiser_EX5
|
||||
3c5b0549 UnitName_UN_e107_ADAN_AAFrigate_EX4
|
||||
3c5b0549 UnitName_UN_f104_TCAF_Battleship_EX5
|
||||
3c5b0549 UnitName_UN_f105_TCAF_Cruiser_EX5
|
||||
3c5b0549 Weapon_TCAF_Ship_AAGun_EX5
|
||||
3c9ae32e EnumWeapon_EX5.tbl
|
||||
43faa517 UN_e001_ADAN_Elan_EX4
|
||||
43faa517 UN_e007_ADAN_Turret_EX4
|
||||
43faa517 UN_e008_ADAN_TurretPlus_EX4
|
||||
43faa517 UN_e010_ADAN_Attacker_S_EX4
|
||||
43faa517 UN_e011_ADAN_Attacker_B_EX4
|
||||
43faa517 UN_f001_TCAF_DeltaSaber_T_EX5
|
||||
43faa517 UN_f001_TCAF_DeltaSaber_T_EX5_el
|
||||
43faa517 UN_f003_TCAF_ArrowHead_EX4
|
||||
43faa517 UN_f003_TCAF_ArrowHead_EX5
|
||||
43faa517 UnitName_UN_e001_ADAN_Elan_EX4
|
||||
43faa517 UnitName_UN_e007_ADAN_Turret_EX4
|
||||
43faa517 UnitName_UN_e008_ADAN_TurretPlus_EX4
|
||||
43faa517 UnitName_UN_e010_ADAN_Attacker_S_EX4
|
||||
43faa517 UnitName_UN_e011_ADAN_Attacker_B_EX4
|
||||
43faa517 UnitName_UN_f001_TCAF_DeltaSaber_T_EX5
|
||||
43faa517 UnitName_UN_f001_TCAF_DeltaSaber_T_EX5_el
|
||||
43faa517 UnitName_UN_f003_TCAF_ArrowHead_EX4
|
||||
43faa517 UnitName_UN_f003_TCAF_ArrowHead_EX5
|
||||
659aff47 UN_e001_ADAN_Elan_EX4
|
||||
659aff47 UN_e007_ADAN_Turret_EX4
|
||||
659aff47 UN_e008_ADAN_TurretPlus_EX4
|
||||
659aff47 UN_e010_ADAN_Attacker_S_EX4
|
||||
659aff47 UN_e011_ADAN_Attacker_B_EX4
|
||||
659aff47 UN_e107_ADAN_AAFrigate_EX4
|
||||
659aff47 UN_f003_TCAF_ArrowHead_EX4
|
||||
6ab4825a WeaponCannonName_TCAF_Ship_AAGun_EX5
|
||||
6ab4825a Weapon_TCAF_Ship_AAGun_EX5
|
||||
6b9b000d UN_f001_TCAF_DeltaSaber_T_EX5
|
||||
6b9b000d UN_f001_TCAF_DeltaSaber_T_EX5_el
|
||||
6b9b000d UN_f003_TCAF_ArrowHead_EX5
|
||||
6b9b000d UN_f104_TCAF_Battleship_EX5
|
||||
6b9b000d UN_f105_TCAF_Cruiser_EX5
|
||||
ffbc19c2 Weapon_TCAF_Ship_AAGun_EX5
|
||||
(59 distinct)
|
||||
750
docs/re/captures/challenge-screen-config.txt
Normal file
@@ -0,0 +1,750 @@
|
||||
|
||||
===== tables.pak entry #60 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+fra\] =====
|
||||
0 xBASE_INFO
|
||||
1 0x061031
|
||||
2 VERSION
|
||||
3 dat\GP_CHALLENGE.pak+fra\
|
||||
4 PATH
|
||||
5 strings.tbl
|
||||
6 STRINGS
|
||||
7 py_menu_scr.prt
|
||||
8 MENU
|
||||
9 CHIPS
|
||||
10 py_menu_n_btn1.prt
|
||||
11 Button_TimeAttack
|
||||
12 py_menu_n_btn2.prt
|
||||
13 Button_ScoreAttack
|
||||
14 py_menu_n_btn3.prt
|
||||
15 Button_Extra01
|
||||
16 py_menu_n_btn4.prt
|
||||
17 Button_Extra02
|
||||
18 py_menu_n_btn5.prt
|
||||
19 Button_Extra03
|
||||
20 py_menu_n_btn6.prt
|
||||
21 Button_Extra04
|
||||
22 py_menu_b_btn1.prt
|
||||
23 Button_TimeAttack_Gray
|
||||
24 py_menu_b_btn2.prt
|
||||
25 Button_ScoreAttack_Gray
|
||||
26 py_menu_b_btn3.prt
|
||||
27 Button_Extra01_Gray
|
||||
28 py_menu_b_btn4.prt
|
||||
29 Button_Extra02_Gray
|
||||
30 py_menu_b_btn5.prt
|
||||
31 Button_Extra03_Gray
|
||||
32 py_menu_b_btn6.prt
|
||||
33 Button_Extra04_Gray
|
||||
34 py_menu_challenge1.t32
|
||||
35 Thumbnail_TimeAttack
|
||||
36 py_menu_challenge2.t32
|
||||
37 Thumbnail_ScoreAttack
|
||||
38 py_menu_challenge3.t32
|
||||
39 Thumbnail_Extra01
|
||||
40 py_menu_challenge4.t32
|
||||
41 Thumbnail_Extra02
|
||||
42 py_menu_challenge5.t32
|
||||
43 Thumbnail_Extra03
|
||||
44 py_menu_challenge6.t32
|
||||
45 Thumbnail_Extra04
|
||||
46 py_menu_str_time.t32
|
||||
47 Record_Time
|
||||
48 py_menu_str_point.t32
|
||||
49 Record_Point
|
||||
50 py_menu_str_p.t32
|
||||
51 Unit_Point
|
||||
52 py_menu_new.prt
|
||||
53 New_Stage
|
||||
54 FONTS
|
||||
55 Font_Desc
|
||||
56 Font_Record
|
||||
57 CONTM___.TTF
|
||||
58 FONT
|
||||
59 24
|
||||
60 HEIGHT
|
||||
61 28
|
||||
62 Desc_Stage
|
||||
63 580,400
|
||||
64 LOCATE
|
||||
65 8
|
||||
66 LINE_SPACE
|
||||
67 Desc_Time
|
||||
68 1048,608,R
|
||||
69 0
|
||||
70 INDEX
|
||||
71 Desc_Points
|
||||
72 1032,608,R
|
||||
73 UNIT
|
||||
74 New_Stage01
|
||||
75 New_Stage02
|
||||
76 0,60
|
||||
77 INDEX_ADJUST
|
||||
78 New_Stage03
|
||||
79 0,120
|
||||
80 New_Stage04
|
||||
81 0,180
|
||||
82 New_Stage05
|
||||
83 0,240
|
||||
84 New_Stage06
|
||||
85 0,300
|
||||
86 MISSIONS
|
||||
87 TimeAttack
|
||||
88 ScoreAttack
|
||||
89 Extra01
|
||||
90 Extra02
|
||||
91 Extra03
|
||||
92 Extra04
|
||||
93 MISSION_ID
|
||||
94 Time
|
||||
95 RECORD_TYPE
|
||||
96 16
|
||||
97 REQUIREMENT
|
||||
98 NORMAL_BUTTON
|
||||
99 GRAY_BUTTON
|
||||
100 THUMBNAIL
|
||||
101 TimeAttackRequirement
|
||||
102 REQUIREMENT_DESC
|
||||
103 TimeAttackStageDesc
|
||||
104 STAGE_DESC
|
||||
105 TEXT_STAGE
|
||||
106 TEXT_RECORD
|
||||
107 NEW_STAGE
|
||||
108 25
|
||||
109 Points
|
||||
110 ScoreAttackRequirement
|
||||
111 ScoreAttackStageDesc
|
||||
112 26
|
||||
113 Extra01Requirement
|
||||
114 ExtraStage01Desc
|
||||
115 27
|
||||
116 Extra02Requirement
|
||||
117 ExtraStage02Desc
|
||||
118 Extra03Requirement
|
||||
119 ExtraStage03Desc
|
||||
120 29
|
||||
121 Extra04Requirement
|
||||
122 ExtraStage04Desc
|
||||
|
||||
===== tables.pak entry #64 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+eng\] =====
|
||||
0 xBASE_INFO
|
||||
1 0x061031
|
||||
2 VERSION
|
||||
3 dat\GP_CHALLENGE.pak+eng\
|
||||
4 PATH
|
||||
5 strings.tbl
|
||||
6 STRINGS
|
||||
7 py_menu_scr.prt
|
||||
8 MENU
|
||||
9 CHIPS
|
||||
10 py_menu_n_btn1.prt
|
||||
11 Button_TimeAttack
|
||||
12 py_menu_n_btn2.prt
|
||||
13 Button_ScoreAttack
|
||||
14 py_menu_n_btn3.prt
|
||||
15 Button_Extra01
|
||||
16 py_menu_n_btn4.prt
|
||||
17 Button_Extra02
|
||||
18 py_menu_n_btn5.prt
|
||||
19 Button_Extra03
|
||||
20 py_menu_n_btn6.prt
|
||||
21 Button_Extra04
|
||||
22 py_menu_b_btn1.prt
|
||||
23 Button_TimeAttack_Gray
|
||||
24 py_menu_b_btn2.prt
|
||||
25 Button_ScoreAttack_Gray
|
||||
26 py_menu_b_btn3.prt
|
||||
27 Button_Extra01_Gray
|
||||
28 py_menu_b_btn4.prt
|
||||
29 Button_Extra02_Gray
|
||||
30 py_menu_b_btn5.prt
|
||||
31 Button_Extra03_Gray
|
||||
32 py_menu_b_btn6.prt
|
||||
33 Button_Extra04_Gray
|
||||
34 py_menu_challenge1.t32
|
||||
35 Thumbnail_TimeAttack
|
||||
36 py_menu_challenge2.t32
|
||||
37 Thumbnail_ScoreAttack
|
||||
38 py_menu_challenge3.t32
|
||||
39 Thumbnail_Extra01
|
||||
40 py_menu_challenge4.t32
|
||||
41 Thumbnail_Extra02
|
||||
42 py_menu_challenge5.t32
|
||||
43 Thumbnail_Extra03
|
||||
44 py_menu_challenge6.t32
|
||||
45 Thumbnail_Extra04
|
||||
46 py_menu_str_time.t32
|
||||
47 Record_Time
|
||||
48 py_menu_str_point.t32
|
||||
49 Record_Point
|
||||
50 py_menu_str_p.t32
|
||||
51 Unit_Point
|
||||
52 py_menu_new.prt
|
||||
53 New_Stage
|
||||
54 FONTS
|
||||
55 Font_Desc
|
||||
56 Font_Record
|
||||
57 CONTM___.TTF
|
||||
58 FONT
|
||||
59 24
|
||||
60 HEIGHT
|
||||
61 28
|
||||
62 Desc_Stage
|
||||
63 580,400
|
||||
64 LOCATE
|
||||
65 8
|
||||
66 LINE_SPACE
|
||||
67 Desc_Time
|
||||
68 1048,608,R
|
||||
69 0
|
||||
70 INDEX
|
||||
71 Desc_Points
|
||||
72 1032,608,R
|
||||
73 UNIT
|
||||
74 New_Stage01
|
||||
75 New_Stage02
|
||||
76 0,60
|
||||
77 INDEX_ADJUST
|
||||
78 New_Stage03
|
||||
79 0,120
|
||||
80 New_Stage04
|
||||
81 0,180
|
||||
82 New_Stage05
|
||||
83 0,240
|
||||
84 New_Stage06
|
||||
85 0,300
|
||||
86 MISSIONS
|
||||
87 TimeAttack
|
||||
88 ScoreAttack
|
||||
89 Extra01
|
||||
90 Extra02
|
||||
91 Extra03
|
||||
92 Extra04
|
||||
93 MISSION_ID
|
||||
94 Time
|
||||
95 RECORD_TYPE
|
||||
96 16
|
||||
97 REQUIREMENT
|
||||
98 NORMAL_BUTTON
|
||||
99 GRAY_BUTTON
|
||||
100 THUMBNAIL
|
||||
101 TimeAttackRequirement
|
||||
102 REQUIREMENT_DESC
|
||||
103 TimeAttackStageDesc
|
||||
104 STAGE_DESC
|
||||
105 TEXT_STAGE
|
||||
106 TEXT_RECORD
|
||||
107 NEW_STAGE
|
||||
108 25
|
||||
109 Points
|
||||
110 ScoreAttackRequirement
|
||||
111 ScoreAttackStageDesc
|
||||
112 26
|
||||
113 Extra01Requirement
|
||||
114 ExtraStage01Desc
|
||||
115 27
|
||||
116 Extra02Requirement
|
||||
117 ExtraStage02Desc
|
||||
118 Extra03Requirement
|
||||
119 ExtraStage03Desc
|
||||
120 29
|
||||
121 Extra04Requirement
|
||||
122 ExtraStage04Desc
|
||||
|
||||
===== tables.pak entry #67 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+deu\] =====
|
||||
0 xBASE_INFO
|
||||
1 0x061031
|
||||
2 VERSION
|
||||
3 dat\GP_CHALLENGE.pak+deu\
|
||||
4 PATH
|
||||
5 strings.tbl
|
||||
6 STRINGS
|
||||
7 py_menu_scr.prt
|
||||
8 MENU
|
||||
9 CHIPS
|
||||
10 py_menu_n_btn1.prt
|
||||
11 Button_TimeAttack
|
||||
12 py_menu_n_btn2.prt
|
||||
13 Button_ScoreAttack
|
||||
14 py_menu_n_btn3.prt
|
||||
15 Button_Extra01
|
||||
16 py_menu_n_btn4.prt
|
||||
17 Button_Extra02
|
||||
18 py_menu_n_btn5.prt
|
||||
19 Button_Extra03
|
||||
20 py_menu_n_btn6.prt
|
||||
21 Button_Extra04
|
||||
22 py_menu_b_btn1.prt
|
||||
23 Button_TimeAttack_Gray
|
||||
24 py_menu_b_btn2.prt
|
||||
25 Button_ScoreAttack_Gray
|
||||
26 py_menu_b_btn3.prt
|
||||
27 Button_Extra01_Gray
|
||||
28 py_menu_b_btn4.prt
|
||||
29 Button_Extra02_Gray
|
||||
30 py_menu_b_btn5.prt
|
||||
31 Button_Extra03_Gray
|
||||
32 py_menu_b_btn6.prt
|
||||
33 Button_Extra04_Gray
|
||||
34 py_menu_challenge1.t32
|
||||
35 Thumbnail_TimeAttack
|
||||
36 py_menu_challenge2.t32
|
||||
37 Thumbnail_ScoreAttack
|
||||
38 py_menu_challenge3.t32
|
||||
39 Thumbnail_Extra01
|
||||
40 py_menu_challenge4.t32
|
||||
41 Thumbnail_Extra02
|
||||
42 py_menu_challenge5.t32
|
||||
43 Thumbnail_Extra03
|
||||
44 py_menu_challenge6.t32
|
||||
45 Thumbnail_Extra04
|
||||
46 py_menu_str_time.t32
|
||||
47 Record_Time
|
||||
48 py_menu_str_point.t32
|
||||
49 Record_Point
|
||||
50 py_menu_str_p.t32
|
||||
51 Unit_Point
|
||||
52 py_menu_new.prt
|
||||
53 New_Stage
|
||||
54 FONTS
|
||||
55 Font_Desc
|
||||
56 Font_Record
|
||||
57 CONTM___.TTF
|
||||
58 FONT
|
||||
59 24
|
||||
60 HEIGHT
|
||||
61 28
|
||||
62 Desc_Stage
|
||||
63 580,400
|
||||
64 LOCATE
|
||||
65 8
|
||||
66 LINE_SPACE
|
||||
67 Desc_Time
|
||||
68 1048,608,R
|
||||
69 0
|
||||
70 INDEX
|
||||
71 Desc_Points
|
||||
72 1032,608,R
|
||||
73 UNIT
|
||||
74 New_Stage01
|
||||
75 New_Stage02
|
||||
76 0,60
|
||||
77 INDEX_ADJUST
|
||||
78 New_Stage03
|
||||
79 0,120
|
||||
80 New_Stage04
|
||||
81 0,180
|
||||
82 New_Stage05
|
||||
83 0,240
|
||||
84 New_Stage06
|
||||
85 0,300
|
||||
86 MISSIONS
|
||||
87 TimeAttack
|
||||
88 ScoreAttack
|
||||
89 Extra01
|
||||
90 Extra02
|
||||
91 Extra03
|
||||
92 Extra04
|
||||
93 MISSION_ID
|
||||
94 Time
|
||||
95 RECORD_TYPE
|
||||
96 16
|
||||
97 REQUIREMENT
|
||||
98 NORMAL_BUTTON
|
||||
99 GRAY_BUTTON
|
||||
100 THUMBNAIL
|
||||
101 TimeAttackRequirement
|
||||
102 REQUIREMENT_DESC
|
||||
103 TimeAttackStageDesc
|
||||
104 STAGE_DESC
|
||||
105 TEXT_STAGE
|
||||
106 TEXT_RECORD
|
||||
107 NEW_STAGE
|
||||
108 25
|
||||
109 Points
|
||||
110 ScoreAttackRequirement
|
||||
111 ScoreAttackStageDesc
|
||||
112 26
|
||||
113 Extra01Requirement
|
||||
114 ExtraStage01Desc
|
||||
115 27
|
||||
116 Extra02Requirement
|
||||
117 ExtraStage02Desc
|
||||
118 Extra03Requirement
|
||||
119 ExtraStage03Desc
|
||||
120 29
|
||||
121 Extra04Requirement
|
||||
122 ExtraStage04Desc
|
||||
|
||||
===== tables.pak entry #68 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+ita\] =====
|
||||
0 xBASE_INFO
|
||||
1 0x061031
|
||||
2 VERSION
|
||||
3 dat\GP_CHALLENGE.pak+ita\
|
||||
4 PATH
|
||||
5 strings.tbl
|
||||
6 STRINGS
|
||||
7 py_menu_scr.prt
|
||||
8 MENU
|
||||
9 CHIPS
|
||||
10 py_menu_n_btn1.prt
|
||||
11 Button_TimeAttack
|
||||
12 py_menu_n_btn2.prt
|
||||
13 Button_ScoreAttack
|
||||
14 py_menu_n_btn3.prt
|
||||
15 Button_Extra01
|
||||
16 py_menu_n_btn4.prt
|
||||
17 Button_Extra02
|
||||
18 py_menu_n_btn5.prt
|
||||
19 Button_Extra03
|
||||
20 py_menu_n_btn6.prt
|
||||
21 Button_Extra04
|
||||
22 py_menu_b_btn1.prt
|
||||
23 Button_TimeAttack_Gray
|
||||
24 py_menu_b_btn2.prt
|
||||
25 Button_ScoreAttack_Gray
|
||||
26 py_menu_b_btn3.prt
|
||||
27 Button_Extra01_Gray
|
||||
28 py_menu_b_btn4.prt
|
||||
29 Button_Extra02_Gray
|
||||
30 py_menu_b_btn5.prt
|
||||
31 Button_Extra03_Gray
|
||||
32 py_menu_b_btn6.prt
|
||||
33 Button_Extra04_Gray
|
||||
34 py_menu_challenge1.t32
|
||||
35 Thumbnail_TimeAttack
|
||||
36 py_menu_challenge2.t32
|
||||
37 Thumbnail_ScoreAttack
|
||||
38 py_menu_challenge3.t32
|
||||
39 Thumbnail_Extra01
|
||||
40 py_menu_challenge4.t32
|
||||
41 Thumbnail_Extra02
|
||||
42 py_menu_challenge5.t32
|
||||
43 Thumbnail_Extra03
|
||||
44 py_menu_challenge6.t32
|
||||
45 Thumbnail_Extra04
|
||||
46 py_menu_str_time.t32
|
||||
47 Record_Time
|
||||
48 py_menu_str_point.t32
|
||||
49 Record_Point
|
||||
50 py_menu_str_p.t32
|
||||
51 Unit_Point
|
||||
52 py_menu_new.prt
|
||||
53 New_Stage
|
||||
54 FONTS
|
||||
55 Font_Desc
|
||||
56 Font_Record
|
||||
57 CONTM___.TTF
|
||||
58 FONT
|
||||
59 24
|
||||
60 HEIGHT
|
||||
61 28
|
||||
62 Desc_Stage
|
||||
63 580,400
|
||||
64 LOCATE
|
||||
65 8
|
||||
66 LINE_SPACE
|
||||
67 Desc_Time
|
||||
68 1048,608,R
|
||||
69 0
|
||||
70 INDEX
|
||||
71 Desc_Points
|
||||
72 1032,608,R
|
||||
73 UNIT
|
||||
74 New_Stage01
|
||||
75 New_Stage02
|
||||
76 0,60
|
||||
77 INDEX_ADJUST
|
||||
78 New_Stage03
|
||||
79 0,120
|
||||
80 New_Stage04
|
||||
81 0,180
|
||||
82 New_Stage05
|
||||
83 0,240
|
||||
84 New_Stage06
|
||||
85 0,300
|
||||
86 MISSIONS
|
||||
87 TimeAttack
|
||||
88 ScoreAttack
|
||||
89 Extra01
|
||||
90 Extra02
|
||||
91 Extra03
|
||||
92 Extra04
|
||||
93 MISSION_ID
|
||||
94 Time
|
||||
95 RECORD_TYPE
|
||||
96 16
|
||||
97 REQUIREMENT
|
||||
98 NORMAL_BUTTON
|
||||
99 GRAY_BUTTON
|
||||
100 THUMBNAIL
|
||||
101 TimeAttackRequirement
|
||||
102 REQUIREMENT_DESC
|
||||
103 TimeAttackStageDesc
|
||||
104 STAGE_DESC
|
||||
105 TEXT_STAGE
|
||||
106 TEXT_RECORD
|
||||
107 NEW_STAGE
|
||||
108 25
|
||||
109 Points
|
||||
110 ScoreAttackRequirement
|
||||
111 ScoreAttackStageDesc
|
||||
112 26
|
||||
113 Extra01Requirement
|
||||
114 ExtraStage01Desc
|
||||
115 27
|
||||
116 Extra02Requirement
|
||||
117 ExtraStage02Desc
|
||||
118 Extra03Requirement
|
||||
119 ExtraStage03Desc
|
||||
120 29
|
||||
121 Extra04Requirement
|
||||
122 ExtraStage04Desc
|
||||
|
||||
===== tables.pak entry #74 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+jpn\] =====
|
||||
0 BASE_INFO
|
||||
1 0x061031
|
||||
2 VERSION
|
||||
3 dat\GP_CHALLENGE.pak+jpn\
|
||||
4 PATH
|
||||
5 strings.tbl
|
||||
6 STRINGS
|
||||
7 py_menu_scr.prt
|
||||
8 MENU
|
||||
9 CHIPS
|
||||
10 py_menu_n_btn1.prt
|
||||
11 Button_TimeAttack
|
||||
12 py_menu_n_btn2.prt
|
||||
13 Button_ScoreAttack
|
||||
14 py_menu_n_btn3.prt
|
||||
15 Button_Extra01
|
||||
16 py_menu_n_btn4.prt
|
||||
17 Button_Extra02
|
||||
18 py_menu_n_btn5.prt
|
||||
19 Button_Extra03
|
||||
20 py_menu_n_btn6.prt
|
||||
21 Button_Extra04
|
||||
22 py_menu_b_btn1.prt
|
||||
23 Button_TimeAttack_Gray
|
||||
24 py_menu_b_btn2.prt
|
||||
25 Button_ScoreAttack_Gray
|
||||
26 py_menu_b_btn3.prt
|
||||
27 Button_Extra01_Gray
|
||||
28 py_menu_b_btn4.prt
|
||||
29 Button_Extra02_Gray
|
||||
30 py_menu_b_btn5.prt
|
||||
31 Button_Extra03_Gray
|
||||
32 py_menu_b_btn6.prt
|
||||
33 Button_Extra04_Gray
|
||||
34 py_menu_challenge1.t32
|
||||
35 Thumbnail_TimeAttack
|
||||
36 py_menu_challenge2.t32
|
||||
37 Thumbnail_ScoreAttack
|
||||
38 py_menu_challenge3.t32
|
||||
39 Thumbnail_Extra01
|
||||
40 py_menu_challenge4.t32
|
||||
41 Thumbnail_Extra02
|
||||
42 py_menu_challenge5.t32
|
||||
43 Thumbnail_Extra03
|
||||
44 py_menu_challenge6.t32
|
||||
45 Thumbnail_Extra04
|
||||
46 py_menu_str_time.t32
|
||||
47 Record_Time
|
||||
48 py_menu_str_point.t32
|
||||
49 Record_Point
|
||||
50 py_menu_str_p.t32
|
||||
51 Unit_Point
|
||||
52 py_menu_new.prt
|
||||
53 New_Stage
|
||||
54 FONTS
|
||||
55 Font_Desc
|
||||
56 Font_Record
|
||||
57 DFSOGE5.TTC
|
||||
58 FONT
|
||||
59 24
|
||||
60 HEIGHT
|
||||
61 CONTM___.TTF
|
||||
62 28
|
||||
63 Desc_Stage
|
||||
64 580,400
|
||||
65 LOCATE
|
||||
66 8
|
||||
67 LINE_SPACE
|
||||
68 Desc_Time
|
||||
69 1048,608,R
|
||||
70 INDEX
|
||||
71 Desc_Points
|
||||
72 1032,608,R
|
||||
73 UNIT
|
||||
74 New_Stage01
|
||||
75 New_Stage02
|
||||
76 0,60
|
||||
77 INDEX_ADJUST
|
||||
78 New_Stage03
|
||||
79 0,120
|
||||
80 New_Stage04
|
||||
81 0,180
|
||||
82 New_Stage05
|
||||
83 0,240
|
||||
84 New_Stage06
|
||||
85 0,300
|
||||
86 MISSIONS
|
||||
87 TimeAttack
|
||||
88 ScoreAttack
|
||||
89 Extra01
|
||||
90 Extra02
|
||||
91 Extra03
|
||||
92 Extra04
|
||||
93 MISSION_ID
|
||||
94 Time
|
||||
95 RECORD_TYPE
|
||||
96 16
|
||||
97 REQUIREMENT
|
||||
98 NORMAL_BUTTON
|
||||
99 GRAY_BUTTON
|
||||
100 THUMBNAIL
|
||||
101 TimeAttackRequirement
|
||||
102 REQUIREMENT_DESC
|
||||
103 TimeAttackStageDesc
|
||||
104 STAGE_DESC
|
||||
105 TEXT_STAGE
|
||||
106 TEXT_RECORD
|
||||
107 NEW_STAGE
|
||||
108 25
|
||||
109 Points
|
||||
110 ScoreAttackRequirement
|
||||
111 ScoreAttackStageDesc
|
||||
112 26
|
||||
113 Extra01Requirement
|
||||
114 ExtraStage01Desc
|
||||
115 27
|
||||
116 Extra02Requirement
|
||||
117 ExtraStage02Desc
|
||||
118 Extra03Requirement
|
||||
119 ExtraStage03Desc
|
||||
120 29
|
||||
121 Extra04Requirement
|
||||
122 ExtraStage04Desc
|
||||
|
||||
===== tables.pak entry #75 schema 54a10697 123 tokens [dat\GP_CHALLENGE.pak+esp\] =====
|
||||
0 xBASE_INFO
|
||||
1 0x061031
|
||||
2 VERSION
|
||||
3 dat\GP_CHALLENGE.pak+esp\
|
||||
4 PATH
|
||||
5 strings.tbl
|
||||
6 STRINGS
|
||||
7 py_menu_scr.prt
|
||||
8 MENU
|
||||
9 CHIPS
|
||||
10 py_menu_n_btn1.prt
|
||||
11 Button_TimeAttack
|
||||
12 py_menu_n_btn2.prt
|
||||
13 Button_ScoreAttack
|
||||
14 py_menu_n_btn3.prt
|
||||
15 Button_Extra01
|
||||
16 py_menu_n_btn4.prt
|
||||
17 Button_Extra02
|
||||
18 py_menu_n_btn5.prt
|
||||
19 Button_Extra03
|
||||
20 py_menu_n_btn6.prt
|
||||
21 Button_Extra04
|
||||
22 py_menu_b_btn1.prt
|
||||
23 Button_TimeAttack_Gray
|
||||
24 py_menu_b_btn2.prt
|
||||
25 Button_ScoreAttack_Gray
|
||||
26 py_menu_b_btn3.prt
|
||||
27 Button_Extra01_Gray
|
||||
28 py_menu_b_btn4.prt
|
||||
29 Button_Extra02_Gray
|
||||
30 py_menu_b_btn5.prt
|
||||
31 Button_Extra03_Gray
|
||||
32 py_menu_b_btn6.prt
|
||||
33 Button_Extra04_Gray
|
||||
34 py_menu_challenge1.t32
|
||||
35 Thumbnail_TimeAttack
|
||||
36 py_menu_challenge2.t32
|
||||
37 Thumbnail_ScoreAttack
|
||||
38 py_menu_challenge3.t32
|
||||
39 Thumbnail_Extra01
|
||||
40 py_menu_challenge4.t32
|
||||
41 Thumbnail_Extra02
|
||||
42 py_menu_challenge5.t32
|
||||
43 Thumbnail_Extra03
|
||||
44 py_menu_challenge6.t32
|
||||
45 Thumbnail_Extra04
|
||||
46 py_menu_str_time.t32
|
||||
47 Record_Time
|
||||
48 py_menu_str_point.t32
|
||||
49 Record_Point
|
||||
50 py_menu_str_p.t32
|
||||
51 Unit_Point
|
||||
52 py_menu_new.prt
|
||||
53 New_Stage
|
||||
54 FONTS
|
||||
55 Font_Desc
|
||||
56 Font_Record
|
||||
57 CONTM___.TTF
|
||||
58 FONT
|
||||
59 24
|
||||
60 HEIGHT
|
||||
61 28
|
||||
62 Desc_Stage
|
||||
63 580,400
|
||||
64 LOCATE
|
||||
65 8
|
||||
66 LINE_SPACE
|
||||
67 Desc_Time
|
||||
68 1048,608,R
|
||||
69 0
|
||||
70 INDEX
|
||||
71 Desc_Points
|
||||
72 1032,608,R
|
||||
73 UNIT
|
||||
74 New_Stage01
|
||||
75 New_Stage02
|
||||
76 0,60
|
||||
77 INDEX_ADJUST
|
||||
78 New_Stage03
|
||||
79 0,120
|
||||
80 New_Stage04
|
||||
81 0,180
|
||||
82 New_Stage05
|
||||
83 0,240
|
||||
84 New_Stage06
|
||||
85 0,300
|
||||
86 MISSIONS
|
||||
87 TimeAttack
|
||||
88 ScoreAttack
|
||||
89 Extra01
|
||||
90 Extra02
|
||||
91 Extra03
|
||||
92 Extra04
|
||||
93 MISSION_ID
|
||||
94 Time
|
||||
95 RECORD_TYPE
|
||||
96 16
|
||||
97 REQUIREMENT
|
||||
98 NORMAL_BUTTON
|
||||
99 GRAY_BUTTON
|
||||
100 THUMBNAIL
|
||||
101 TimeAttackRequirement
|
||||
102 REQUIREMENT_DESC
|
||||
103 TimeAttackStageDesc
|
||||
104 STAGE_DESC
|
||||
105 TEXT_STAGE
|
||||
106 TEXT_RECORD
|
||||
107 NEW_STAGE
|
||||
108 25
|
||||
109 Points
|
||||
110 ScoreAttackRequirement
|
||||
111 ScoreAttackStageDesc
|
||||
112 26
|
||||
113 Extra01Requirement
|
||||
114 ExtraStage01Desc
|
||||
115 27
|
||||
116 Extra02Requirement
|
||||
117 ExtraStage02Desc
|
||||
118 Extra03Requirement
|
||||
119 ExtraStage03Desc
|
||||
120 29
|
||||
121 Extra04Requirement
|
||||
122 ExtraStage04Desc
|
||||
115
docs/re/captures/disc-unit-signatures.txt
Normal file
@@ -0,0 +1,115 @@
|
||||
|
||||
|
||||
id hp size_x size_y size_z kind
|
||||
UN_e006_ADAN_Vindicator_MargrasF 6400 12 5 30 unit
|
||||
UN_be005_ADAN_SpaceFortress 10000 2500 3100 2600 unit
|
||||
UN_f001_TCAF_DeltaSaber_T 1000 10 7 29 unit
|
||||
UN_f002_TCAF_DeltaSaber_W 1000 10 7 29 unit
|
||||
UN_e009_ADAN_Phantom 1000 18 16 24 unit
|
||||
UN_e001_ADAN_Elan_EX4 500 25 10 30 unit
|
||||
UN_e011_ADAN_Attacker_B 1500 200 50 100 unit
|
||||
UN_f001_TCAF_DeltaSaber_T_Ttrl 1000 10 7 29 unit
|
||||
UN_f202_TCAF_Cargo 4000 1400 600 2000 unit
|
||||
UN_e007_ADAN_Turret_EX4 100 20 9 22 unit
|
||||
UN_e010_ADAN_Attacker_S 500 100 40 50 unit
|
||||
UN_be001_ADAN_TerrafoamingUnit 10000 2600 2200 — unit
|
||||
UN_e005_ADAN_ElanTypeQ_Margras 3200 24 15 38 unit
|
||||
UN_e001_ADAN_Elan_GR_Violeta 1200 25 10 30 unit
|
||||
UN_e004_ADAN_ElanPlus_N 800 24 10 30 unit
|
||||
UN_e201_ADAN_ISCMissile 3000 300 — 2800 unit
|
||||
UN_f001_TCAF_DeltaSaber_T_Player_Ttrl1 1500 10 7 29 unit
|
||||
UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2 1500 10 7 29 unit
|
||||
UN_f003_TCAF_ArrowHead_EX4 300 10 5 20 unit
|
||||
UN_f003_TCAF_ArrowHead_EX5 300 10 5 20 unit
|
||||
UN_e011_ADAN_Attacker_B_HF 2250 200 50 100 unit
|
||||
UN_e015_ADAN_Puppy — 7 5.5 10 unit
|
||||
Ship_Test 100 20 14 29 unit
|
||||
UN_e010_ADAN_Attacker_S_HF 750 100 40 50 unit
|
||||
Test_ADAN_PrometheusDriver 10000 1 — — unit
|
||||
UN_f001_TCAF_DeltaSaber_T_EX5 1000 10 7 29 unit
|
||||
UN_e002_ADAN_Elan_N 800 20 10 30 unit
|
||||
UN_e008_ADAN_TurretPlus 300 24 9 35 unit
|
||||
UN_mn040_Asteroid_Big 10000 2000 3500 — unit
|
||||
UN_mn500_ADAN_FloatingMine 1000 200 — — unit
|
||||
UN_S04_Asteroid_cmesh_01 10000 46900 55500 13600 unit
|
||||
UN_S05_Asteroid_cmesh_01 10000 10400 13000 34400 unit
|
||||
UN_S04_Asteroid_cmesh_03 10000 42800 37700 47400 unit
|
||||
UN_S04_Asteroid_cmesh_04 10000 59000 56900 21800 unit
|
||||
UN_S05_Asteroid_cmesh_03 10000 8300 13000 19800 unit
|
||||
UN_S04_Asteroid_cmesh_05 — 26000 21800 72400 unit
|
||||
UN_S05_Asteroid_cmesh_04 10000 7400 15800 30100 unit
|
||||
UN_e011_ADAN_Attacker_B_EX4 1500 200 50 100 unit
|
||||
UN_e910_core_ADAN_GeneratorCore 10000 2000 — — unit
|
||||
UN_n001_TTRL_Box 100 0.1 — — unit
|
||||
UN_e006_ADAN_Vindicator_Margras 6400 12 5 30 unit
|
||||
UN_e010_ADAN_Attacker_S_EX4 500 100 40 50 unit
|
||||
UN_bf001_TCAF_SchlosBase 10000 2500 3100 2600 unit
|
||||
UN_f001_TCAF_DeltaSaber_T_EX5_el 1000 10 7 29 unit
|
||||
UN_n001_TTRL_Box_move 100 0.1 — — unit
|
||||
UN_e004_ADAN_ElanPlus_NF 800 24 10 30 unit
|
||||
Test_ADAN_PrometheusDriver_InsideP2_01 10000 1 — — unit
|
||||
UN_e001_ADAN_Elan 500 25 10 30 unit
|
||||
UN_e007_ADAN_Turret 100 20 9 22 unit
|
||||
UN_e003_ADAN_ElanPlus_Margras 1600 24 10 30 unit
|
||||
UN_e901_ADAN_Boss 10000 500 1700 — unit
|
||||
UN_e013_ADAN_ElanPlus_Taskent 1300 24 10 30 unit
|
||||
UN_e011_ADAN_Attacker_B_HF_Wayne 3000 200 50 100 unit
|
||||
UN_f003_TCAF_ArrowHead 300 10 5 20 unit
|
||||
UN_f004_TCAF_DeltaSaber_A_Player 1500 10 7 29 unit
|
||||
UN_e010_ADAN_Attacker_S_HF_Wayne 1000 100 40 50 unit
|
||||
UN_e015_ADAN_Puppy_2 — 7 5.5 10 unit
|
||||
UN_S01_Asteroid_cmesh_01a — 23000 21700 13400 unit
|
||||
UN_S01_Asteroid_cmesh_02a 10000 20900 24000 23800 unit
|
||||
UN_S01_Asteroid_cmesh_01b — 18000 20900 14200 unit
|
||||
UN_S01_Asteroid_cmesh_02b — 20400 21800 26700 unit
|
||||
UN_S01_Asteroid_cmesh_03a 10000 23600 11700 30300 unit
|
||||
UN_bf002_TCAF_TransitPlatform 10000 2500 3100 4600 unit
|
||||
UN_e001_ADAN_Elan_GR 800 25 10 30 unit
|
||||
UN_S13_Asteroid_cmesh_01a 10000 14200 10200 14700 unit
|
||||
UN_S01_Asteroid_cmesh_03b — 21800 18600 37200 unit
|
||||
UN_S01_Asteroid_cmesh_04a — 32800 19900 23300 unit
|
||||
UN_S13_Asteroid_cmesh_01b 10000 8000 5400 16400 unit
|
||||
UN_S13_Asteroid_cmesh_02a 10000 6200 5400 11300 unit
|
||||
UN_S01_Asteroid_cmesh_04b — 26400 23100 19600 unit
|
||||
UN_S04_Asteroid_cmesh_02a — 25100 20800 61400 unit
|
||||
UN_S13_Asteroid_cmesh_02b 10000 5100 6900 10500 unit
|
||||
UN_S05_Asteroid_cmesh_02a 10000 10100 13200 9100 unit
|
||||
UN_S04_Asteroid_cmesh_02b 10000 25200 19700 67000 unit
|
||||
UN_S05_Asteroid_cmesh_02b 10000 8700 14900 12300 unit
|
||||
UN_S08_Asteroid_cmesh_01a 10000 14700 14900 21300 unit
|
||||
UN_S08_Asteroid_cmesh_01b 10000 14900 14600 15800 unit
|
||||
UN_S08_Asteroid_cmesh_02a 10000 17400 13400 15200 unit
|
||||
UN_S04_Asteroid_cmesh_06a — 14400 20800 45900 unit
|
||||
UN_S08_Asteroid_cmesh_02b 10000 16800 10700 14200 unit
|
||||
UN_S08_Asteroid_cmesh_03a — 14000 10000 13700 unit
|
||||
UN_S04_Asteroid_cmesh_06b 10000 14100 16200 21500 unit
|
||||
UN_S04_Asteroid_cmesh_07a — 23200 14000 67100 unit
|
||||
UN_S08_Asteroid_cmesh_03b 10000 13900 15100 15700 unit
|
||||
UN_S04_Asteroid_cmesh_07b 10000 25300 4900 40600 unit
|
||||
UN_e008_ADAN_TurretPlus_EX4 300 24 9 35 unit
|
||||
UN_f001_TCAF_DeltaSaber_T_Player 1500 10 7 29 unit
|
||||
UN_f003_TCAF_ArrowHead_Yoji 400 10 5 20 unit
|
||||
UN_f002_TCAF_DeltaSaber_W_Player 1500 10 7 29 unit
|
||||
UN_e104_ADAN_Carrier 20000 2300 1000 3300 vessel
|
||||
UN_f106_TCAF_Destroyer 10000 200 — 2000 vessel
|
||||
UN_e105_ADAN_Cruiser 30000 600 — 3800 vessel
|
||||
UN_e101_ADAN_SDBattleship 100000 1900 2100 12300 vessel
|
||||
UN_f105_TCAF_Cruiser 30000 700 — 3800 vessel
|
||||
UN_f102_TCAF_LightCarrier 20000 500 — 1600 vessel
|
||||
UN_e102_ADAN_BattleshipEX 50000 1900 2300 6500 vessel
|
||||
UN_e107_ADAN_AAFrigate_EX4 4000 100 200 — vessel
|
||||
UN_e102_ADAN_Battleship 50000 1900 2300 6500 vessel
|
||||
UN_f104_TCAF_Battleship 30000 2000 — 6000 vessel
|
||||
UN_f105_TCAF_Cruiser_EX5 30000 700 — 3800 vessel
|
||||
UN_e108_ADAN_ASFrigateEX 4000 80 — 350 vessel
|
||||
UN_f106_TCAF_Destroyer_Inv 10000 200 — 2000 vessel
|
||||
UN_e107_ADAN_AAFrigate 4000 100 200 — vessel
|
||||
UN_f104_TCAF_Battleship_EX5 30000 2000 — 6000 vessel
|
||||
UN_f105_TCAF_Cruiser_Inv 30000 700 — 3800 vessel
|
||||
UN_f102_TCAF_LightCarrier_Inv 20000 500 — 1600 vessel
|
||||
UN_e108_ADAN_ASFrigate 4000 80 — 350 vessel
|
||||
UN_e106_ADAN_DestroyerEX 10000 300 — 2100 vessel
|
||||
UN_f101_TCAF_Acropolis 25000 400 — 1400 vessel
|
||||
UN_e105_ADAN_CruiserEX 30000 600 — 3800 vessel
|
||||
UN_e101_ADAN_SDBattleshipEX 100000 1900 2100 12300 vessel
|
||||
UN_e106_ADAN_Destroyer 10000 300 — 2100 vessel
|
||||
BIN
docs/re/captures/e106-cover-slack-before-after.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/re/captures/e106-static-assembly-fixed.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/re/captures/e106-static-assembly-volume-bug.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
161
docs/re/captures/escort-decay-ab.csv
Normal file
@@ -0,0 +1,161 @@
|
||||
run,turret_rule,t_s,acropolis_hull,e007_alive,e007_killed_cum
|
||||
mission01,off,0,25000.0,0,0
|
||||
mission01,off,10,25000.0,5,0
|
||||
mission01,off,20,25000.0,6,0
|
||||
mission01,off,30,25000.0,6,0
|
||||
mission01,off,40,25000.0,6,0
|
||||
mission01,off,50,25000.0,8,0
|
||||
mission01,off,60,25000.0,11,0
|
||||
mission01,off,70,25000.0,11,3
|
||||
mission01,off,80,25000.0,8,3
|
||||
mission01,off,90,25000.0,8,3
|
||||
mission01,off,100,25000.0,10,3
|
||||
mission01,off,110,25000.0,14,3
|
||||
mission01,off,120,25000.0,16,3
|
||||
mission01,off,130,25000.0,17,3
|
||||
mission01,off,140,25000.0,20,3
|
||||
mission01,off,150,25000.0,23,3
|
||||
mission01,off,160,25000.0,26,4
|
||||
mission01,off,170,24509.5,33,4
|
||||
mission01,off,180,24509.5,37,4
|
||||
mission01,off,190,24419.5,38,5
|
||||
mission01,off,200,24212.5,41,5
|
||||
mission01,off,210,23447.5,40,5
|
||||
mission01,off,220,23447.5,40,5
|
||||
mission01,off,220,23447.5,40,5
|
||||
mission01,off,230,23447.5,41,5
|
||||
mission01,off,230,23447.5,41,5
|
||||
mission01,off,240,22912.0,43,5
|
||||
mission01,off,240,22867.0,43,5
|
||||
mission01,off,250,22667.0,42,6
|
||||
mission01,off,250,22647.0,42,6
|
||||
mission01,off,260,22334.5,44,6
|
||||
mission01,off,260,22177.9,44,6
|
||||
mission01,off,270,21101.2,46,6
|
||||
mission01,off,270,21101.2,45,6
|
||||
mission01,off,280,20292.6,46,6
|
||||
mission01,off,280,20279.1,46,6
|
||||
mission01,off,290,19598.5,58,6
|
||||
mission01,off,290,19598.5,58,6
|
||||
mission01,off,300,18725.2,58,6
|
||||
mission01,off,300,18658.5,47,6
|
||||
mission01,off,310,18231.9,47,6
|
||||
mission01,off,310,18231.9,47,6
|
||||
mission01,off,320,17425.2,47,6
|
||||
mission01,off,330,17046.9,49,6
|
||||
mission01,off,340,16044.8,49,7
|
||||
mission01,off,350,15461.5,50,7
|
||||
mission01,off,360,14956.3,51,7
|
||||
mission01,off,370,14425.5,51,8
|
||||
mission01,off,380,13910.5,51,8
|
||||
mission01,off,390,13418.9,49,11
|
||||
mission01,off,400,13218.9,45,12
|
||||
mission01,off,410,12517.9,44,12
|
||||
mission01,off,420,12161.3,44,13
|
||||
mission01,off,430,11601.3,44,13
|
||||
mission02,on,0,25000.0,0,0
|
||||
mission02,on,10,25000.0,5,0
|
||||
mission02,on,20,25000.0,6,0
|
||||
mission02,on,30,25000.0,5,0
|
||||
mission02,on,40,25000.0,5,1
|
||||
mission02,on,50,25000.0,7,1
|
||||
mission02,on,60,25000.0,10,1
|
||||
mission02,on,70,25000.0,7,4
|
||||
mission02,on,80,25000.0,7,4
|
||||
mission02,on,90,25000.0,7,4
|
||||
mission02,on,100,25000.0,7,4
|
||||
mission02,on,110,25000.0,9,4
|
||||
mission02,on,120,25000.0,14,4
|
||||
mission02,on,130,25000.0,16,4
|
||||
mission02,on,140,25000.0,19,4
|
||||
mission02,on,150,25000.0,19,4
|
||||
mission02,on,160,25000.0,23,5
|
||||
mission02,on,170,24730.0,28,5
|
||||
mission02,on,180,24730.0,32,5
|
||||
mission02,on,190,24730.0,36,5
|
||||
mission02,on,200,24640.0,43,6
|
||||
mission02,on,210,24190.0,46,6
|
||||
mission02,on,220,24055.0,47,6
|
||||
mission02,on,220,24055.0,47,6
|
||||
mission02,on,230,24055.0,49,6
|
||||
mission02,on,230,24055.0,49,6
|
||||
mission02,on,240,23474.5,52,6
|
||||
mission02,on,240,23474.5,52,6
|
||||
mission02,on,250,23474.5,53,6
|
||||
mission02,on,250,23474.5,53,6
|
||||
mission02,on,260,23474.5,54,6
|
||||
mission02,on,260,23474.5,54,6
|
||||
mission02,on,270,22697.0,54,6
|
||||
mission02,on,270,22697.0,54,6
|
||||
mission02,on,280,22091.2,56,6
|
||||
mission02,on,280,22091.2,55,7
|
||||
mission02,on,290,21577.9,55,7
|
||||
mission02,on,290,21555.4,55,7
|
||||
mission02,on,300,20799.1,56,7
|
||||
mission02,on,310,19781.1,55,7
|
||||
mission02,on,320,18925.8,56,8
|
||||
mission02,on,330,18592.5,56,8
|
||||
mission02,on,340,17432.5,57,8
|
||||
mission02,on,350,16932.6,59,8
|
||||
mission02,on,360,16171.6,58,9
|
||||
mission02,on,370,15017.6,58,11
|
||||
mission02,on,380,14217.2,59,11
|
||||
mission02,on,390,13322.2,59,12
|
||||
mission02,on,400,12808.9,58,13
|
||||
mission02,on,410,12090.6,61,16
|
||||
mission02,on,420,11440.6,66,19
|
||||
mission02,on,430,11127.3,55,19
|
||||
mission03,on,0,25000.0,0,0
|
||||
mission03,on,10,25000.0,3,0
|
||||
mission03,on,20,25000.0,8,0
|
||||
mission03,on,30,25000.0,5,0
|
||||
mission03,on,40,25000.0,6,0
|
||||
mission03,on,50,25000.0,8,0
|
||||
mission03,on,60,25000.0,9,0
|
||||
mission03,on,70,25000.0,16,0
|
||||
mission03,on,80,25000.0,10,0
|
||||
mission03,on,90,25000.0,11,0
|
||||
mission03,on,100,25000.0,10,2
|
||||
mission03,on,110,25000.0,15,2
|
||||
mission03,on,120,25000.0,12,2
|
||||
mission03,on,130,25000.0,10,4
|
||||
mission03,on,140,25000.0,11,6
|
||||
mission03,on,150,25000.0,14,6
|
||||
mission03,on,160,25000.0,17,7
|
||||
mission03,on,170,24685.0,24,7
|
||||
mission03,on,180,24640.0,23,7
|
||||
mission03,on,190,24640.0,27,7
|
||||
mission03,on,200,24460.0,26,8
|
||||
mission03,on,210,24100.0,28,8
|
||||
mission03,on,220,24100.0,29,9
|
||||
mission03,on,230,24100.0,28,11
|
||||
mission03,on,230,24100.0,28,11
|
||||
mission03,on,240,23810.0,35,11
|
||||
mission03,on,240,23810.0,36,11
|
||||
mission03,on,250,23630.0,36,12
|
||||
mission03,on,250,23630.0,36,12
|
||||
mission03,on,260,23630.0,38,13
|
||||
mission03,on,260,23630.0,38,13
|
||||
mission03,on,270,22998.0,39,13
|
||||
mission03,on,270,22908.0,39,13
|
||||
mission03,on,280,22197.5,40,13
|
||||
mission03,on,280,22188.5,40,13
|
||||
mission03,on,290,21602.4,38,13
|
||||
mission03,on,290,21602.4,38,13
|
||||
mission03,on,300,21042.4,39,13
|
||||
mission03,on,300,20975.8,39,13
|
||||
mission03,on,310,20529.1,41,15
|
||||
mission03,on,310,20529.1,41,15
|
||||
mission03,on,320,19812.5,38,17
|
||||
mission03,on,320,19767.5,38,17
|
||||
mission03,on,330,19434.2,39,18
|
||||
mission03,on,340,18785.9,37,19
|
||||
mission03,on,350,18339.2,37,19
|
||||
mission03,on,360,17528.1,38,19
|
||||
mission03,on,370,16901.5,37,19
|
||||
mission03,on,380,16260.7,35,19
|
||||
mission03,on,390,15708.2,37,19
|
||||
mission03,on,400,15346.2,37,19
|
||||
mission03,on,410,14412.1,36,20
|
||||
mission03,on,420,13951.9,37,22
|
||||
mission03,on,430,13211.9,35,23
|
||||
|
BIN
docs/re/captures/f101-index-shift-before-after.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/re/captures/filepad-flight-stage01.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/re/captures/filepad-mainmenu.png
Normal file
|
After Width: | Height: | Size: 938 KiB |
BIN
docs/re/captures/hangar-loadout-screen.png
Normal file
|
After Width: | Height: | Size: 918 KiB |
BIN
docs/re/captures/mission-select-all-story-unlocked.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/re/captures/mission-select-ends-at-stage16.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/re/captures/mission-select-stage01-only.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/re/captures/mission-select-stage01-record.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |