Compare commits
111 Commits
auto/re-au
...
auto/re-sa
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b618f500f | |||
| 7cd9fb8e0b | |||
| 19b338d9d8 | |||
| d674cddad4 | |||
| 0434afa6a6 | |||
| 2ed475868b | |||
| be42e998bc | |||
| 71adafe7f7 | |||
| a3868fab5b | |||
| bab2f9e6ca | |||
| f4f3bcb20a | |||
| 892f1e3979 | |||
| 3322202732 | |||
| a836f8e9ea | |||
| 4d8c1b9457 | |||
| 33a7bf836b | |||
| c79c0f6ccf | |||
| e8434541f2 | |||
| 1d8b783d26 | |||
| cdaeac8e1b | |||
| 0625976ed7 | |||
| 095816834a | |||
| 27fd9f186f | |||
| 85ad540a7e | |||
| f340815db6 | |||
| 9d33345560 | |||
| 2f0ce19f68 | |||
| f835705b48 | |||
| ee2b98341d | |||
| 6634d79c2e | |||
| c9c98cf942 | |||
| db8104ab99 | |||
| bb0c94c0ce | |||
| fd34d350f2 | |||
| 55e1fe3783 | |||
| 7f1ac41563 | |||
| d83bdd3b68 | |||
| 1815490d01 | |||
| b58f42e46d | |||
| df7a10749a | |||
| 845b63d7dd | |||
| dde52c02e7 | |||
| 51723af59a | |||
| 76a433f75c | |||
| 26f1bdf6fd | |||
| 3245aca8cb | |||
| aa82d293a9 | |||
| 6d96446735 | |||
| f655c90b0c | |||
| aedad811bc | |||
| 64d372c7e8 | |||
| 0f9c95c52e | |||
| 343a26ecc0 | |||
| 27a0701e0d | |||
| f18d5919f7 | |||
| d660705c47 | |||
| 7c514e3a57 | |||
| b5a4764567 | |||
| f34280476d | |||
| 140856b3f9 | |||
| 4097e8503f | |||
| c9916bcb41 | |||
| c5a5065389 | |||
| 5b8b5e0ab1 | |||
| d9c69c566c | |||
| d9ffc223bc | |||
| ac10d3bd19 | |||
| 9228255bfa | |||
| 35e2d1067f | |||
| de357ffdc9 | |||
| f65387143e | |||
| dd8770826b | |||
| 8814df843b | |||
| 8b7c9da067 | |||
| a7905d0f6b | |||
| 671891f9bf | |||
| 38f58ea559 | |||
| bb9e75fe6a | |||
| df45a1986b | |||
| 98b3cf56ac | |||
| bc42c0970e | |||
| 2da9b8a9b8 | |||
| eac3435cc7 | |||
| 6e0fbe52a6 | |||
| 841156a95d | |||
| dc97dc215d | |||
| f5f95be0a1 | |||
| f88f4f6847 | |||
| 6ebf2ba00a | |||
| f0ceda1c6f | |||
| 5cc995e864 | |||
| 882dbc2ae2 | |||
| 398e8ae0af | |||
| 5c3e3dfe47 | |||
| f4d59c5783 | |||
| 530555de9f | |||
| 69b4a2e569 | |||
| ca500c171e | |||
| 3d9f21f030 | |||
| 1d4b35df0f | |||
|
|
bbfeb1c387 | ||
|
|
695351dfc4 | ||
|
|
4821ba7fea | ||
|
|
58f421d896 | ||
|
|
9f41fe08e9 | ||
|
|
95ac545b2b | ||
|
|
ab8f5307ff | ||
|
|
76f463b611 | ||
|
|
3f6efadf9e | ||
|
|
402985adbf | ||
|
|
c277e42c92 |
@@ -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
|
||||
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());
|
||||
}
|
||||
}
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
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[..]
|
||||
);
|
||||
}
|
||||
}
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
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!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -115,6 +115,12 @@ pub struct GameMesh {
|
||||
pub indices: Vec<u32>,
|
||||
/// Sub-mesh / node name from the descriptor, when available.
|
||||
pub name: Option<String>,
|
||||
/// Byte offset of this sub-mesh's vertex buffer inside the container, when
|
||||
/// the decode path knows it. The content-anchored stage path does — and a
|
||||
/// runtime capture names the same offset (a draw's `vbase` is this plus the
|
||||
/// container's load address), so this is what makes an anchor checkable
|
||||
/// against ground truth. See `examples/shared_vbase_check.rs`.
|
||||
pub vbuf_offset: Option<usize>,
|
||||
}
|
||||
|
||||
/// A model = the set of sub-meshes recovered from one XPR2 container's first
|
||||
@@ -376,6 +382,7 @@ impl Xbg7Model {
|
||||
uvs,
|
||||
indices,
|
||||
name: None,
|
||||
vbuf_offset: Some(vb),
|
||||
});
|
||||
off = align16(ve) - base;
|
||||
}
|
||||
@@ -456,11 +463,32 @@ impl Xbg7Model {
|
||||
Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None)
|
||||
}
|
||||
|
||||
/// Decode the whole container (cached), then hand back the requested subset.
|
||||
///
|
||||
/// The decode itself must always see every resource — distinct assignment
|
||||
/// resolves collisions against the whole population, and pruning first made
|
||||
/// the answer depend on the request (see the module history). That makes a
|
||||
/// single-resource query as expensive as a full decode, so the full decode is
|
||||
/// memoised per container: the viewer asks for one ship's parts at a time and
|
||||
/// would otherwise re-anchor 6 000 resources per ship.
|
||||
fn anchor_models_filtered(
|
||||
bytes: &[u8],
|
||||
min_consistency: f32,
|
||||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||
wanted: Option<&std::collections::HashSet<String>>,
|
||||
) -> Vec<Xbg7Model> {
|
||||
let Some(w) = wanted else {
|
||||
return Self::anchor_models_uncached(bytes, min_consistency, should_cancel, None);
|
||||
};
|
||||
let full = full_decode_cached(bytes, min_consistency, should_cancel);
|
||||
full.iter().filter(|m| w.contains(&m.name)).cloned().collect()
|
||||
}
|
||||
|
||||
fn anchor_models_uncached(
|
||||
bytes: &[u8],
|
||||
min_consistency: f32,
|
||||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||
wanted: Option<&std::collections::HashSet<String>>,
|
||||
) -> Vec<Xbg7Model> {
|
||||
let mut out = Vec::new();
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
@@ -516,11 +544,13 @@ impl Xbg7Model {
|
||||
}
|
||||
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
if let Some(w) = wanted {
|
||||
if !w.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// NOTE: `wanted` is NOT applied here. Distinct assignment resolves
|
||||
// collisions against the whole set of resources, so pruning first
|
||||
// made a filtered decode depend on *which* subset was asked for —
|
||||
// measured 2026-08-12: 27 of 356 resources in `Stage_S02` came out
|
||||
// at a different offset when requested alone. The filter is applied
|
||||
// to the OUTPUT instead, so a subset is always a subset of the
|
||||
// container's own answer.
|
||||
resources.push(Res {
|
||||
name,
|
||||
markers,
|
||||
@@ -556,6 +586,7 @@ impl Xbg7Model {
|
||||
// preserves resource order, so the output is identical to the sequential
|
||||
// decode. `should_cancel()` is polled per resource so a superseded load
|
||||
// stops promptly.
|
||||
let empty_taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
let decode_one = |r: &Res| -> Option<Xbg7Model> {
|
||||
if should_cancel() {
|
||||
return None;
|
||||
@@ -567,13 +598,23 @@ impl Xbg7Model {
|
||||
// simple props take this path; `min_consistency` behaviour is
|
||||
// exactly as before.
|
||||
let (vtx_count, index_count) = r.markers[0];
|
||||
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
|
||||
anchor_pool_mesh(
|
||||
bytes,
|
||||
starts,
|
||||
index_count,
|
||||
vtx_count,
|
||||
&r.decl,
|
||||
min_consistency,
|
||||
&empty_taken,
|
||||
0,
|
||||
)
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
// Several sub-meshes sharing grouped index/vertex pools → the
|
||||
// deterministic grouped-pool decode (hero ships et al.).
|
||||
let grouped = anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl);
|
||||
let grouped =
|
||||
anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl, &empty_taken);
|
||||
if !grouped.is_empty() {
|
||||
grouped
|
||||
} else {
|
||||
@@ -582,7 +623,16 @@ impl Xbg7Model {
|
||||
// to the original single-block adjacency anchor on the first
|
||||
// marker so coverage is never *below* the pre-grouped decode.
|
||||
let (vtx_count, index_count) = r.markers[0];
|
||||
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
|
||||
anchor_pool_mesh(
|
||||
bytes,
|
||||
starts,
|
||||
index_count,
|
||||
vtx_count,
|
||||
&r.decl,
|
||||
min_consistency,
|
||||
&empty_taken,
|
||||
0,
|
||||
)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
@@ -594,18 +644,585 @@ impl Xbg7Model {
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let decoded: Vec<(usize, Xbg7Model)> = {
|
||||
use rayon::prelude::*;
|
||||
out = resources.par_iter().filter_map(decode_one).collect();
|
||||
}
|
||||
resources
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
|
||||
.collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out = resources.iter().filter_map(decode_one).collect();
|
||||
let decoded: Vec<(usize, Xbg7Model)> = resources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
|
||||
.collect();
|
||||
|
||||
// ── Distinct assignment ──────────────────────────────────────────
|
||||
//
|
||||
// Selection above is per-resource and greedy: each takes the first
|
||||
// candidate that validates, so two resources can claim ONE buffer while
|
||||
// a valid buffer sits unused. A runtime capture proves that is wrong for
|
||||
// the mirrored `e106_bdy_0{1,2}_l` twins — the container holds both
|
||||
// halves (`0x3b3ee8` and its X-mirror `0x3c55d8`) and the engine draws
|
||||
// each from its own — and both offsets validate for both names, so the
|
||||
// correct block merely lost the first-match race.
|
||||
//
|
||||
// So: walk the decodes in container order, let the first claimant keep a
|
||||
// buffer, and re-anchor any later resource that wanted the same one,
|
||||
// skipping everything already claimed. Only single-sub-mesh resources
|
||||
// (the adjacency-anchor path) take part; grouped-pool models are left
|
||||
// exactly as they were.
|
||||
let mut taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
// Opt-in monotone pass: the last offset handed to each (stride, vtx, idx)
|
||||
// signature. Resources that share a signature are interchangeable to the
|
||||
// validator — many containers hold dozens of identical 24-vertex bound
|
||||
// boxes — so file order is the only thing that can pin which is which.
|
||||
let monotone = std::env::var("XBG7_MONOTONE").is_ok();
|
||||
let mut last_by_sig: std::collections::HashMap<(usize, usize, usize), usize> =
|
||||
std::collections::HashMap::new();
|
||||
let mut models: Vec<Xbg7Model> = Vec::with_capacity(decoded.len());
|
||||
for (i, mut m) in decoded {
|
||||
let r = &resources[i];
|
||||
let starts = &starts_by_stride[&r.decl.stride];
|
||||
if monotone && m.meshes.len() == 1 && r.markers.len() == 1 {
|
||||
let (vc, ic) = r.markers[0];
|
||||
let sig = (r.decl.stride, vc, ic);
|
||||
let floor = last_by_sig.get(&sig).map_or(0, |o| o + 1);
|
||||
if m.meshes[0].vbuf_offset.map_or(false, |o| o < floor) {
|
||||
if let Some(alt) = anchor_pool_mesh(
|
||||
bytes,
|
||||
starts,
|
||||
ic,
|
||||
vc,
|
||||
&r.decl,
|
||||
min_consistency,
|
||||
&taken,
|
||||
floor,
|
||||
) {
|
||||
m.meshes[0] = alt;
|
||||
}
|
||||
}
|
||||
if let Some(o) = m.meshes[0].vbuf_offset {
|
||||
last_by_sig.insert(sig, o);
|
||||
}
|
||||
}
|
||||
if let Some(vb) = m.meshes[0].vbuf_offset {
|
||||
if taken.contains(&vb) {
|
||||
if m.meshes.len() == 1 && r.markers.len() == 1 {
|
||||
let (vtx_count, index_count) = r.markers[0];
|
||||
if let Some(alt) = anchor_pool_mesh(
|
||||
bytes,
|
||||
starts,
|
||||
index_count,
|
||||
vtx_count,
|
||||
&r.decl,
|
||||
min_consistency,
|
||||
&taken,
|
||||
0,
|
||||
) {
|
||||
m.meshes[0] = alt;
|
||||
}
|
||||
} else {
|
||||
// Grouped pool: re-place the WHOLE pool past everything
|
||||
// claimed, or keep what we had.
|
||||
let alt = anchor_grouped_meshes(
|
||||
bytes, data_base, starts, &r.markers, &r.decl, &taken,
|
||||
);
|
||||
if !alt.is_empty() {
|
||||
m.meshes = alt;
|
||||
}
|
||||
}
|
||||
// No free candidate → keep the collided decode rather than
|
||||
// drop the resource; coverage never regresses.
|
||||
}
|
||||
for sub in &m.meshes {
|
||||
if let Some(vb2) = sub.vbuf_offset {
|
||||
taken.insert(vb2);
|
||||
}
|
||||
}
|
||||
}
|
||||
models.push(m);
|
||||
}
|
||||
if let Some(w) = wanted {
|
||||
models.retain(|m| w.contains(&m.name));
|
||||
}
|
||||
out = models;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic: would the anchor scan accept `vb` as the vertex buffer of the
|
||||
/// named resource's first sub-mesh? A runtime capture proves which offset the
|
||||
/// engine drew from, so this answers whether the correct block is *acceptable*
|
||||
/// and merely lost the first-match race, or is rejected outright by
|
||||
/// `validate_block`. Returns `(vtx_count, index_count, accepted_pad)`.
|
||||
pub fn debug_try_anchor(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
vb: usize,
|
||||
max_pad: usize,
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return None;
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||||
const DIR_BASE: usize = 0x10;
|
||||
for _ in 0..header.num_resources {
|
||||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
|
||||
if &e.type_tag != b"XBG7" {
|
||||
continue;
|
||||
}
|
||||
let desc = e.data_offset as usize + DIR_BASE;
|
||||
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||||
if desc >= bytes.len() || desc_end <= desc {
|
||||
continue;
|
||||
}
|
||||
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
if rname != name {
|
||||
continue;
|
||||
}
|
||||
let d = &bytes[desc..desc_end];
|
||||
let markers = all_index_markers(d);
|
||||
let decl = parse_vertex_decl(d)?;
|
||||
let (vtx_count, index_count) = *markers.first()?;
|
||||
let idx_bytes = index_count * 2;
|
||||
for pad in 0..=max_pad {
|
||||
if vb < idx_bytes + pad {
|
||||
continue;
|
||||
}
|
||||
let mc = if pad == 0 { 0.0 } else { 0.85 };
|
||||
if validate_block(
|
||||
bytes,
|
||||
vb - idx_bytes - pad,
|
||||
vb,
|
||||
vtx_count,
|
||||
index_count,
|
||||
&decl,
|
||||
mc,
|
||||
true,
|
||||
) {
|
||||
return Some((vtx_count, index_count, pad));
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// How far did the anchor scan get for a resource it failed to place?
|
||||
///
|
||||
/// Runs the same candidate loop the decoder runs and keeps the **furthest**
|
||||
/// rejection — the candidate that passed the most gates before failing. Over the
|
||||
/// resources that never decode, the distribution of these says which gate to
|
||||
/// work on, instead of tuning one threshold and re-measuring.
|
||||
pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)> {
|
||||
let (decl, markers) = decl_of(bytes, name)?;
|
||||
let starts = debug_vertex_run_starts(bytes, decl.stride);
|
||||
#[allow(clippy::type_complexity)]
|
||||
let rank = |why: &str| -> usize {
|
||||
if why.contains("out of range") {
|
||||
1
|
||||
} else if why.contains("buffer not covered") {
|
||||
2
|
||||
} else if why.contains("not finite") || why.contains("degenerate") {
|
||||
3
|
||||
} else if why.contains("connectivity") {
|
||||
4
|
||||
} else if why.contains("winding") {
|
||||
5
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
// A grouped-pool resource is placed by its PIVOT sub-mesh, so it needs the
|
||||
// grouped candidate loop; running the single-block loop on `markers[0]`
|
||||
// would report a gate the decoder never consulted for it.
|
||||
if markers.len() > 1 {
|
||||
let n = markers.len();
|
||||
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
|
||||
for &(_, ic) in &markers {
|
||||
rel_ib.push(acc_i);
|
||||
acc_i = align4(acc_i + ic * 2);
|
||||
}
|
||||
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
|
||||
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
|
||||
let (vck, ick) = markers[kmax];
|
||||
let off_v: usize = markers.iter().take(kmax).map(|&(vc, _)| vc * decl.stride).sum();
|
||||
let mut best = (0usize, String::from("no pool start reached any gate"));
|
||||
for &vb0 in &starts {
|
||||
for pad in 0..=3usize {
|
||||
if vb0 < span + pad {
|
||||
continue;
|
||||
}
|
||||
let ib0 = vb0 - span - pad;
|
||||
match validate_block_report(
|
||||
bytes,
|
||||
ib0 + rel_ib[kmax],
|
||||
vb0 + off_v,
|
||||
vck,
|
||||
ick,
|
||||
&decl,
|
||||
0.85,
|
||||
true,
|
||||
) {
|
||||
Ok(()) => return None, // the pivot would have anchored
|
||||
Err(why) => {
|
||||
let r = rank(&why);
|
||||
if r > best.0 {
|
||||
best = (r, why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(best);
|
||||
}
|
||||
let (vtx_count, index_count) = *markers.first()?;
|
||||
let idx_bytes = index_count * 2;
|
||||
let mut best = (0usize, String::from("no candidate reached any gate"));
|
||||
for &vb in &starts {
|
||||
for pad in 0..=3usize {
|
||||
if vb < idx_bytes + pad {
|
||||
continue;
|
||||
}
|
||||
// Mirror production exactly: the pad-0 path now carries the winding
|
||||
// floor too. Reporting at 0.0 would accept blocks the decoder
|
||||
// rejects and point at the wrong gate.
|
||||
let mc = if pad == 0 { pad0_consistency() } else { 0.85 };
|
||||
if let Err(why) = validate_block_report(
|
||||
bytes,
|
||||
vb - idx_bytes - pad,
|
||||
vb,
|
||||
vtx_count,
|
||||
index_count,
|
||||
&decl,
|
||||
mc,
|
||||
true,
|
||||
) {
|
||||
let r = rank(&why);
|
||||
if r > best.0 {
|
||||
best = (r, why);
|
||||
}
|
||||
} else {
|
||||
return None; // it would have decoded — not a miss
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(best)
|
||||
}
|
||||
|
||||
/// Diagnostic: why does the decoder refuse a grouped-pool resource at a given
|
||||
/// pool start? Recomputes the pool layout exactly as [`anchor_grouped_meshes`]
|
||||
/// does and reports the pivot sub-mesh's verdict for each index/vertex pad —
|
||||
/// so a capture-proven pool that the decoder rejects names the gate to fix.
|
||||
pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec<String> {
|
||||
let Some((decl, markers)) = decl_of(bytes, name) else {
|
||||
return vec!["no such XBG7 resource".into()];
|
||||
};
|
||||
let n = markers.len();
|
||||
if n == 0 {
|
||||
return vec!["no index markers".into()];
|
||||
}
|
||||
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
|
||||
for &(_, ic) in &markers {
|
||||
rel_ib.push(acc_i);
|
||||
acc_i = align4(acc_i + ic * 2);
|
||||
}
|
||||
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
|
||||
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
|
||||
let (vck, ick) = markers[kmax];
|
||||
let mut off_v = 0usize;
|
||||
for &(vc, _) in markers.iter().take(kmax) {
|
||||
off_v += vc * decl.stride;
|
||||
}
|
||||
let mut out = vec![format!(
|
||||
"{name}: {n} sub-meshes, pivot #{kmax} ({vck} verts, {ick} idx), pool span {span}"
|
||||
)];
|
||||
for pad in 0..=3usize {
|
||||
if vb0 < span + pad {
|
||||
out.push(format!(" pad {pad}: pool start is before the index pool"));
|
||||
continue;
|
||||
}
|
||||
let ib0 = vb0 - span - pad;
|
||||
// Same gates the production pivot test uses: strict winding (0.85)
|
||||
// AND connectivity. Reporting at 0.0 would accept blocks the decoder
|
||||
// rejects and send the reader chasing the wrong gate.
|
||||
let verdict = validate_block_report(
|
||||
bytes,
|
||||
ib0 + rel_ib[kmax],
|
||||
vb0 + off_v,
|
||||
vck,
|
||||
ick,
|
||||
&decl,
|
||||
0.85,
|
||||
true,
|
||||
);
|
||||
out.push(match verdict {
|
||||
Ok(()) => format!(" pad {pad}: ACCEPTED"),
|
||||
Err(why) => format!(" pad {pad}: {why}"),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Diagnostic: for a resource whose vertex buffer is *known* (a runtime capture
|
||||
/// names it), where could its index buffer be? The anchor scan assumes the index
|
||||
/// buffer sits immediately before the vertex buffer; this scans the whole
|
||||
/// container instead and returns every offset that validates as this resource's
|
||||
/// index buffer. An empty result means the block is unreadable at that `vb` for
|
||||
/// another reason; a hit far from `vb` means the adjacency assumption is what
|
||||
/// fails. Returns `(ib_offset, signed distance vb - ib)` pairs.
|
||||
pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usize, i64)> {
|
||||
let Some(decl) = decl_of(bytes, name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let markers = decl.1;
|
||||
let decl = decl.0;
|
||||
let Some(&(vtx_count, index_count)) = markers.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let end = bytes.len().saturating_sub(index_count * 2);
|
||||
let mut ib = 0usize;
|
||||
while ib < end {
|
||||
// Cheap prefilter: the first few indices must be in range, and a real
|
||||
// index buffer is not a run of zeros.
|
||||
let ok = (0..6).all(|k| (be16(bytes, ib + k * 2) as usize) < vtx_count)
|
||||
&& (0..6).any(|k| be16(bytes, ib + k * 2) != 0);
|
||||
// `SOFT_IB=1` drops the connectivity requirement, to separate "no index
|
||||
// buffer fits" from "our connectivity test is too strict".
|
||||
let strict = std::env::var("SOFT_IB").is_err();
|
||||
if ok && validate_block(bytes, ib, vb, vtx_count, index_count, &decl, 0.0, strict) {
|
||||
out.push((ib, vb as i64 - ib as i64));
|
||||
}
|
||||
ib += 2;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The connectivity cap: a searched block whose mean triangle edge exceeds this
|
||||
/// fraction of its bounding-box diagonal is rejected.
|
||||
///
|
||||
/// **1.0 since 2026-08-12 — effectively inert.** The winding-consistency gate
|
||||
/// ([`pad0_consistency`]) replaced this as the primary structural test: it is an
|
||||
/// objective topology signal rather than a shape heuristic, and swapping them
|
||||
/// decodes **143 more** resources with **17 fewer** cross-container
|
||||
/// inconsistencies while the capture oracle stays at 46/46. The cap is kept as a
|
||||
/// knob and a backstop against absurd blocks. History below.
|
||||
///
|
||||
/// **0.42 from 2026-08-12**, raised from 0.28 on runtime evidence. A capture
|
||||
/// names the blocks the engine really draws, and the old cap rejected one of them
|
||||
/// outright — `e106_eng_02_l`, a 24-triangle LOD, measures **0.417**, because a
|
||||
/// coarse mesh's edges *are* a large fraction of its own size. Swept against the
|
||||
/// 46 capture-named `Stage_S02` buffers (with distinct anchor assignment): 0.28
|
||||
/// anchors 40 exactly and leaves 4 unclaimed, 0.42 anchors **45 and leaves none**,
|
||||
/// and nothing above 0.42 improves further — so this is the least permissive
|
||||
/// value that captures the whole measured gain. `XBG7_EDGE_CAP` overrides it (see
|
||||
/// docs/re/structures/xbg7-mesh.md).
|
||||
fn edge_cap() -> f32 {
|
||||
std::env::var("XBG7_EDGE_CAP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Winding floor for the grouped-pool **pivot** (default `0.85`). The single-block
|
||||
/// path settled at 0.70 on measurement; this is the same question for the pivot,
|
||||
/// and `XBG7_GROUPED_CONSISTENCY` sweeps it.
|
||||
fn grouped_consistency() -> f32 {
|
||||
std::env::var("XBG7_GROUPED_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.85)
|
||||
}
|
||||
|
||||
/// Coverage requirement, as `max_index + N >= vtx_count`. **`1` since
|
||||
/// 2026-08-12** — i.e. the indices must reach the pool's last vertex exactly.
|
||||
///
|
||||
/// The old `4` tolerated three unreferenced tail vertices, and that slack was a
|
||||
/// mis-anchor tell rather than a real variation: 8 580 of 8 629 decoded
|
||||
/// sub-meshes cover their pool exactly, and `e106_bdy_03` in `Stage_S02` was one
|
||||
/// of the few that did not — slack 3, decoding to a 600×1600×998 slab visible in
|
||||
/// a render, where three other containers give 276×236×941. Requiring exact
|
||||
/// coverage moves it onto the block those containers agree on. Costs 3 resources
|
||||
/// disc-wide; capture oracle unchanged at 46/46. `XBG7_COVER_SLACK` overrides
|
||||
/// (note `0` rejects everything — the comparison is `max_idx + N >= vtx_count`).
|
||||
fn cover_slack() -> usize {
|
||||
std::env::var("XBG7_COVER_SLACK").ok().and_then(|v| v.parse().ok()).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Smallest bounding-box extent a block may have (default `0.5`). An absolute
|
||||
/// floor on a format with no unit convention is a scale assumption, so it is a
|
||||
/// knob: `XBG7_MIN_EXTENT`.
|
||||
fn min_extent() -> f32 {
|
||||
std::env::var("XBG7_MIN_EXTENT").ok().and_then(|v| v.parse().ok()).unwrap_or(0.5)
|
||||
}
|
||||
|
||||
/// Use a scale-free collinearity test for degeneracy instead of the absolute
|
||||
/// triangle-area one (`XBG7_REL_DEGEN=1`). More principled in the abstract — an
|
||||
/// absolute area threshold calls a small object's every triangle degenerate —
|
||||
/// but measured on this disc it decodes **no more** resources and raises
|
||||
/// cross-container inconsistency 39 → 44, so it is **not** the default.
|
||||
fn rel_degen() -> bool {
|
||||
std::env::var("XBG7_REL_DEGEN").is_ok()
|
||||
}
|
||||
|
||||
/// Winding-consistency floor for the pad-0 single-block anchor.
|
||||
///
|
||||
/// **0.70 since 2026-08-12.** A triangle's face normal should agree with its
|
||||
/// vertices' stored normals almost always (≈1.0) or almost never (≈0.0, inverted
|
||||
/// winding); a mis-carve wires arbitrary vertices and lands near 0.5. Gating on
|
||||
/// `max(na, 1−na)` is therefore an objective topology test, where the
|
||||
/// connectivity cap it replaces is a shape heuristic that provably rejected a
|
||||
/// capture-proven block. Measured over the disc, with connectivity inert:
|
||||
///
|
||||
/// | floor | resources decoded | shared inconsistent |
|
||||
/// |---|---|---|
|
||||
/// | 0.60 | 6 214 | 53 |
|
||||
/// | **0.70** | **6 212** | **39** |
|
||||
/// | 0.80 | 5 770 | 1 |
|
||||
/// | 0.85 | 5 770 | 0 |
|
||||
///
|
||||
/// 0.70 dominates the previous connectivity-only default (6 069 / 56) on both
|
||||
/// axes with the capture oracle unchanged, so it ships. The cliff at 0.80 buys
|
||||
/// perfect cross-container consistency for 442 resources — recorded rather than
|
||||
/// taken, since consistency is the weaker witness (see the docs).
|
||||
fn pad0_consistency() -> f32 {
|
||||
std::env::var("XBG7_PAD0_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.70)
|
||||
}
|
||||
|
||||
/// Triangle count below which the looser [`small_cap`] applies. `0` (default)
|
||||
/// disables the split, so the flat [`edge_cap`] governs every block.
|
||||
fn small_tris() -> usize {
|
||||
std::env::var("XBG7_SMALL_TRIS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The connectivity cap for blocks below [`small_tris`] triangles.
|
||||
fn small_cap() -> f32 {
|
||||
std::env::var("XBG7_EDGE_CAP_SMALL").ok().and_then(|v| v.parse().ok()).unwrap_or(0.45)
|
||||
}
|
||||
|
||||
/// Internal: the descriptor parameters the diagnostics need.
|
||||
fn decl_of(bytes: &[u8], name: &str) -> Option<(VertexDecl, Vec<(usize, usize)>)> {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return None;
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||||
const DIR_BASE: usize = 0x10;
|
||||
for _ in 0..header.num_resources {
|
||||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
|
||||
if &e.type_tag != b"XBG7" {
|
||||
continue;
|
||||
}
|
||||
let desc = e.data_offset as usize + DIR_BASE;
|
||||
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||||
if desc >= bytes.len() || desc_end <= desc {
|
||||
continue;
|
||||
}
|
||||
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
if rname != name {
|
||||
continue;
|
||||
}
|
||||
let d = &bytes[desc..desc_end];
|
||||
return Some((parse_vertex_decl(d)?, all_index_markers(d)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Diagnostic: a resource's `(vtx_count, idx_count)` markers and vertex stride,
|
||||
/// as the stage anchor scan reads them from the descriptor.
|
||||
pub fn debug_resource_params(bytes: &[u8], name: &str) -> Option<(Vec<(usize, usize)>, usize)> {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return None;
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||||
const DIR_BASE: usize = 0x10;
|
||||
for _ in 0..header.num_resources {
|
||||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
|
||||
if &e.type_tag != b"XBG7" {
|
||||
continue;
|
||||
}
|
||||
let desc = e.data_offset as usize + DIR_BASE;
|
||||
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||||
if desc >= bytes.len() || desc_end <= desc {
|
||||
continue;
|
||||
}
|
||||
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
if rname != name {
|
||||
continue;
|
||||
}
|
||||
let d = &bytes[desc..desc_end];
|
||||
return Some((all_index_markers(d), parse_vertex_decl(d)?.stride));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Memoised whole-container decode, keyed by a cheap fingerprint of the bytes
|
||||
/// plus the consistency setting. Holds the last few containers; a stage decode is
|
||||
/// a handful of MB, and the alternative is re-anchoring every resource for every
|
||||
/// ship the viewer shows.
|
||||
fn full_decode_cached(
|
||||
bytes: &[u8],
|
||||
min_consistency: f32,
|
||||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||
) -> std::sync::Arc<Vec<Xbg7Model>> {
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
// Fingerprint: length plus three sampled 4 KB windows. Two different
|
||||
// containers agreeing on all of that is not a case this format produces.
|
||||
let mut fp: u64 = 0xcbf2_9ce4_8422_2325 ^ bytes.len() as u64;
|
||||
let windows = [0usize, bytes.len() / 2, bytes.len().saturating_sub(4096)];
|
||||
for w in windows {
|
||||
for b in bytes.iter().skip(w).take(4096) {
|
||||
fp = (fp ^ *b as u64).wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
}
|
||||
let key = (fp, min_consistency.to_bits());
|
||||
#[allow(clippy::type_complexity)]
|
||||
static CACHE: OnceLock<Mutex<Vec<((u64, u32), Arc<Vec<Xbg7Model>>)>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
|
||||
if let Ok(c) = cache.lock() {
|
||||
if let Some((_, v)) = c.iter().find(|(k, _)| *k == key) {
|
||||
return Arc::clone(v);
|
||||
}
|
||||
}
|
||||
let models = Arc::new(Xbg7Model::anchor_models_uncached(
|
||||
bytes,
|
||||
min_consistency,
|
||||
should_cancel,
|
||||
None,
|
||||
));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.push((key, Arc::clone(&models)));
|
||||
if c.len() > 4 {
|
||||
c.remove(0);
|
||||
}
|
||||
}
|
||||
models
|
||||
}
|
||||
|
||||
/// Diagnostic: the candidate vertex-buffer starts the stage anchor scan will
|
||||
/// consider for a given `stride`, for one container. A runtime capture names the
|
||||
/// offsets the engine really drew from (see `examples/shared_vbase_check.rs`), so
|
||||
/// asking whether a proven offset is in this list separates the two possible
|
||||
/// root causes of a mis-anchor: **absent** ⇒ the run scan misses it, **present**
|
||||
/// ⇒ the scan sees it and the selection picks another.
|
||||
pub fn debug_vertex_run_starts(bytes: &[u8], stride: usize) -> Vec<usize> {
|
||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let Ok(header) = Xpr2Header::read(&mut cur) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let data_base = header.header_size as usize;
|
||||
if data_base >= bytes.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
vertex_run_starts(bytes, data_base, stride)
|
||||
}
|
||||
|
||||
/// Scan the data section for offsets that begin a `stride`-sized unit-normal
|
||||
/// vertex run (NORMAL is `f16×4` at vertex offset +12). A run *start* is an
|
||||
/// offset whose normal is unit while the preceding stride slot's is not — i.e.
|
||||
@@ -652,9 +1269,23 @@ fn anchor_pool_mesh(
|
||||
vtx_count: usize,
|
||||
decl: &VertexDecl,
|
||||
min_consistency: f32,
|
||||
taken: &std::collections::HashSet<usize>,
|
||||
min_vb: usize,
|
||||
) -> Option<GameMesh> {
|
||||
let idx_bytes = index_count * 2;
|
||||
for &vb in starts {
|
||||
// Monotone assignment (opt-in): resources of one signature are laid out
|
||||
// in descriptor order, so a later one may not take an earlier block.
|
||||
if vb < min_vb {
|
||||
continue;
|
||||
}
|
||||
// A buffer another resource already claimed is not a candidate: the
|
||||
// engine draws each part from its own buffer (proved for the mirrored
|
||||
// `e106_bdy_0{1,2}_l` twins by a runtime capture), so two resources
|
||||
// landing on one offset means at least one of them is wrong.
|
||||
if taken.contains(&vb) {
|
||||
continue;
|
||||
}
|
||||
// The index buffer sits just before the vertex buffer, which is 4-byte
|
||||
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb −
|
||||
// idx_bytes − pad`). pad 0 is the immediate-adjacency case (all stages so
|
||||
@@ -667,8 +1298,14 @@ fn anchor_pool_mesh(
|
||||
continue;
|
||||
}
|
||||
let ib = vb - idx_bytes - pad;
|
||||
// `XBG7_PAD0_CONSISTENCY` adds a winding requirement to the pad-0
|
||||
// path, which has none by default. Winding agreement is an objective
|
||||
// topology signal (≈1.0 or ≈0.0 for a real mesh, ≈0.5 for a
|
||||
// mis-carve) where the connectivity cap is a shape heuristic with a
|
||||
// capture-proven false positive — so it is the candidate replacement
|
||||
// for that cap. Off by default; see docs/re/structures/xbg7-mesh.md.
|
||||
let mc = if pad == 0 {
|
||||
min_consistency
|
||||
min_consistency.max(pad0_consistency())
|
||||
} else {
|
||||
min_consistency.max(0.85)
|
||||
};
|
||||
@@ -699,17 +1336,43 @@ fn validate_block(
|
||||
min_consistency: f32,
|
||||
strict_connectivity: bool,
|
||||
) -> bool {
|
||||
validate_block_report(
|
||||
bytes,
|
||||
ib,
|
||||
vb,
|
||||
vtx_count,
|
||||
index_count,
|
||||
decl,
|
||||
min_consistency,
|
||||
strict_connectivity,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// [`validate_block`], but naming the gate that rejected a block. A runtime
|
||||
/// capture can prove a block is real; when the decoder still refuses it, this
|
||||
/// says which test is wrong rather than leaving a threshold to be guessed at.
|
||||
fn validate_block_report(
|
||||
bytes: &[u8],
|
||||
ib: usize,
|
||||
vb: usize,
|
||||
vtx_count: usize,
|
||||
index_count: usize,
|
||||
decl: &VertexDecl,
|
||||
min_consistency: f32,
|
||||
strict_connectivity: bool,
|
||||
) -> Result<(), String> {
|
||||
let stride = decl.stride;
|
||||
let idx_bytes = index_count * 2;
|
||||
if ib + idx_bytes > bytes.len() {
|
||||
return false;
|
||||
return Err("index buffer runs past the container".into());
|
||||
}
|
||||
let vtx_bytes = match vtx_count.checked_mul(stride) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
None => return Err("vertex byte count overflows".into()),
|
||||
};
|
||||
if vb + vtx_bytes > bytes.len() {
|
||||
return false;
|
||||
return Err("vertex buffer runs past the container".into());
|
||||
}
|
||||
|
||||
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||||
@@ -717,12 +1380,14 @@ fn validate_block(
|
||||
for k in 0..index_count {
|
||||
let i = be16(bytes, ib + k * 2) as u32;
|
||||
if i >= vtx_count as u32 {
|
||||
return false;
|
||||
return Err(format!("index {i} out of range (vtx_count {vtx_count})"));
|
||||
}
|
||||
max_idx = max_idx.max(i);
|
||||
}
|
||||
if (max_idx as usize) + 4 < vtx_count {
|
||||
return false;
|
||||
if (max_idx as usize) + cover_slack() < vtx_count {
|
||||
return Err(format!(
|
||||
"indices reach only {max_idx} of {vtx_count} vertices (buffer not covered)"
|
||||
));
|
||||
}
|
||||
|
||||
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
||||
@@ -747,7 +1412,7 @@ fn validate_block(
|
||||
for (a, slot) in pc.iter_mut().enumerate() {
|
||||
let x = bef(bytes, base + a * 4);
|
||||
if !x.is_finite() || x.abs() > 1.0e6 {
|
||||
return false;
|
||||
return Err(format!("position component {x} is not finite/plausible"));
|
||||
}
|
||||
*slot = x;
|
||||
lo[a] = lo[a].min(x);
|
||||
@@ -761,7 +1426,21 @@ fn validate_block(
|
||||
u[2] * w[0] - u[0] * w[2],
|
||||
u[0] * w[1] - u[1] * w[0],
|
||||
];
|
||||
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
|
||||
// Degeneracy = collinear vertices, which is a SCALE-FREE property:
|
||||
// compare the cross-product magnitude to the two edge lengths that
|
||||
// produced it (i.e. sin of the angle between them). The old absolute
|
||||
// `area < 1e-9` test called a small object's every triangle degenerate —
|
||||
// `g005` spans 0.346 units and scored 7 of 8 — so it rejected tiny props
|
||||
// for being tiny. `XBG7_ABS_DEGEN=1` restores the absolute test.
|
||||
let cross = (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt();
|
||||
let un = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
|
||||
let wn = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
|
||||
let is_degenerate = if rel_degen() {
|
||||
cross < 1.0e-6 * un * wn || un == 0.0 || wn == 0.0
|
||||
} else {
|
||||
0.5 * cross < 1.0e-9
|
||||
};
|
||||
if is_degenerate {
|
||||
degenerate += 1;
|
||||
} else if let Some(no) = decl.normal_offset {
|
||||
// Stored-normal agreement: the face normal should point the way
|
||||
@@ -788,8 +1467,10 @@ fn validate_block(
|
||||
t += tstep;
|
||||
}
|
||||
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
||||
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||||
return false; // too flat, or >30% degenerate → not this block
|
||||
if extent < min_extent() || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||||
return Err(format!(
|
||||
"extent {extent:.3} (min 0.5), {degenerate}/{sampled} degenerate (max 30%)"
|
||||
));
|
||||
}
|
||||
// Connectivity check: a correctly-anchored mesh has triangle edges that
|
||||
// are SMALL relative to its overall size (~0.05–0.15 of the bbox
|
||||
@@ -805,8 +1486,17 @@ fn validate_block(
|
||||
.sqrt()
|
||||
.max(1e-6);
|
||||
let mean_edge = edge_sum / (sampled as f32 * 3.0);
|
||||
if mean_edge / diag > 0.28 {
|
||||
return false;
|
||||
// A COARSE block is coarse by construction: a 24-triangle LOD's edges
|
||||
// are a large fraction of its own size, which is why the flat cap has a
|
||||
// capture-proven false positive (`e106_eng_02_l`, ratio 0.417). Under
|
||||
// `XBG7_SMALL_TRIS` blocks below that triangle count get the looser
|
||||
// `XBG7_EDGE_CAP_SMALL` instead — a targeted relaxation, off by default.
|
||||
let cap = if tris < small_tris() { small_cap() } else { edge_cap() };
|
||||
if mean_edge / diag > cap {
|
||||
return Err(format!(
|
||||
"connectivity: mean_edge/diag {:.3} > cap {cap:.2}",
|
||||
mean_edge / diag
|
||||
));
|
||||
}
|
||||
}
|
||||
// Winding-consistency gate (same as `from_xpr2`): a correctly-anchored
|
||||
@@ -818,10 +1508,13 @@ fn validate_block(
|
||||
if nrm_counted > 0 && min_consistency > 0.0 {
|
||||
let na = nrm_agree as f32 / nrm_counted as f32;
|
||||
if na.max(1.0 - na) < min_consistency {
|
||||
return false;
|
||||
return Err(format!(
|
||||
"winding consistency {:.3} < {min_consistency:.2}",
|
||||
na.max(1.0 - na)
|
||||
));
|
||||
}
|
||||
}
|
||||
true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several*
|
||||
@@ -854,6 +1547,7 @@ fn anchor_grouped_meshes(
|
||||
starts: &[usize],
|
||||
markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
|
||||
decl: &VertexDecl,
|
||||
taken: &std::collections::HashSet<usize>,
|
||||
) -> Vec<GameMesh> {
|
||||
let n = markers.len();
|
||||
if n == 0 {
|
||||
@@ -889,6 +1583,11 @@ fn anchor_grouped_meshes(
|
||||
let (vck, ick) = markers[kmax];
|
||||
|
||||
for &vb0 in starts {
|
||||
// Distinct assignment: a pool another resource already claimed is not a
|
||||
// candidate (see the collision resolution in `anchor_models_filtered`).
|
||||
if taken.contains(&vb0) {
|
||||
continue;
|
||||
}
|
||||
for pad in 0..=3usize {
|
||||
if vb0 < span + pad {
|
||||
continue;
|
||||
@@ -903,7 +1602,7 @@ fn anchor_grouped_meshes(
|
||||
// collapses well below 0.85, so only the true pad/pivot passes.
|
||||
let ib_k = ib0 + rel_ib[kmax];
|
||||
let vb_k = vb0 + off_v[kmax];
|
||||
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
|
||||
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, grouped_consistency(), true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -929,7 +1628,28 @@ fn anchor_grouped_meshes(
|
||||
if !ok && i > kmax {
|
||||
break; // chain diverged — emit the validated prefix, no garbage
|
||||
}
|
||||
// Sub-meshes BEFORE the pivot are emitted even when they fail the
|
||||
// quality gates (a tiny flat lead part is legitimately poor), but
|
||||
// an index that addresses past its own vertex buffer is not a
|
||||
// quality question — it is unusable. Measured 2026-08-12: 18
|
||||
// sub-meshes disc-wide carried indices up to 364 vertices past
|
||||
// the end (`coverage_audit`), which any renderer would fault on.
|
||||
// Same two structural requirements the searched path enforces:
|
||||
// every index inside the buffer, and the indices reaching the
|
||||
// end of it. Real geometry covers its pool exactly — 8 586 of
|
||||
// 8 636 decoded sub-meshes reference their last vertex, none
|
||||
// more than 3 short (`coverage_audit`) — so a sub-mesh whose
|
||||
// indices stop well short is reading the wrong block, not a
|
||||
// sparse one.
|
||||
let mut max_idx = 0usize;
|
||||
let in_range = (0..ic).all(|k| {
|
||||
let i = be16(bytes, ib + k * 2) as usize;
|
||||
max_idx = max_idx.max(i);
|
||||
i < vc
|
||||
});
|
||||
if in_range && max_idx + cover_slack() >= vc {
|
||||
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
|
||||
}
|
||||
vb += vc * stride;
|
||||
}
|
||||
return meshes;
|
||||
@@ -976,6 +1696,7 @@ fn read_pool_mesh(
|
||||
uvs,
|
||||
indices,
|
||||
name: None,
|
||||
vbuf_offset: Some(vb),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,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();
|
||||
@@ -682,10 +717,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,33 +65,38 @@ 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 r in 0..rects {
|
||||
let at = be32(bytes, TABLE + r * 4) as usize;
|
||||
if at + RECT_HDR > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
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];
|
||||
@@ -101,7 +107,6 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(T8adImage {
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
@@ -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);
|
||||
|
||||
@@ -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,17 +13,19 @@ 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 |
|
||||
| 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
|
||||
|
||||
@@ -32,6 +34,8 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
|
||||
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
|
||||
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
|
||||
| Mission / escort state, every entity's hull | ✅ | [`tools/re-capture/mission_state.py`](../../tools/re-capture/mission_state.py) · [escort state](mission-escort-state.md) | `hull = position + 0x154` is a property of the **entity class**, not of the player object: at t=0 it equals each entity's own definition `HP` across 7 classes and 5 distinct HP values (turret 100, fighter 500, destroyer 10000, cruiser 30000, **ACROPOLIS 25000**), falls under fire (780 damage events in 240 s), goes negative at death, and the object then leaves the heap. So an escort objective is scoreable live — `UN_f101_TCAF_Acropolis` measured at 25000 → 23038 over 240 s, attack starting only at t≈170 s. `REMAINING OB` counts objectives, not hostiles (012 on the HUD vs 118 live ADAN); its address is still ❔ |
|
||||
| In-flight control mapping | ✅/🟡 | [`tools/re-capture/fire_probe.sh`](../../tools/re-capture/fire_probe.sh) · [controls](flight-controls-runtime.md) | Measured by holding each pad input and photographing the HUD ammo counters: **`RB` = nose gun** (6000→5956 in 4 s, ~11 rounds/s, HEAT rises), **`Y` = main mount** (missiles, 300→299), d-pad = **tactical map** overlay, nothing else moves a counter. No target-cycle input exists — the `TARGET` marker is present with nothing pressed, so targeting is automatic and a missile lock is **time-on-target**. That, not target choice or ballistics, is what caps lethality at 2 kills per 98 missiles |
|
||||
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
|
||||
|
||||
## Functions / code paths
|
||||
|
||||
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.
|
||||
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 |
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/dpad-tactical-map.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
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/escort-stage02-hud.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/fire-probe-ammo-counters.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
docs/re/captures/first-missile-kills.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/re/captures/hangar-loadout-screen.png
Normal file
|
After Width: | Height: | Size: 918 KiB |
BIN
docs/re/captures/kill-counters-all-runs.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
docs/re/captures/kills-target-commitment.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/re/captures/mission-select-stage01-record.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
330
docs/re/captures/mission-state-stage02-escort-weighted.jsonl
Normal file
240
docs/re/captures/mission-state-stage02.jsonl
Normal file
BIN
docs/re/captures/mission01-t485.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/mission03-turretrule-t430.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
102
docs/re/captures/movie-subtitle-voice-map.csv
Normal file
@@ -0,0 +1,102 @@
|
||||
movie,telop,subtitle,voicetrack
|
||||
logo1.wmv,,,
|
||||
logo2.wmv,,,
|
||||
logo3.wmv,,,
|
||||
logo4.wmv,,,
|
||||
ADV.wmv,,,VOICE_ADV
|
||||
SYLPH_HD720p_8M-CBR_2ch.wmv,pwterop_s01a.prt,SYLPH_HD720p_8M-CBR_2ch.tbl,
|
||||
S00A.wmv,,SUBTITLE_S00A.tbl,VOICE_S00A
|
||||
S01A.wmv,,SUBTITLE_S01A.tbl,VOICE_S01A
|
||||
RT01A.wmv,pwrt01.prt,SUBTITLE_RT01A.tbl,VOICE_RT01A
|
||||
RT01B.wmv,,SUBTITLE_RT01B.tbl,VOICE_RT01B
|
||||
RT01C_1.wmv,,SUBTITLE_RT01C_1.tbl,VOICE_RT01C_1
|
||||
RT01C_2.wmv,,SUBTITLE_RT01C_2.tbl,VOICE_RT01C_2
|
||||
S02A.wmv,pwterop_s02a.prt,SUBTITLE_S02A.tbl,VOICE_S02A
|
||||
S02B.wmv,,SUBTITLE_S02B.tbl,VOICE_S02B
|
||||
S02C.wmv,,SUBTITLE_S02C.tbl,VOICE_S02C
|
||||
RT02A.wmv,pwrt02.prt,SUBTITLE_RT02A.tbl,VOICE_RT02A
|
||||
RT02B.wmv,,SUBTITLE_RT02B.tbl,VOICE_RT02B
|
||||
RT02C.wmv,,SUBTITLE_RT02C.tbl,VOICE_RT02C
|
||||
RT02D_1.wmv,,SUBTITLE_RT02D_1.tbl,VOICE_RT02D_1
|
||||
RT02D_2.wmv,,SUBTITLE_RT02D_2.tbl,VOICE_RT02D_2
|
||||
hokyu_LS_s02A.wmv,,SUBTITLE_hokyu_LS_s02A.tbl,VOICE_D_450
|
||||
hokyu_LS_s02H.wmv,,SUBTITLE_hokyu_LS_s02H.tbl,VOICE_D_453
|
||||
S03A.wmv,,SUBTITLE_S03A.tbl,VOICE_S03A
|
||||
RT03A.wmv,pwrt03.prt,SUBTITLE_RT03A.tbl,VOICE_RT03A
|
||||
RT03B.wmv,,SUBTITLE_RT03B.tbl,VOICE_RT03B
|
||||
RT03C.wmv,,SUBTITLE_RT03C.tbl,VOICE_RT03C
|
||||
RT03D.wmv,,SUBTITLE_RT03D.tbl,VOICE_RT03D
|
||||
hokyu_LS_s03A.wmv,,SUBTITLE_hokyu_LS_s03A.tbl,
|
||||
hokyu_LS_s03H.wmv,,SUBTITLE_hokyu_LS_s03H.tbl,
|
||||
S04A.wmv,,SUBTITLE_S04A.tbl,VOICE_S04A
|
||||
S04B.wmv,,SUBTITLE_S04B.tbl,VOICE_S04B
|
||||
RT04A.wmv,pwrt04.prt,SUBTITLE_RT04A.tbl,VOICE_RT04A
|
||||
RT04B.wmv,,SUBTITLE_RT04B.tbl,VOICE_RT04B
|
||||
hokyu_DS_s02A.wmv,,SUBTITLE_hokyu_DS_s02A.tbl,VOICE_D_452
|
||||
S05A.wmv,,SUBTITLE_S05A.tbl,VOICE_S05A
|
||||
RT05A.wmv,pwrt05.prt,SUBTITLE_RT05A.tbl,VOICE_RT05A
|
||||
RT05B.wmv,,SUBTITLE_RT05B.tbl,VOICE_RT05B
|
||||
RT05C.wmv,,SUBTITLE_RT05C.tbl,VOICE_RT05C
|
||||
S06A.wmv,pwterop_s06a.prt,SUBTITLE_S06A.tbl,VOICE_S06A
|
||||
S06B.wmv,,SUBTITLE_S06B.tbl,VOICE_S06B
|
||||
RT06A.wmv,pwrt06.prt,SUBTITLE_RT06A.tbl,VOICE_RT06A
|
||||
RT06B.wmv,,SUBTITLE_RT06B.tbl,VOICE_RT06B
|
||||
RT06C.wmv,,SUBTITLE_RT06C.tbl,VOICE_RT06C
|
||||
RT06D.wmv,,SUBTITLE_RT06D.tbl,VOICE_RT06D
|
||||
hokyu_LS_s06A.wmv,,SUBTITLE_hokyu_LS_s06A.tbl,
|
||||
hokyu_LS_s06H.wmv,,SUBTITLE_hokyu_LS_s06H.tbl,
|
||||
S07A.wmv,,SUBTITLE_S07A.tbl,VOICE_S07A
|
||||
S07B.wmv,,SUBTITLE_S07B.tbl,VOICE_S07B
|
||||
RT07A.wmv,pwrt07.prt,SUBTITLE_RT07A.tbl,VOICE_RT07A
|
||||
RT07B.wmv,,SUBTITLE_RT07B.tbl,VOICE_RT07B
|
||||
RT07C.wmv,,SUBTITLE_RT07C.tbl,VOICE_RT07C
|
||||
hokyu_DS_s07A.wmv,,SUBTITLE_hokyu_DS_s07A.tbl,
|
||||
hokyu_DS_s07H.wmv,,SUBTITLE_hokyu_DS_s07H.tbl,VOICE_D_454
|
||||
RT08A.wmv,pwrt08.prt,SUBTITLE_RT08A.tbl,VOICE_RT08A
|
||||
RT08B.wmv,,SUBTITLE_RT08B.tbl,VOICE_RT08B
|
||||
RT08C.wmv,,SUBTITLE_RT08C.tbl,VOICE_RT08C
|
||||
hokyu_DS_s08A.wmv,,SUBTITLE_hokyu_DS_s08A.tbl,
|
||||
S09B.wmv,,SUBTITLE_S09B.tbl,VOICE_S09B
|
||||
RT09A.wmv,pwrt09.prt,SUBTITLE_RT09A.tbl,VOICE_RT09A
|
||||
RT09B.wmv,,SUBTITLE_RT09B.tbl,VOICE_RT09B
|
||||
RT09C.wmv,,SUBTITLE_RT09C.tbl,VOICE_RT09C
|
||||
RT09D.wmv,,SUBTITLE_RT09D.tbl,VOICE_RT09D
|
||||
hokyu_LS_s09A.wmv,,SUBTITLE_hokyu_LS_s09A.tbl,VOICE_D_451
|
||||
hokyu_LS_s09H.wmv,,SUBTITLE_hokyu_LS_s09H.tbl,
|
||||
S10B.wmv,,SUBTITLE_S10B.tbl,VOICE_S10B
|
||||
RT10A.wmv,pwrt10.prt,SUBTITLE_RT10A.tbl,VOICE_RT10A
|
||||
RT10B.wmv,,SUBTITLE_RT10B.tbl,VOICE_RT10B
|
||||
S11A.wmv,,SUBTITLE_S11A.tbl,VOICE_S11A
|
||||
S11C.wmv,,SUBTITLE_S11C.tbl,VOICE_S11C
|
||||
RT11A.wmv,pwrt11.prt,SUBTITLE_RT11A.tbl,VOICE_RT11A
|
||||
RT11B.wmv,,SUBTITLE_RT11B.tbl,VOICE_RT11B
|
||||
RT11C.wmv,,SUBTITLE_RT11C.tbl,VOICE_RT11C
|
||||
hokyu_LS_s11A.wmv,,SUBTITLE_hokyu_LS_s11A.tbl,
|
||||
S12A.wmv,,SUBTITLE_S12A.tbl,VOICE_S12A
|
||||
S12B.wmv,,SUBTITLE_S12B.tbl,VOICE_S12B
|
||||
S12C.wmv,,SUBTITLE_S12C.tbl,VOICE_S12C
|
||||
RT12A.wmv,pwrt12.prt,SUBTITLE_RT12A.tbl,VOICE_RT12A
|
||||
RT12B_1.wmv,,SUBTITLE_RT12B_1.tbl,VOICE_RT12B_1
|
||||
RT12B_2.wmv,,SUBTITLE_RT12B_2.tbl,VOICE_RT12B_2
|
||||
S13A.wmv,,SUBTITLE_S13A.tbl,VOICE_S13A
|
||||
S13B.wmv,,SUBTITLE_S13B.tbl,VOICE_S13B
|
||||
RT13A.wmv,pwrt13.prt,SUBTITLE_RT13A.tbl,VOICE_RT13A
|
||||
RT13B_1.wmv,,SUBTITLE_RT13B_1.tbl,VOICE_RT13B_1
|
||||
RT13B_2.wmv,,SUBTITLE_RT13B_2.tbl,VOICE_RT13B_2
|
||||
hokyu_DS_s13A.wmv,,SUBTITLE_hokyu_DS_s13A.tbl,
|
||||
S14A.wmv,,SUBTITLE_S14A.tbl,VOICE_S14A
|
||||
RT14A.wmv,pwrt14.prt,SUBTITLE_RT14A.tbl,VOICE_RT14A
|
||||
RT14B.wmv,,SUBTITLE_RT14B.tbl,VOICE_RT14B
|
||||
RT14C.wmv,,SUBTITLE_RT14C.tbl,VOICE_RT14C
|
||||
hokyu_DS_s14H.wmv,,SUBTITLE_hokyu_DS_s14H.tbl,
|
||||
S15A.wmv,pwterop_s15a.prt,SUBTITLE_S15A.tbl,VOICE_S15A
|
||||
S15B.wmv,,SUBTITLE_S15B.tbl,VOICE_S15B
|
||||
S15C.wmv,,SUBTITLE_S15C.tbl,VOICE_S15C
|
||||
RT15A.wmv,pwrt15.prt,SUBTITLE_RT15A.tbl,VOICE_RT15A
|
||||
RT15B.wmv,,SUBTITLE_RT15B.tbl,VOICE_RT15B
|
||||
RT15C.wmv,,SUBTITLE_RT15C.tbl,VOICE_RT15C
|
||||
hokyu_LS_s15A.wmv,,SUBTITLE_hokyu_LS_s15A.tbl,
|
||||
S16A.wmv,pwterop_s16a.prt,SUBTITLE_S16A.tbl,VOICE_S16A
|
||||
RT16C.wmv,pwrt16.prt,SUBTITLE_RT16C.tbl,VOICE_RT16C
|
||||
hokyu_LS_s24A.wmv,,,
|
||||
hokyu_LS_s27A.wmv,,,
|
||||
|
BIN
docs/re/captures/options-key-config-actions.png
Normal file
|
After Width: | Height: | Size: 881 KiB |
13
docs/re/captures/pause-tutorial-screen-layout.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
11 elements
|
||||
# element parent kind pivot kf placement
|
||||
0 pgp_ttrl_eff11.t32 - 0x0 (34,33) 7 rest (470,121) t=9..25 [3:603,121 6:603,121 8:503,121 9:470,121 25:463,121 30:463,121 1:493,121]
|
||||
1 pgp_ttrl_eff12.t32 - 0x0 (34,33) 7 rest (742,121) t=9..25 [3:609,121 6:609,121 8:709,121 9:742,121 25:749,121 30:749,121 2:719,121]
|
||||
2 pgp_ttrl_eff10.t32 - 0x0 (204,60) 5 rest (436,94) t=14..23 [8:436,94 14:436,94 23:436,94 30:436,94 3:436,94]
|
||||
3 pgp_ttrl_eff22.t32 - 0x0 (133,130) 5 rest (480,225) t=11..25 [5:480,225 11:480,225 25:460,225 30:460,225 4:490,225]
|
||||
4 pgp_ttrl_eff23.t32 - 0x0 (138,130) 5 rest (524,271) t=11..25 [5:524,271 11:524,271 25:544,271 30:544,271 5:514,271]
|
||||
5 pgp_ttrl_eff21.t32 - 0x0 (214,180) 5 rest (426,198) t=9..21 [9:426,198 21:426,198 23:426,198 30:426,198 6:426,198]
|
||||
6 pgp_ttrl_title.rat - 0x0 (101,36) 6 rest (540,119) t=13..23 [5:540,119 13:540,119 23:540,119 28:540,119 30:540,119 7:540,119]
|
||||
7 pgp_ttrl_btn10.rat - 0x3002 (43,21) 5 rest (546,288) t=15..23 [9:546,288 15:546,288 23:546,288 26:546,288 8:546,288]
|
||||
8 pgp_ttrl_btn11.rat - 0x3002 (86,22) 5 rest (546,358) t=11..17 [11:546,358 17:546,358 23:546,358 26:546,358 9:546,358]
|
||||
9 pgp_ttrl_btn12.rat - 0x3002 (110,21) 5 rest (546,428) t=13..19 [13:546,428 19:546,428 23:546,428 26:546,428 10:546,428]
|
||||
10 pgp_ttrl_msg.t32 - 0x0 (190,19) 5 rest (451,545) t=28..1869640736 [16:451,545 21:451,545 23:451,545 28:451,545 1869640736:451,545]
|
||||
BIN
docs/re/captures/savedata-game02-samestate.bin
Normal file
BIN
docs/re/captures/savedata-game03-developed-mg1.bin
Normal file
BIN
docs/re/captures/savedata-stage02-5pct.bin
Normal file
BIN
docs/re/captures/savegame-details-slot02.png
Normal file
|
After Width: | Height: | Size: 900 KiB |
BIN
docs/re/captures/savegame-details-slot03-developed.png
Normal file
|
After Width: | Height: | Size: 908 KiB |
BIN
docs/re/captures/shipcap-close-f105-2994.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
docs/re/captures/shipcap-stage02-launch.png
Normal file
|
After Width: | Height: | Size: 543 KiB |
15
docs/re/captures/stage-s01-capture-truth-offsets.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
file offset vcount candidate claimed by our decode our resources with that vcount
|
||||
0x38788 181 NO — NOBODY — none
|
||||
0x4b8b8 93 NO — NOBODY — none
|
||||
0xb6574 41 NO — NOBODY n042_bdy_m e007_bdy_01 @ -0x24864
|
||||
0xdbbac 77 NO — NOBODY — none _rou_e010_break @ -0x5050
|
||||
0x133da0 76 NO — NOBODY e303_wep_01_m, n041_bdy_l _rou_e010_break @ -0x407bc
|
||||
0x162840 60 NO — NOBODY — none _rou_e010_break @ -0x6f25c
|
||||
0x3b3ee8 119 yes e106_bdy_01_l(119), e106_bdy_02_l(119) e106_bdy_01_l, e106_bdy_02_l e106_bdy_01_l @ -0x0
|
||||
0x3c55d8 119 yes — NOBODY e106_bdy_01_l, e106_bdy_02_l e106_bdy_01_m @ -0x104e8
|
||||
0x3dd2c4 146 yes e106_bdy_03_l(146) e106_bdy_03_l e106_bdy_03_l @ -0x0
|
||||
0x40763c 179 yes e106_bdy_04_l(179) e106_bdy_04_l e106_bdy_04_l @ -0x0
|
||||
0x40e418 51 yes — NOBODY e106_brg_01_b_02, e106_brg_01_l e106_brg_01_b_02 @ -0x5d0
|
||||
0x444ccc 58 yes e106_eng_01_l(58) e106_eng_01_l e106_eng_01_l @ -0x0
|
||||
0x44a32c 44 yes — NOBODY e106_eng_02_l e106_eng_02 @ -0x35b8
|
||||
0x45705c 82 yes e106_wep_02_01_l(82) e106_wep_02_01_l e106_wep_02_01_l @ -0x0
|
||||
47
docs/re/captures/stage-s02-capture-truth-offsets.txt
Normal file
@@ -0,0 +1,47 @@
|
||||
file offset vcount candidate claimed by our decode our resources with that vcount
|
||||
0x135036c 240 yes _rou_f101(240) — none _rou_f101 @ -0x0
|
||||
0x13519ec 240 NO _rou_f101(240) — none _rou_f101 @ -0x0
|
||||
0x13fd6e8 25448 yes f101_bdy_01(25448) f101_bdy_01 f101_bdy_01 @ -0x0
|
||||
0x192961c 13172 yes f101_bdy_02(13172) f101_bdy_02 f101_bdy_02 @ -0x0
|
||||
0x1a0befc 2296 yes f101_bdy_03(2296) f101_bdy_03 f101_bdy_03 @ -0x0
|
||||
0x1a22c1c 7694 yes f101_eng_01(7694) f101_eng_01 f101_eng_01 @ -0x0
|
||||
0x1be5df4 628 yes f101_wep_01_m(628) f101_wep_01_m f101_wep_01_m @ -0x0
|
||||
0x1bf40f4 261 yes e_rob_f001(24), e_rou_f003_Near(24), e_rou_f101_wep_01(24), e_rou_f105(24), _rou_f105_break(261), e_rou_f106(24), e_rou_f302_barrel(24), e_rou_f302_base(24), e_rou_f303_barrel(24), e_rou_f303_base(24), e_rou_e007_Far(24), e_rou_e007_Near(24), e_rou_e010_Far(24), e_rou_e010_Near(24), e_rou_e105(24), e_rou_e105_wep_01(24), e_rou_e106(24), e_rou_e106_eng(24), e_rou_e106_wep_02_01(24), e_rou_e106_wep_02_joint(24), e_rou_e108_Missile_open(24), e_rou_e201(24), e_rou_e302_barrel(24), e_rou_e302_base(24), e_rou_e303_barrel_Near(24), e_rou_e303_base_Near(24), e_rou_e501(24) f106_eng_01_l e_rob_f001 @ -0x0
|
||||
0x1bf596c 2446 NO _rou_f105_break(2446) f105_bdy_01_m _rou_f105_break @ -0x0
|
||||
0x1c03ebc 1960 NO _rou_f105_break(1960) f105_bdy_02_m _rou_f105_break @ -0x0
|
||||
0x1c0f67c 82 NO _rou_f105_break(82) e106_wep_02_01_l _rou_f105_break @ -0x0
|
||||
0x1c0fe2c 2336 NO _rou_f105_break(2336) f105_bdy_03_m _rou_f105_break @ -0x0
|
||||
0x1c1d92c 220 NO _rou_f105_break(220) — none _rou_f105_break @ -0x0
|
||||
0x1c1edcc 80 NO _rou_f105_break(80) f001_bdy_24_m _rou_f105_break @ -0x0
|
||||
0x1c1f54c 148 NO _rou_f105_break(148) — none _rou_f105_break @ -0x0
|
||||
0x1c2032c 156 NO _rou_f105_break(156) e105_bdy_01_l _rou_f105_break @ -0x0
|
||||
0x1c211cc 1402 NO _rou_f105_break(1402) f105_eng_01_m _rou_f105_break @ -0x0
|
||||
0x1c2953c 84 NO _rou_f105_break(84) — none _rou_f105_break @ -0x0
|
||||
0x1c29d1c 192 NO _rou_f105_break(192) f105_sld_01_b01 _rou_f105_break @ -0x0
|
||||
0x1c2af1c 122 NO _rou_f105_break(122) e106_eng_02_m _rou_f105_break @ -0x0
|
||||
0x1c2ba8c 24 NO _rou_f105_break(24) e_rob_f001, e_rou_f003_Far, e_rou_f003_Near, e_rou_f101_wep_01, e_rou_f105, f105_sld_01_l, e_rou_f106, e_rou_f302_barrel, e_rou_f302_base, f302_01_l, e_rou_f303_barrel, e_rou_f303_base, _rou_f402_dead, e_rou_e007_Far, e_rou_e007_Near, e_rou_e010_Far, e_rou_e010_Near, e_rou_e105, e_rou_e105_wep_01, e_rou_e106, e_rou_e106_eng, e_rou_e106_wep_02_01, e_rou_e106_wep_02_joint, e_rou_e108_Missile_open, e_rou_e201, e201_bdy_03_m, e_rou_e302_barrel, e_rou_e302_base, e_rou_e303_barrel_Near, e_rou_e303_base_Near, e303_wep_02_l, e_rou_e501 _rou_f105_break @ -0x0
|
||||
0x227ac94 1692 yes f106_bdy_02_l(1692) f106_bdy_02_l f106_bdy_02_l @ -0x0
|
||||
0x22c2ef0 686 yes f106_bdy_03_l(686) f106_bdy_03_l f106_bdy_03_l @ -0x0
|
||||
0x22ed420 261 yes f106_eng_01_l(261) f106_eng_01_l f106_eng_01_l @ -0x0
|
||||
0x2326c9c 254 yes f106_sld_01_l(254) f106_sld_01_l, f106_sld_02_l, e303_wep_02_m f106_sld_01_l @ -0x0
|
||||
0x233b3fc 254 yes f106_sld_02_l(254) f106_sld_01_l, f106_sld_02_l, e303_wep_02_m f106_sld_02_l @ -0x0
|
||||
0x24ce5f4 32 yes f303_body_l(32) f303_body_l, e302_barrel_l f303_body_l @ -0x0
|
||||
0x24fa1c8 128 yes _rou_f402(128) _rou_f402 _rou_f402 @ -0x0
|
||||
0x28ea1d4 156 yes e105_bdy_01_l(156) e105_bdy_01_l e105_bdy_01_l @ -0x0
|
||||
0x28f7514 107 yes e105_bdy_02_l(107) e105_bdy_02_l, e105_brg_m e105_bdy_02_l @ -0x0
|
||||
0x291e1e8 185 yes e105_bdy_03_l(185) e105_bdy_03_l e105_bdy_03_l @ -0x0
|
||||
0x29a4788 181 yes e105_bdy_04_l(181) e105_bdy_04_l e105_bdy_04_l @ -0x0
|
||||
0x29b78b8 93 yes e105_bdy_05_l(93) e105_bdy_05_l e105_bdy_05_l @ -0x0
|
||||
0x2a22574 41 yes e105_bdy_06_l(41) e105_bdy_06_l e105_bdy_06_l @ -0x0
|
||||
0x2a47bac 77 yes e105_brg_l(77) e105_brg_l e105_brg_l @ -0x0
|
||||
0x2a9fda0 76 yes e105_eng_01_l(76) e105_eng_01_l, e303_wep_01_m e105_eng_01_l @ -0x0
|
||||
0x2ace840 60 yes e105_wep_01_l(60) e105_wep_01_l e105_wep_01_l @ -0x0
|
||||
0x2d1fee8 119 yes e106_bdy_01_l(119) e106_bdy_01_l, e106_bdy_02_l e106_bdy_01_l @ -0x0
|
||||
0x2d315d8 119 yes e106_bdy_02_l(119) e106_bdy_01_l, e106_bdy_02_l e106_bdy_02_l @ -0x0
|
||||
0x2d492c4 146 yes e106_bdy_03_l(146) e106_bdy_03_l e106_bdy_03_l @ -0x0
|
||||
0x2d7363c 179 yes e106_bdy_04_l(179) e106_bdy_04_l e106_bdy_04_l @ -0x0
|
||||
0x2d7a418 51 yes e106_brg_01_l(51) e106_brg_01_b_02, e106_brg_01_l e106_brg_01_l @ -0x0
|
||||
0x2db0ccc 58 yes e106_eng_01_l(58) e106_eng_01_l e106_eng_01_l @ -0x0
|
||||
0x2db632c 44 yes e106_eng_02_l(44) e106_eng_02_l, e302_base_m e106_eng_02_l @ -0x0
|
||||
0x2dc305c 82 yes e106_wep_02_01_l(82) e106_wep_02_01_l e106_wep_02_01_l @ -0x0
|
||||
0x329077c 6000 yes n006_02(6000) n006_02 n006_02 @ -0x0
|
||||
125
docs/re/captures/stage02-defaulted-field-runtime-values.txt
Normal file
@@ -0,0 +1,125 @@
|
||||
|
||||
map check: 406 fields agree with the disc, 0 disagree
|
||||
|
||||
runtime value where the disc record DEFAULTS the field:
|
||||
AA_AxisMode_Max 0 (10 units)
|
||||
AA_AxisMode_Min 0, 1.74533 (11 units)
|
||||
AA_PitchMinus_Max 0, 0.0628319, 0.174533 (8 units)
|
||||
AA_PitchMinus_Min 0, 0.0628319, 0.261799, 0.349066, 0.698132 (10 units)
|
||||
AA_PitchPlus_Max 0, 0.0628319 (6 units)
|
||||
AA_PitchPlus_Min 0, 0.0628319, 1.74533 (8 units)
|
||||
AA_Roll_Max 0, 0.0628319, 0.174533, 0.523599, 1.0472 (10 units)
|
||||
AA_Roll_Min 0, 0.0628319, 1.0472 (7 units)
|
||||
AA_Yaw_Max 0, 0.0628319, 0.0872665 (7 units)
|
||||
AA_Yaw_Min 0, 0.0628319, 0.261799 (7 units)
|
||||
AB_AA_PitchMinus 0, 0.174533 (11 units)
|
||||
AB_AA_PitchPlus 0 (10 units)
|
||||
AB_AA_Roll 0, 0.523599 (11 units)
|
||||
AB_AA_Yaw 0, 0.174533 (11 units)
|
||||
AB_AV_PitchMinus 0 (10 units)
|
||||
AB_AV_PitchPlus 0, 0.523599 (11 units)
|
||||
AB_AV_Roll 0, 0.698132 (11 units)
|
||||
AB_AV_Yaw 0, 0.279253 (11 units)
|
||||
AB_ConsumeShield 0, 10 (11 units)
|
||||
AB_ConsumeShield_Begin 0 (10 units)
|
||||
AV_AxisMode_Max 0, 0.174533 (11 units)
|
||||
AV_AxisMode_Min 0 (10 units)
|
||||
AV_PitchMinus_Max 0, 0.0628319, 0.349066 (7 units)
|
||||
AV_PitchMinus_Min 0, 0.0628319 (6 units)
|
||||
AV_PitchPlus_Max 0 (1 units)
|
||||
AV_PitchPlus_Min 0, 0.0628319, 1.74533 (7 units)
|
||||
AV_Roll_Max 0, 0.0628319, 0.349066, 1.0472, 2.0944 (10 units)
|
||||
AV_Roll_Min 0, 0.0628319, 1.0472 (7 units)
|
||||
AV_Yaw_Max 0, 0.0628319, 0.174533 (7 units)
|
||||
AV_Yaw_Min 0, 0.0628319, 0.349066 (7 units)
|
||||
AccPitchFactor 0, 30 (5 units)
|
||||
Acceleration 0 (1 units)
|
||||
ArterBurner_Acc 0, 2 (10 units)
|
||||
ArterBurner_Vc 0, 2 (6 units)
|
||||
AttackCraftPoint 0, 0.1, 0.5, 1 (9 units)
|
||||
AttackVesselPoint 0, 0.1 (3 units)
|
||||
BarrelRoll_CountMaximum 0, 5 (11 units)
|
||||
BarrelRoll_CountMinimum 0, 0.5 (11 units)
|
||||
BarrelRoll_Radius 0 (7 units)
|
||||
BarrelRoll_Time 0 (7 units)
|
||||
BoostAway_Time_Maximum 0, 2 (11 units)
|
||||
BoostAway_Time_Minimum 0, 1 (11 units)
|
||||
ChargeDelay 1, 3, 5 (10 units)
|
||||
ChargeDelay_Break 1, 10 (6 units)
|
||||
ChargeSpeed 25 (3 units)
|
||||
Color_B 0, 1 (3 units)
|
||||
Color_G 1 (1 units)
|
||||
CruisingVelocity 0 (1 units)
|
||||
CutoffTimeMax 0, 5 (10 units)
|
||||
CutoffTimeMin 0 (7 units)
|
||||
DecPitchFactor 0, 30 (11 units)
|
||||
Deceleration 0, 50 (2 units)
|
||||
DefencePoint 0, 0.1, 0.25 (5 units)
|
||||
Delay 0, 1.5, 3 (8 units)
|
||||
DelayAdjustment 0, 1, 2 (10 units)
|
||||
DestroyMotionTime 0 (1 units)
|
||||
DragFactorThreshold 0, 0.5 (10 units)
|
||||
DryMass 100, 10000 (8 units)
|
||||
FCSRange 0, 500000 (3 units)
|
||||
FiringRange 0 (11 units)
|
||||
GrossMass 10000, 200 (10 units)
|
||||
HQRatio 0.2, 1 (9 units)
|
||||
HoldPosition_BackRatio 0, 0.1, 0.3 (11 units)
|
||||
HoldPosition_CancelTime 0, 20 (10 units)
|
||||
HoldPosition_CutoffRatio 0, 0.5 (11 units)
|
||||
HoldPosition_LengthMax 0, 400 (8 units)
|
||||
HoldPosition_LengthMin 0, 150 (10 units)
|
||||
HoldPosition_MaximumTime 0, 10 (10 units)
|
||||
HoldPosition_MinimumTime 0, 5 (11 units)
|
||||
HoldPosition_SideRatio 0, 0.3 (11 units)
|
||||
HomingResistAdjustment 0, 0.5 (11 units)
|
||||
LowerHPThresholdRatio 0, 0.3 (7 units)
|
||||
MaxValue 100 (3 units)
|
||||
MaximumBank_Normal 0, 0.523599, 1.0472 (5 units)
|
||||
MaximumVelocity 0 (1 units)
|
||||
MinimumVelocity 0, 100 (7 units)
|
||||
PitchDragFactor 0, 2, 3 (11 units)
|
||||
PowerCutConsumeShield 0, 20 (11 units)
|
||||
PowerCutDeceleration 0, 10 (11 units)
|
||||
RadarRange 0 (1 units)
|
||||
ResistanceParalyze 1 (1 units)
|
||||
ResistanceToExplosion 0.1, 0.5, 1 (9 units)
|
||||
ResistanceToOptics 0.1, 1 (4 units)
|
||||
ResistanceToPlayer 0, 1 (10 units)
|
||||
ResistanceToShell 0.1, 1 (8 units)
|
||||
ReverseThrust_Acc 0, 0.25, 2 (9 units)
|
||||
ReverseThrust_Vc 0 (1 units)
|
||||
RollDragFactor 0, 2, 3 (11 units)
|
||||
SELength 2000 (11 units)
|
||||
ShieldRatio 1 (6 units)
|
||||
SideRoll_Length 0 (7 units)
|
||||
SideRoll_Time 0, 0.5, 1.5 (11 units)
|
||||
SideThrustAcceleration 0, 100, 1000 (5 units)
|
||||
SideThrustVelocity_Max 0, 100, 1000 (3 units)
|
||||
Size_Radius 10, 50 (4 units)
|
||||
Size_Y 200, 300, 400, 600, 700 (5 units)
|
||||
Slalom_CutoffRatio 0 (7 units)
|
||||
Slalom_TurnCount_Max 0 (11 units)
|
||||
Slalom_TurnCount_Min 0 (9 units)
|
||||
SolidCutoff_LengthMax 0 (7 units)
|
||||
SolidCutoff_LengthMin 0, 500 (9 units)
|
||||
SolidCutoff_Ratio 0, 0.5 (11 units)
|
||||
Through_AngleMaximum 0, 1.0472 (11 units)
|
||||
Through_AngleMinimum 0, 0.523599 (11 units)
|
||||
Through_CutoffRatio 0, 0.5 (11 units)
|
||||
Through_LengthMaximum 0, 500 (10 units)
|
||||
Through_LengthMinimum 0, 250 (11 units)
|
||||
Through_Time1Max 0, 2 (11 units)
|
||||
Through_Time1Min 0, 5 (11 units)
|
||||
Through_Time2Max 0, 1.5 (8 units)
|
||||
Through_Time2Min 0, 5 (11 units)
|
||||
ThrusterRatio 0.2, 1 (10 units)
|
||||
TurnAttack_CutoffRatio 0 (7 units)
|
||||
TurnAttack_DoubleRatio 0 (7 units)
|
||||
TurnAttack_DoubleTimeMax 0 (7 units)
|
||||
TurnAttack_DoubleTimeMin 0 (7 units)
|
||||
TurnAway_Time_Maximum 0, 2, 5 (11 units)
|
||||
TurnAway_Time_Minimum 0, 0.5 (11 units)
|
||||
Turn_AngularVelocity 0, 0.523599 (8 units)
|
||||
UsingChaffRatio 0 (11 units)
|
||||
YawDragFactor 0 (1 units)
|
||||
BIN
docs/re/captures/stage02-flight-live-dump.png
Normal file
|
After Width: | Height: | Size: 894 KiB |
7182
docs/re/captures/stage02-live-unit-definitions-deep.txt
Normal file
1358
docs/re/captures/stage02-live-unit-definitions.txt
Normal file
BIN
docs/re/captures/tutorial-advanced-controls-captions.png
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
docs/re/captures/tutorial-hud-target-select.png
Normal file
|
After Width: | Height: | Size: 884 KiB |
BIN
docs/re/captures/tutorial-menu-labels.png
Normal file
|
After Width: | Height: | Size: 890 KiB |
BIN
docs/re/captures/xpr2-colour-check-deltasaber.png
Normal file
|
After Width: | Height: | Size: 580 KiB |