Merge remote-tracking branch 'origin/main' into auto/frame-blend-draw-path
Some checks failed
CI / Native — linux (pull_request) Failing after 11m25s
CI / WASM — Web (pull_request) Successful in 29m52s
CI / Formatting (pull_request) Failing after 1m5s

# Conflicts:
#	crates/sylpheed-cli/src/main.rs
This commit is contained in:
sylph-decoder
2026-09-11 19:23:13 +00:00
239 changed files with 40899 additions and 2988 deletions

View File

@@ -38,7 +38,6 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use tracing::info;
use sylpheed_formats::vfs::{identify_format, GameAssets};
use sylpheed_formats::{IdxdObject, PakArchive};
@@ -336,7 +335,7 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("sylpheed=info".parse().unwrap())
.add_directive("sylpheed=info".parse().unwrap()),
)
.init();
@@ -344,24 +343,33 @@ async fn main() -> Result<()> {
match cli.command {
Commands::Extract { iso, output } => cmd_extract(&iso, &output).await,
Commands::List { iso, filter } => cmd_list(&iso, filter).await,
Commands::List { iso, filter } => cmd_list(&iso, filter).await,
Commands::Sniff { dir, unknown_only } => cmd_sniff(&dir, unknown_only),
Commands::Texture { cmd } => match cmd {
TextureCommands::Info { file } => cmd_texture_info(&file),
TextureCommands::Info { file } => cmd_texture_info(&file),
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
},
Commands::Mesh { cmd } => match cmd {
MeshCommands::Info { file } => cmd_mesh_info(&file),
MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => {
cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only)
}
MeshCommands::Render {
file,
output,
size,
yaw,
pitch,
dist,
row,
only,
} => cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only),
},
Commands::Pak { cmd } => match cmd {
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::Textures { pak, output, verbose } => {
cmd_pak_textures(&pak, &output, verbose)
}
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::Textures {
pak,
output,
verbose,
} => cmd_pak_textures(&pak, &output, verbose),
},
Commands::Audio { cmd } => match cmd {
AudioCommands::Info { file } => cmd_audio_info(&file),
@@ -514,7 +522,9 @@ fn cmd_screen_info(pak: &Path, want: Option<usize>, geometry: bool, all: bool) -
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {rest}",
el.index,
el.name,
el.parent.map(|p| p.to_string()).unwrap_or_else(|| "-".into()),
el.parent
.map(|p| p.to_string())
.unwrap_or_else(|| "-".into()),
format!("{:#x}", el.kind),
format!("({},{})", el.pivot_x, el.pivot_y),
el.keyframes.len(),
@@ -604,6 +614,11 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
}
}
// 8 parameters against a threshold of 7 — a plain function, unlike the Bevy
// systems in the viewer, so this one is real if mild. Left as-is because the
// arguments are the CLI flags this subcommand takes; grouping them into a
// struct is a change to the command surface, not a lint fix.
#[allow(clippy::too_many_arguments)]
fn cmd_screen_render(
pak: &Path,
output: &Path,
@@ -680,7 +695,10 @@ fn cmd_screen_render(
screen.height
);
if !screen.missing.is_empty() {
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
println!(
" sprites that did not resolve/decode: {:?}",
screen.missing
);
}
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
// ghost instance carries its template's name, so a bare name list shows
@@ -718,9 +736,7 @@ fn cmd_screen_render(
// ── save file ────────────────────────────────────────────────────────────────
fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
use sylpheed_formats::savegame::{
self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT,
};
use sylpheed_formats::savegame::{self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT};
let raw = std::fs::read(file).context("read save")?;
let save = savegame::parse(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
@@ -770,7 +786,11 @@ fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
" {} +{:<3} {:<14} {}",
mark(f.confidence),
f.offset,
if f.name.is_empty() { "(unnamed)" } else { f.name },
if f.name.is_empty() {
"(unnamed)"
} else {
f.name
},
value
);
if all && !f.note.is_empty() {
@@ -779,7 +799,10 @@ fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
}
let dev = save.develop_state();
let owned = dev.iter().filter(|d| **d == DevelopState::Developed).count();
let owned = dev
.iter()
.filter(|d| **d == DevelopState::Developed)
.count();
let ready = dev
.iter()
.filter(|d| **d == DevelopState::Developable)
@@ -823,9 +846,18 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
println!("{} {}", "Audio:".green().bold(), file.display());
println!(" Codec : {}", info.codec.label().yellow());
let opt = |v: Option<String>| v.unwrap_or_else(|| "".dimmed().to_string());
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
println!(
" Channels : {}",
opt(info.channels.map(|c| c.to_string()))
);
println!(
" Sample rate: {}",
opt(info.sample_rate.map(|r| format!("{r} Hz")))
);
println!(
" Bit depth : {}",
opt(info.bits_per_sample.map(|b| format!("{b}-bit")))
);
if let Some(b) = info.avg_bytes_per_sec {
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
}
@@ -840,7 +872,10 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
if let Some(p) = info.xma_packets {
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
}
println!(" Size : {} bytes", info.size_bytes.to_string().yellow());
println!(
" Size : {} bytes",
info.size_bytes.to_string().yellow()
);
if info.codec.needs_decoder() {
println!(
" {} decode not supported (needs an XMA2 decoder + the sound-bank descriptor)",
@@ -873,7 +908,7 @@ async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
ProgressStyle::default_bar()
.template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("█▉▊▋▌▍▎▏ ")
.progress_chars("█▉▊▋▌▍▎▏ "),
);
let stats = reader.extract_all(output_dir).await?;
@@ -902,7 +937,11 @@ async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
// ── list ───────────────────────────────────────────────────────────────────
async fn cmd_list(iso_path: &Path, filter: Option<String>) -> Result<()> {
println!("{} {}", "Listing".green().bold(), iso_path.display().to_string().cyan());
println!(
"{} {}",
"Listing".green().bold(),
iso_path.display().to_string().cyan()
);
let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
let files = reader.list_all_files().await?;
@@ -947,7 +986,9 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for file in &files {
let Ok(bytes) = assets.read(file) else { continue; };
let Ok(bytes) = assets.read(file) else {
continue;
};
let fmt = identify_format(&bytes);
let label = fmt.extension_hint();
*counts.entry(label).or_insert(0) += 1;
@@ -968,9 +1009,10 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
let hex_preview = if label == "bin" && bytes.len() >= 8 {
format!(
" {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7]
).dimmed().to_string()
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]
)
.dimmed()
.to_string()
} else {
String::new()
};
@@ -982,13 +1024,9 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
println!();
println!("{}", "Format Summary:".bold());
let mut summary: Vec<_> = counts.into_iter().collect();
summary.sort_by(|a, b| b.1.cmp(&a.1));
summary.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
for (fmt, count) in summary {
println!(
" {:>6} .{}",
count.to_string().yellow(),
fmt
);
println!(" {:>6} .{}", count.to_string().yellow(), fmt);
}
Ok(())
@@ -997,8 +1035,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
// ── texture info ──────────────────────────────────────────────────────────
fn cmd_texture_info(file: &Path) -> Result<()> {
let bytes = std::fs::read(file)
.with_context(|| format!("Cannot read {}", file.display()))?;
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
use sylpheed_formats::texture::X360Texture;
let tex = X360Texture::from_xpr2(&bytes)
@@ -1006,16 +1043,27 @@ fn cmd_texture_info(file: &Path) -> Result<()> {
let d = tex.format.desc();
println!("{} {}", "Texture:".green().bold(), file.display());
println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow());
println!(
" Resolution : {}×{}",
tex.width.to_string().yellow(),
tex.height.to_string().yellow()
);
println!(
" Format : {} ({:?}) · {} bpp, {}",
tex.format.gpu_name().yellow(),
tex.format,
d.bpp,
if d.compressed { "compressed" } else { "uncompressed" },
if d.compressed {
"compressed"
} else {
"uncompressed"
},
);
println!(" Mip levels : {}", tex.mip_levels);
println!(" Data size : {} bytes", tex.data.len().to_string().yellow());
println!(
" Data size : {} bytes",
tex.data.len().to_string().yellow()
);
Ok(())
}
@@ -1023,13 +1071,12 @@ fn cmd_texture_info(file: &Path) -> Result<()> {
// ── texture export ────────────────────────────────────────────────────────
fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
let bytes = std::fs::read(file)
.with_context(|| format!("Cannot read {}", file.display()))?;
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
use sylpheed_formats::texture::X360Texture;
let tex = X360Texture::from_xpr2(&bytes)?;
let rgba = decode_to_rgba8(&tex)
.with_context(|| format!("decoding {:?} texture", tex.format))?;
let rgba =
decode_to_rgba8(&tex).with_context(|| format!("decoding {:?} texture", tex.format))?;
image::save_buffer(
output,
@@ -1046,7 +1093,11 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
tex.width,
tex.height,
tex.format,
if tex.is_cubemap { " (cubemap face 0)" } else { "" },
if tex.is_cubemap {
" (cubemap face 0)"
} else {
""
},
output.display().to_string().cyan(),
);
Ok(())
@@ -1124,7 +1175,7 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
let nv = sub.positions.len();
let mut referenced = vec![false; nv];
let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0], tri[1], tri[2]);
imax = imax.max(a).max(b).max(c);
if a == b || b == c || a == c {
@@ -1146,11 +1197,17 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
};
let mut maxedges: Vec<f32> = sub
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
.collect();
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6);
let median = maxedges
.get(maxedges.len() / 2)
.copied()
.unwrap_or(1.0)
.max(1e-6);
let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count();
println!(
" sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}",
@@ -1270,10 +1327,16 @@ fn cmd_mesh_render(
(lo[2] + hi[2]) * 0.5,
];
let (scale, cell) = if multi {
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]).max(1e-3);
let extent = (hi[0] - lo[0])
.max(hi[1] - lo[1])
.max(hi[2] - lo[2])
.max(1e-3);
let col = i % cols;
let row = i / cols;
(CELL / extent, [col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0])
(
CELL / extent,
[col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0],
)
} else {
(1.0, [0.0, 0.0, 0.0])
};
@@ -1310,7 +1373,9 @@ fn cmd_mesh_render(
let med = {
let mut e: Vec<f32> = sub
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n)
.map(|t| {
let d = |a: u32, b: u32| {
@@ -1337,14 +1402,16 @@ fn cmd_mesh_render(
};
for place in &mine {
let f = |i: usize| {
let p = place.map(|pl| pl.apply(sub.positions[i])).unwrap_or(sub.positions[i]);
let p = place
.map(|pl| pl.apply(sub.positions[i]))
.unwrap_or(sub.positions[i]);
[
(p[0] - center[0]) * scale * mirror[0] + cell[0],
(p[1] - center[1]) * scale * mirror[1] + cell[1],
(p[2] - center[2]) * scale * mirror[2] + cell[2],
]
};
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a < n && b < n && c < n {
if span_only || span_hide {
@@ -1530,7 +1597,9 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
// Emit RGBA. X8 has no meaningful alpha.
let opaque = matches!(tex.format, F::X8R8G8B8);
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
let src = tex.data.as_chunks::<4>().0;
let dst = rgba.as_chunks_mut::<4>().0;
for (px, out) in src.iter().zip(dst) {
out[0] = px[1]; // R
out[1] = px[2]; // G
out[2] = px[3]; // B
@@ -1726,8 +1795,10 @@ fn emit_t8ad(
}
match t8ad::parse(slice) {
Some(img) => {
let out =
output.join(format!("{hash:08x}_{stem}_{}x{}.png", img.width, img.height));
let out = output.join(format!(
"{hash:08x}_{stem}_{}x{}.png",
img.width, img.height
));
image::save_buffer(
&out,
&img.rgba,
@@ -1749,8 +1820,7 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
use sylpheed_formats::{lsta, ratc, t8ad};
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
std::fs::create_dir_all(output)
.with_context(|| format!("creating {}", output.display()))?;
std::fs::create_dir_all(output).with_context(|| format!("creating {}", output.display()))?;
println!(
"{} {}{}",
@@ -1780,12 +1850,12 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
let mut idx = 0usize;
while let Some(pos) = payload[off..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.position(|w| w == t8ad::T8AD_MAGIC)
{
let start = off + pos;
let next = payload[start + 4..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.position(|w| w == t8ad::T8AD_MAGIC)
.map(|p| start + 4 + p)
.unwrap_or(payload.len());
emit_t8ad(
@@ -1818,7 +1888,14 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
} else {
safe_name(&child.name)
};
emit_t8ad(&payload[child.offset..end], hash, &stem, output, verbose, &mut stats)?;
emit_t8ad(
&payload[child.offset..end],
hash,
&stem,
output,
verbose,
&mut stats,
)?;
}
}
continue;

View File

@@ -55,7 +55,33 @@ license.workspace = true
# a squash-merge can orphan, and no way for the exporter to be built against a
# decoder it was never tested with. A decoder change and the exporter change it
# requires now land in the same commit or not at all.
sylpheed-formats = { path = "../sylpheed-formats" }
# PINNED BY TAG, which is what MISSION section 2 prescribes and what the tagging
# rule exists for: "the RE agent tags when it lands something you need and tells
# you over the message channel -- that is how you stay current without floating."
# That is exactly what happened here.
#
# The tag carries the CORRECTED keyframe association: a placement group is an
# 8-byte header then `frames` x {u32 time; 36-byte pose}, so pose 0's time is the
# group's lead-in word and EVERY POSE IS TIMED, including the last. The working
# tree's copy still has the retired `SYLPHEED_KF_TIME_SHIFT` knob -- a superseded
# partial fix that got the association right but left pose 0 untimed, which is
# why testing it moved the untimed frame from last to first instead of removing
# it. The old reading is behind `SYLPHEED_KF_TIME_LEGACY=1` here.
#
# 🔴 THE COST, STATED: `sylpheed-cli` builds from the WORKSPACE crate, so until
# this lands on `main` the exporter and the reference renderer read DIFFERENT
# decoders and `tools/port/verify-screen` is comparing two eras rather than
# detecting drift. `tools/port/verify-capture` is unaffected -- it compares the
# port against oracle CAPTURES and never touches the CLI -- and it is the check
# that matters. Revert to the path dependency the day the tag is an ancestor of
# `main`.
# Bumped c -> d 2026-08-29. What I wanted from the new state: `d` carries parser
# and `audio.rs` changes on top of `c`. ⚠️ Its headline change -- Reborn's
# renderer drawing `rotation_deg`, and `compose` drawing a leaf that carries
# geometry -- does NOT reach this port from here: `sylpheed-cli` builds from the
# WORKSPACE crate, so the reference renderer stays unrotated until the tag lands
# on `main`. This bump is for the parser, not for the renderer.
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Sylpheed.git", tag = "formats-pin-2026-09-01" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,65 @@
//! Throwaway probe: what are a music bank's sub-waves, decoded and timed?
//!
//! `export_bgm` sums every sub-wave `media` returns and scales by 1/n. If one of
//! them is not music, the divisor is wrong and every real stem is attenuated for
//! nothing -- the same defect already found and fixed in `export_voice`.
use std::process::Command;
use sylpheed_formats::media;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&disc);
for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] {
match media::sound_bank_riffs(&src, bank) {
Ok(riffs) => {
println!("{bank}: {} sub-wave(s)", riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("bk_{i}.xma.wav"));
std::fs::write(&p, r).unwrap();
let w = std::env::temp_dir().join(format!("bk_{i}.wav"));
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p)
.arg(&w)
.output();
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"])
.arg(&w)
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
.output()
.unwrap();
let t = String::from_utf8_lossy(&out.stderr).into_owned();
let get = |k: &str| {
t.lines()
.find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string()))
.unwrap_or_else(|| "?".into())
};
let dur = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(&w)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!(
" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}",
r.len(),
dur,
get("Peak level dB:"),
get("RMS level dB:")
);
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&w);
}
}
Err(e) => println!("{bank}: {e}"),
}
}
}

View File

@@ -0,0 +1,56 @@
//! Is `BGM_103` the ONLY bank with those two wave sizes?
//!
//! `authored/audio.json` says *"Static code, disc census and runtime all agree"*
//! — three legs. Reading the sentence beneath it, legs two and three are **one**
//! comparison: the disc's declared wave sizes matched byte-for-byte against what
//! the XMA probe saw at the menu. That is a disc-to-runtime match, not two
//! independent confirmations.
//!
//! It is a third leg only if the census independently EXCLUDES alternatives — if
//! some other bank carried the same two sizes, the byte match would not
//! distinguish it. So the sizes are counted across every `BGM_*` bank on the
//! disc.
//!
//! Prompted by the Decoder's point that a decorative second support is worse
//! than none: **a conclusion with two supports reads as better evidenced than
//! one with a single support, so apparent redundancy is itself the
//! misinformation.**
use sylpheed_formats::media;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&root);
const WANT: [usize; 2] = [3_876_864, 3_930_112];
let (mut found, mut matches) = (0usize, Vec::new());
for n in 0..=199u32 {
let name = format!("BGM_{n:03}.slb");
let Ok(riffs) = media::sound_bank_riffs(&src, &name) else {
continue;
};
if riffs.is_empty() {
continue;
}
found += 1;
let sizes: Vec<usize> = riffs.iter().map(|r| r.len()).collect();
// Compare on the DATA payload the port sums, not on the RIFF wrapper:
// a wrapper differs by header bytes and would hide a real collision.
let near = sizes
.iter()
.any(|s| WANT.iter().any(|w| s.abs_diff(*w) < 4096));
if near {
matches.push((name.clone(), sizes.clone()));
}
}
println!(" {found} BGM_* bank(s) readable on this disc");
for (n, s) in &matches {
println!(" {n:<14} wave sizes {s:?}");
}
println!(
"\n {} bank(s) carry a wave within 4 KiB of {WANT:?}",
matches.len()
);
println!(" Exactly 1 means the census EXCLUDES alternatives and is a real third");
println!(" leg. More than 1 means the byte match does not distinguish BGM_103,");
println!(" and \"three legs\" is two. Zero means this reader cannot see the");
println!(" incumbent and its answer means nothing.");
}

View File

@@ -0,0 +1,122 @@
//! Test the Decoder's UNTESTED reading of a residual they recorded as odd.
//!
//! `GP_DIALOG` holds 140 entries against a 70-record dialog table — a 2:1 ratio
//! that would make the id→entry join an ordering question. It does not hold:
//! adjacent pairing gives identical element-name sets on **2 of 65** pairs,
//! halves pairing on **0**. In `GP_TITLE` a language pair shares its element set
//! exactly, so identical sets are the signature there and almost nothing matches
//! here.
//!
//! The residual: the only two adjacent pairs that DO match are entries `0/1` and
//! `2/3` — and `2/3` is the DIFFICULTY build. Their plausible reading is that
//! dialog text is baked into language-specific sprites, so EN/JP entries differ
//! by construction. ⚠️ **They flagged it as untested and did not assert it**, and
//! it has a hole they named themselves: it would explain the 63 that differ and
//! leave the 2 that match needing their own explanation.
//!
//! This prints what the differences actually look like, so the reading is judged
//! against the names rather than accepted as plausible.
use std::collections::BTreeSet;
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak");
let sets: Vec<Option<BTreeSet<String>>> = ar
.entries()
.iter()
.map(|e| {
let by = ar.read(e).ok()?;
if !ratc::is_ratc(&by) {
return None;
}
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|el| el.name.clone()).collect())
})
.collect();
let (mut same, mut diff, mut pairs) = (0usize, 0usize, 0usize);
let mut shown = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
let (Some(a), Some(b)) = (&sets[i], &sets[i + 1]) else {
continue;
};
pairs += 1;
if a == b {
same += 1;
println!(
" entries {i:>3}/{:<3} IDENTICAL sets, {} element(s)",
i + 1,
a.len()
);
continue;
}
diff += 1;
// The stage-dialog pairs, checked by name and by SPRITE COUNT. A
// translation of one dialog carries the same amount of text; a
// different stage does not. This is the Decoder's closing evidence for
// the 37 pairs that differ WITHOUT a button-count mismatch, re-derived
// here because it settles a bound I had recorded as unlikely to be
// tested -- and saying so is what got it tested.
if (10..=15).contains(&i) {
let sp = |x: &BTreeSet<String>| x.iter().filter(|n| n.ends_with(".t32")).count();
let stage = |x: &BTreeSet<String>| -> Vec<String> {
let mut v: Vec<String> = x
.iter()
.filter_map(|n| {
n.strip_prefix("pzstg")
.and_then(|r| r.get(..2))
.map(|s| s.to_string())
})
.collect();
v.sort();
v.dedup();
v
};
println!(
" entries {i:>3}/{:<3} stages {:?} vs {:?} sprites {} vs {}",
i + 1,
stage(a),
stage(b),
sp(a),
sp(b)
);
}
if shown < 3 {
shown += 1;
let only_a: Vec<_> = a.difference(b).cloned().collect();
let only_b: Vec<_> = b.difference(a).cloned().collect();
println!(
" entries {i:>3}/{:<3} differ: {} only-in-first, {} only-in-second",
i + 1,
only_a.len(),
only_b.len()
);
println!(" first : {:?}", &only_a[..only_a.len().min(4)]);
println!(" second : {:?}", &only_b[..only_b.len().min(4)]);
}
}
// 🔴 THE DECISIVE DETAIL, not the impressionistic one. Two languages of one
// dialog cannot differ in BUTTON COUNT. If adjacent entries do, they are
// different dialogs and the whole adjacent-pairing premise is wrong -- which
// is a stronger statement than "the language reading is untested".
let btns = |s: &Option<BTreeSet<String>>| -> usize {
s.as_ref()
.map_or(0, |x| x.iter().filter(|n| n.contains("btn")).count())
};
let mut mismatched = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
if sets[i].is_none() || sets[i + 1].is_none() {
continue;
}
if btns(&sets[i]) != btns(&sets[i + 1]) {
mismatched += 1
}
}
println!("\n adjacent pairs whose BUTTON COUNTS differ: {mismatched}");
println!(" A language pair cannot. Every one of these is two different dialogs.");
println!("\n {pairs} adjacent pair(s): {same} identical, {diff} differing");
println!(" Their reading -- text baked into language-specific sprites -- predicts");
println!(" the differing names look SYSTEMATIC (a locale suffix, a parallel set).");
println!(" Judge it against the names above rather than against its plausibility.");
}

View File

@@ -0,0 +1,93 @@
//! Independent check of "DIFFICULTY is a dialog: GP_DIALOG entries 2/3".
//!
//! The Decoder identified `DLG_SELECT_DIFFICULTY` as `GP_DIALOG.pak` entries 2/3
//! by TWO arguments, one of them compound — corrected from "three routes", which
//! was taking credit for the exclusion scan. The image leg names no entry, and
//! the disc and oracle legs are one argument, since the capture is compared
//! against the disc's rows. One of them is button count and geometry. That half is
//! readable from the disc with this port's own reader, so it is checked here
//! rather than taken on their word — the same form as re-deriving `ptbtn11`'s
//! row order from my export when they offered it.
//!
//! ⚠️ What this CANNOT check is their binding claim, and they flagged it first:
//! entries 2/3 are identified by button count and geometry, **not** by a binding
//! from the `DLG_` name to a pak entry. Another four-button dialog with the same
//! rows would be indistinguishable by this evidence. Reproducing the geometry
//! confirms the geometry; it does not name the screen.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
// 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search
// independently. They report zero four-button builds within 6 px of
// 259/329/399/469 anywhere on the disc, which turns "another dialog with
// these rows would be indistinguishable" from a standing reach into a
// bounded one. A disc-wide negative is exactly the claim worth re-running
// with a different reader, because its whole content is an absence.
const WANT: [i32; 4] = [259, 329, 399, 469];
const TOL: i32 = 6;
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut hits, mut scanned) = (0usize, 0usize);
for path in &paks {
let Ok(ar) = pak::PakArchive::open(path) else {
continue;
};
let arch = path.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
scanned += 1;
// Any button-shaped record, not just `pcbtn`: a rival need not share the
// naming convention, and restricting by name would answer a narrower
// question than the one asked.
let mut rows: Vec<(String, i32)> = b
.elements
.iter()
.filter(|el| el.name.contains("btn"))
.filter_map(|el| el.rest().map(|r| (el.name.clone(), r.y)))
.collect();
if rows.is_empty() {
continue;
}
rows.sort_by(|a, b| a.1.cmp(&b.1));
let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
if rows.len() == 4
&& ys
.iter()
.zip(WANT.iter())
.all(|(a, b)| (a - b).abs() <= TOL)
{
hits += 1;
println!(
" {arch} entry {i:>2} {} record(s): {}",
rows.len(),
rows.iter()
.map(|r| r.0.as_str())
.collect::<Vec<_>>()
.join(" ")
);
println!(" rows {ys:?} gaps {gaps:?}");
}
}
}
println!(
"\n {scanned} build(s) scanned across {} pak(s); {hits} match the",
paks.len()
);
println!(" DIFFICULTY row signature within +/-{TOL} px.");
println!(" Expected: exactly 2 -- the EN/JP pair. More means a RIVAL exists and");
println!(" the geometric identification is not unique; fewer means this reader");
println!(" cannot see the incumbents and its zero would mean nothing.");
}

View File

@@ -0,0 +1,66 @@
//! Probe: does a `.rat` leaf record carry geometry the parent element does not?
//!
//! The GPU capture says the title submits `ptloop01`/`ptloop02` scaled 600 %/800 %
//! and rotated +30.26°/45.28°, while the export writes scale 100 % and rotation
//! 0 for both. `ui_layout`'s own note says the rotated quads come from the
//! **nested `.rat` leaf records**, which is where `export_screen` already looks
//! for focus records and nowhere else.
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
let e = &ar.entries()[4]; // entry 4 = the English title
let bundle = ar.read(e).expect("read");
let b = ui_layout::parse_build(&bundle).expect("parse");
println!(
"build has {} elements, {} records",
b.elements.len(),
b.records.len()
);
let mut names: Vec<&String> = b.records.keys().collect();
names.sort();
println!("records: {names:?}");
for el in &b.elements {
if !el.name.starts_with("ptloop") {
continue;
}
let r = el.rest();
println!(
"\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}",
el.name,
el.sprite,
r.map(|r| (r.scale_x, r.scale_y)),
r.map(|r| r.rotation_deg)
);
if let Some(&(off, size)) = b.records.get(&el.name) {
match ui_layout::parse_build(&bundle[off..off + size]) {
Some(leaf) => {
println!(
" LEAF {} parses: {} element(s)",
el.name,
leaf.elements.len()
);
for le in &leaf.elements {
let lr = le.rest();
println!(
" {:<20} rest scale {:?} rot {:?} pos {:?}",
le.name,
lr.map(|r| (r.scale_x, r.scale_y)),
lr.map(|r| r.rotation_deg),
lr.map(|r| (r.x, r.y))
);
for k in &le.keyframes {
println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}",
k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y,
k.fade, k.unknown_4, k.unknown_8);
}
}
}
None => println!(" LEAF {} does NOT parse as a build", el.name),
}
} else {
println!(" no record named {}", el.name);
}
}
}

View File

@@ -0,0 +1,188 @@
//! Run the Decoder's own falsifier for "a nested record's `+0x08` is its loop
//! length" against the bundles THIS PORT SHIPS, before shipping 120 for 105.
//!
//! HANDOFF (`27938aa`, delivered at `07e93ce`) says the plate's glow cycles over
//! **120** units while its keyframes end at 105, and instructs the port to stop
//! shipping 105. The port's `ScreenView` derives a looping record's period from
//! the element's largest keyframe time, so it does ship 105 — and the field that
//! would fix it is decoded in an *example* and a *test* on the Decoder's branch
//! and **exposed in `sylpheed_formats`' public API on no ref at all**.
//!
//! ✅ **Since then the crate exposes it** — `ui_layout::loop_length_units`, taken
//! at `formats-pin-2026-08-30b` — and `screen.rs` has deleted its local copy.
//!
//! 🔴 **This file deliberately did NOT follow it.** The read below is still the
//! raw four bytes, because the moment a control calls the API it is meant to
//! check, it stops being a control and becomes the API tested against itself. It
//! is the independent reading that makes the falsifier mean anything.
//!
//! So this re-runs both of their controls:
//!
//! * **the falsifier** — `+0x08 < max keyframe time` must never occur; an
//! animation cannot restart before its own last pose;
//! * **non-triviality** — if every record had `+0x08 == max t` the field would
//! carry nothing and the name would be a relabelling of the keyframes.
//!
//! and adds the one they could not run: the same two, restricted to the records
//! **this port actually animates**. A disc-wide 0.00 % violation rate says
//! nothing about my six screens if all six sit in the exceptional tail.
use std::collections::BTreeMap;
use sylpheed_formats::{pak, ratc, ui_layout};
/// The records the port animates: the plate glow, the five menu focus records,
/// and the title's two sweeps. Named rather than pattern-matched, because the
/// point is to check the ones that are shipped, not the ones that match a glob.
const SHIPPED: &[&str] = &[
"ptbtn00f", "ptbtn01f", "ptbtn02f", "ptbtn03f", "ptbtn04f", "ptbtn05f", "ptloop01", "ptloop02",
];
/// Which header word to read as the loop length. `0x08` is the decoded one;
/// `--offset=N` re-runs the same falsifier at a neighbour, which is the only way
/// to learn whether the falsifier is evidence for the offset or just for the
/// disc.
static mut OFFSET: usize = 8;
fn main() {
let off: usize = std::env::args()
.find_map(|a| a.strip_prefix("--offset=").and_then(|v| v.parse().ok()))
.unwrap_or(8);
unsafe { OFFSET = off };
println!(" reading the loop length at header +0x{off:02x}");
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
let mut slack_hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut shipped: BTreeMap<String, (i64, i64)> = BTreeMap::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (rn, &(o, s)) in &b.records {
if o + off + 4 > by.len() || o + s > by.len() {
continue;
}
if &by[o..o + 4] != b"RATC" {
continue;
}
// 🔴 THE FALSIFIER IS RUN AT NEIGHBOURING OFFSETS TOO. The
// Decoder's struct-layout control showed that a homogeneous
// repeated table type-checks at every field boundary, so an
// interior test carries no information about phase -- 69 of 70
// records passed under BOTH shifted alignments of their dialog
// table. My falsifier (`+0x08 >= max keyframe time`) is an
// interior test of exactly that kind, and I re-ran it as
// "confirmation" without asking whether it discriminates the
// OFFSET or merely the file.
let len = u32::from_be_bytes(by[o + off..o + off + 4].try_into().unwrap()) as i64;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else {
continue;
};
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0) as i64;
if maxt == 0 {
continue;
} // static: declares no cycle at all
total += 1;
let slack = len - maxt;
*slack_hist.entry(slack).or_default() += 1;
if slack == 0 {
exact += 1
} else if slack > 0 {
holds += 1
} else {
violations += 1
}
let stem = rn.trim_end_matches(".rat");
if SHIPPED.contains(&stem) {
shipped.entry(stem.to_string()).or_insert((len, maxt));
}
}
}
}
println!("disc-wide, records with timed keyframes: {total}");
println!(
" +08 == max t (exact) : {exact:5} {:5.1} %",
pc(exact, total)
);
println!(
" +08 > max t (a hold) : {holds:5} {:5.1} %",
pc(holds, total)
);
println!(
" +08 < max t <- FALSIFIER : {violations:5} {:5.2} %",
pc(violations, total)
);
println!("\nslack distribution, most common first:");
let mut h: Vec<_> = slack_hist.iter().collect();
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
for (k, n) in h.iter().take(8) {
println!(" slack {k:>6} : {n}");
}
println!("\nthe records THIS PORT animates:");
println!(
" {:<12} {:>6} {:>7} {:>7}",
"record", "+0x08", "max t", "slack"
);
let (mut ship_exact, mut ship_hold, mut ship_bad) = (0, 0, 0);
for (n, (len, maxt)) in &shipped {
let slack = len - maxt;
match slack {
0 => ship_exact += 1,
s if s > 0 => ship_hold += 1,
_ => ship_bad += 1,
}
println!(
" {n:<12} {len:>6} {maxt:>7} {slack:>7}{}",
if slack < 0 { " 🔴 FALSIFIED" } else { "" }
);
}
println!("\n shipped: {ship_exact} exact, {ship_hold} hold, {ship_bad} falsified");
if shipped.len() < SHIPPED.len() {
let missing: Vec<_> = SHIPPED
.iter()
.filter(|s| !shipped.contains_key(**s))
.collect();
println!(" ⚠️ not found on the disc: {missing:?} -- a name the port ships and");
println!(" this control never checked is worse than a violation it found.");
}
println!(
"\n verdict: {}",
if ship_bad > 0 {
"🔴 the reading fails on a record the port animates -- do NOT adopt"
} else if ship_hold == 0 {
"⚠️ every shipped record is exact, so this port cannot tell loop length\n from max keyframe time -- adopting 120 would change nothing here"
} else {
"✅ falsifier clean and the field is non-trivial ON THE SHIPPED SET"
}
);
}
fn pc(n: usize, d: usize) -> f64 {
if d == 0 {
0.0
} else {
100.0 * n as f64 / d as f64
}
}

View File

@@ -0,0 +1,91 @@
//! Why do two "every pak, every timed record" scans disagree by 86 %?
//!
//! This port counts 1 781 timed nested records and reports `+0x08 == max t` at
//! 92.3 %. The Decoder counts 3 311 and reports 49.6 %. Both scans are described
//! the same way, so at least one of them is narrower than its own description --
//! and the exactness figure this port has quoted repeatedly is a property of
//! whichever subset it actually walks.
//!
//! Counts the survivors at each filter, so the gap is located rather than
//! guessed at.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut records, mut in_bounds, mut magic, mut parsed, mut timed) = (0, 0, 0, 0, 0);
let (mut untimed, mut all_at_zero) = (0usize, 0usize);
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (_, &(o, s)) in &b.records {
records += 1;
if o + 12 > by.len() || o + s > by.len() {
continue;
}
in_bounds += 1;
if &by[o..o + 4] != b"RATC" {
continue;
}
magic += 1;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else {
continue;
};
parsed += 1;
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0);
// 🔴 `maxt == 0` merges two different populations, and the
// Decoder's cause -- `.max()` returning `Some(0)` -- is only one
// of them. A record with NO timed keyframe has no largest
// keyframe time; a record whose keyframes all sit at t=0 has
// one, and it is 0. Only the first is a question without
// content. Both of us called all 1 530 "the question has no
// meaning"; that is true of one group and an assumption about
// the other.
let any_timed = lb
.elements
.iter()
.any(|el| el.keyframes.iter().any(|k| k.time.is_some()));
if maxt == 0 {
if any_timed {
all_at_zero += 1
} else {
untimed += 1
}
continue;
}
timed += 1;
}
}
}
println!(" records declared by parse_build : {records}");
println!(" within the entry's bounds : {in_bounds}");
println!(
" carrying the RATC magic : {magic} <- {} dropped here",
in_bounds - magic
);
println!(" parsing as a nested build : {parsed}");
println!(" with a largest keyframe time > 0: {timed}");
println!(" of the {} excluded:", untimed + all_at_zero);
println!(" NO timed keyframe at all : {untimed} <- the question has no content");
println!(" timed, but every pose at t=0 : {all_at_zero} <- a largest time EXISTS, and it is 0");
}

View File

@@ -0,0 +1,67 @@
//! Do any screens THIS PORT SHIPS carry a record that declares a cycle while all
//! its poses sit at t = 0?
//!
//! The substantive finding from the denominator thread: 1 530 nested records
//! disc-wide are timed with every pose at t = 0 and still declare a nonzero
//! `+0x08`. A static record that declares a cycle length is a real thing, not a
//! counting artefact — so the question for the port is whether it holds one of
//! those still while the disc says it cycles.
//!
//! Scoped to `GP_TITLE`, because that is the archive the port exports.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize);
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (name, &(o, s)) in &b.records {
if o + 12 > by.len() || o + s > by.len() || &by[o..o + 4] != b"RATC" {
continue;
}
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else {
continue;
};
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0);
let len = ui_layout::loop_length_units(&by[o..o + s]).unwrap_or(0);
total += 1;
if maxt == 0 && len > 0 {
hits += 1;
// A cycle can only produce motion if there is more than one pose
// to move between. All-at-t=0 with a single keyframe per element
// is visually inert however it is played.
let kf: usize = lb.elements.iter().map(|el| el.keyframes.len()).sum();
let multi = lb
.elements
.iter()
.filter(|el| el.keyframes.len() > 1)
.count();
if multi > 0 {
multipose += 1
}
println!(
" entry {i:>2} {name:<16} {len}-unit cycle, {kf} keyframe(s) \
across {} element(s), {multi} with >1 pose",
lb.elements.len()
);
}
}
}
println!("\n {total} nested record(s) in GP_TITLE; {hits} declare a cycle while static.");
println!(" Of those, {multipose} have an element with MORE THAN ONE pose -- the only");
println!(" ones where looping could differ visibly from holding. A record whose");
println!(" elements each carry a single pose renders identically either way, so a");
println!(" declared cycle there is inert rather than a defect.");
}

View File

@@ -0,0 +1,59 @@
//! Throwaway probe: how long is each region chunk of a movie's voice?
//!
//! The question it answers is whether the chunks of a resolved voice region are
//! CONSECUTIVE SEGMENTS (concatenate them) or ALTERNATE TAKES (chunk 0 is the
//! whole track). Getting that backwards plays the dialogue three times over.
use std::process::Command;
use sylpheed_formats::{media, slb::VoiceLang};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&disc);
for movie in ["ADV", "S00A", "RT01A"] {
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
else {
println!("{movie}: no region");
continue;
};
let riffs = media::voice_region_riffs(&src, s, e).expect("riffs");
println!(
"{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)",
e - s,
riffs.len()
);
for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav"));
std::fs::write(&p, r).unwrap();
// XMA declares no duration, so DECODE it and measure the result.
let w = std::env::temp_dir().join(format!("vc_{movie}_{i}.wav"));
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p)
.arg(&w)
.output();
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(&w)
.output()
.unwrap();
let dur = String::from_utf8_lossy(&out.stdout).trim().to_string();
if std::env::var("KEEP_WAV").is_ok() {
let keep = std::path::Path::new(&std::env::var("KEEP_WAV").unwrap())
.join(format!("{movie}_chunk{i}.wav"));
let _ = std::fs::rename(&w, &keep);
println!(" kept -> {}", keep.display());
} else {
let _ = std::fs::remove_file(&w);
}
println!(" chunk {i}: {} bytes -> {dur} s", r.len());
let _ = std::fs::remove_file(&p);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,9 @@
//! * a `buttons` entry naming an element that is not a button, or out of
//! resting-Y order;
//! * a sprite path that does not exist, or a PNG that does not decode;
//! * a name presented as recovered when it was authored.
//! * a name presented as recovered when it was authored;
//! * an audio file that is silent or clips -- the two audio failures that pass
//! every check that is not looking for them.
//!
//! It deliberately does **not** check that the export matches the disc. That is
//! what `sylpheed-cli screen render` is for.
@@ -48,8 +50,9 @@ impl Ctx {
/// name. Anything else means a consumer has to guess, which is the whole thing
/// the format exists to prevent.
fn is_hex32(v: Option<&Value>) -> bool {
v.and_then(Value::as_str)
.is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()))
v.and_then(Value::as_str).is_some_and(|s| {
s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
})
}
fn check_pose(c: &mut Ctx, where_: &str, p: &Value) {
@@ -87,12 +90,17 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
// an invented one, so the provenance is mandatory and closed.
match v.get("name_source").and_then(Value::as_str) {
Some("authored") => {
if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) {
if v.get("name_why")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
c.err("name_source is `authored` but there is no `name_why`");
}
}
Some("index") => {}
other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")),
other => c.err(format!(
"name_source must be `authored` or `index`, got {other:?}"
)),
}
if let Some(s) = v.get("source") {
for key in ["archive", "entry", "build"] {
@@ -117,9 +125,22 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
let mut indices = Vec::new();
let mut buttons_by_y: Vec<(i64, String)> = Vec::new();
for (i, el) in elements.iter().enumerate() {
let id = el.get("id").and_then(Value::as_str).unwrap_or("<no id>").to_string();
let id = el
.get("id")
.and_then(Value::as_str)
.unwrap_or("<no id>")
.to_string();
let at = format!("element {i} ({id})");
for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] {
for key in [
"index",
"id",
"declared",
"role",
"kind_raw",
"pivot",
"layer_source",
"keyframes",
] {
if el.get(key).is_none() {
c.err(format!("{at}: missing `{key}`"));
}
@@ -129,7 +150,9 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
continue;
};
if idx as usize != i {
c.err(format!("{at}: `index` {idx} does not match its position {i}"));
c.err(format!(
"{at}: `index` {idx} does not match its position {i}"
));
}
indices.push(idx as usize);
@@ -150,15 +173,21 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
match el.get("layer_source").and_then(Value::as_str) {
Some("sprite") | Some("implied") => {
if !is_hex32(el.get("layer")) {
c.err(format!("{at}: layer_source claims a key but `layer` is not one"));
c.err(format!(
"{at}: layer_source claims a key but `layer` is not one"
));
}
}
Some("none") => {
if el.get("layer").is_some() {
c.err(format!("{at}: layer_source `none` but a `layer` is present"));
c.err(format!(
"{at}: layer_source `none` but a `layer` is present"
));
}
}
other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")),
other => c.err(format!(
"{at}: layer_source must be sprite/implied/none, got {other:?}"
)),
}
for key in ["sprite", "focus_sprite"] {
@@ -167,7 +196,9 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
if !path.exists() {
c.err(format!("{at}: `{key}` points at {p}, which does not exist"));
} else if let Err(e) = image::open(&path) {
c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}"));
c.err(format!(
"{at}: `{key}` {p} does not decode as an image: {e}"
));
}
}
}
@@ -175,7 +206,11 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
if let Some(r) = el.get("rest") {
check_pose(&mut c, &at, r);
if role == "button" {
if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) {
if let Some(y) = r
.get("pos")
.and_then(Value::as_array)
.and_then(|a| a[1].as_i64())
{
buttons_by_y.push((y, id.clone()));
}
}
@@ -184,10 +219,26 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
for (k, kf) in kfs.iter().enumerate() {
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
}
// The last keyframe of a group carries no time slot on the disc, and
// an invented one is exactly the kind of value this format refuses.
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
// 🔴 INVERTED 2026-08-29, and the old rule is the more interesting
// half. It read: "the last keyframe of a group carries no time slot
// on the disc, and an invented one is exactly the kind of value this
// format refuses." That was true of the OLD keyframe association,
// where a group's data stopped four bytes short of its final block's
// time slot.
//
// Under the corrected layout (`formats-pin-2026-08-29c` onward) a
// group is an 8-byte header then `frames` x {u32 time; 36-byte
// pose}, so **pose 0's time is the group's lead-in word and EVERY
// POSE IS TIMED, including the last.** The rule now says the
// opposite, and an untimed keyframe is the thing to refuse.
//
// ⚠️ This fired 150 times on a re-export and I had not run `check`
// between pinning the tag and measuring against the oracle -- the
// pixel harness was green while the format validator was failing on
// every screen with a multi-keyframe group. A correctness harness
// does not replace a format one; they fail at different layers.
if kfs.len() > 1 && kfs.iter().any(|k| k.get("t").is_none()) {
c.err(format!("{at}: a keyframe has no `t`; every pose is timed under the corrected record layout"));
}
}
}
@@ -196,7 +247,10 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
// either drops an element or draws one twice.
match v.get("paint_order").and_then(Value::as_array) {
Some(po) => {
let mut got: Vec<usize> = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect();
let mut got: Vec<usize> = po
.iter()
.filter_map(|x| x.as_u64().map(|v| v as usize))
.collect();
if got.len() != po.len() {
c.err("`paint_order` holds a non-integer");
}
@@ -238,7 +292,10 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
pub fn run(root: &Path) -> Result<usize> {
let manifest_path = root.join("manifest.json");
if !manifest_path.exists() {
bail!("{} has no manifest.json — is that an export tree?", root.display());
bail!(
"{} has no manifest.json — is that an export tree?",
root.display()
);
}
let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
let mut errors = Vec::new();
@@ -267,6 +324,8 @@ pub fn run(root: &Path) -> Result<usize> {
check_screen(root, file, &mut errors)?;
}
check_audio(root, &m, &mut errors);
if !errors.is_empty() {
for e in &errors {
eprintln!("{e}");
@@ -275,3 +334,92 @@ pub fn run(root: &Path) -> Result<usize> {
}
Ok(screens.len())
}
/// The `audio` array, checked the way a consumer would have to.
///
/// Two of these are content checks rather than schema checks, and they are here
/// on purpose. `docs/port/AUDIO-VERIFICATION.md` names silence as "the failure
/// that looks like success": a file of exactly the right duration, the right
/// channel count and the right size, full of zeroes, because something opened
/// the wrong thing. Every structural check passes it. So does clipping, which
/// the BGM can produce because it is a **sum of two stems** at unity gain.
///
/// The exporter measures both at export time and writes them here; this refuses
/// the tree if what it wrote is a file nobody would want to play. Neither is a
/// judgement about whether the audio is the RIGHT audio — nothing in this
/// binary can know that, and `docs/port/BLOCKED.md` says which parts are still
/// authored guesses.
fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
let Some(audio) = m.get("audio").and_then(Value::as_array) else {
// Absent is correct for every export taken before P6.
return;
};
for a in audio {
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
if !matches!(kind, "se" | "bgm" | "voice") {
errors.push(format!(
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
));
}
for key in ["file", "command", "why"] {
if a.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
}
}
let Some(file) = a.get("file").and_then(Value::as_str) else {
continue;
};
if !root.join(file).exists() {
errors.push(format!(
"manifest.json: lists audio {file}, which does not exist"
));
continue;
}
match a.get("peak_dbfs").and_then(Value::as_f64) {
None => errors.push(format!(
"manifest.json: audio `{name}` carries no `peak_dbfs` -- it was not measured, \
and silence is the audio failure that passes every check that is not looking \
for it"
)),
Some(p) if p <= -90.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- this file is silent"
)),
// The bound differs by kind, and the difference is the point. A
// `bgm` is something WE combined -- a sum of stems -- so a peak at
// or above full scale is our arithmetic and is refused outright. An
// `se` is a single wave off the disc: it is mastered near full
// scale, and a lossy decode of a near-full-scale signal overshoots
// by a fraction of a dB (`confirm` lands at +0.18). Refusing that
// would be refusing the disc's own mastering, and "fixing" it would
// mean attenuating a game asset to make a number smaller.
//
// 🟡 +1.0 dB is a JUDGEMENT, not a measurement: a few tenths is
// reconstruction overshoot, a whole dB is not. Nobody has measured
// the overshoot distribution across a corpus of cues, and if a cue
// ever trips this the right response is that measurement, not a
// looser bound.
// `voice` was on the strict side of this bound while it was a SUM of a
// region's chunks. It no longer is: a region carries three
// presentations of one take, so the exporter keeps ONE stream and
// performs no arithmetic on it. That puts `voice` with `se` -- a
// single wave off the disc, mastered near full scale, whose lossy
// decode overshoots by a fraction of a dB. `ADV`'s louder
// presentation measures +0.0003 dBFS at source; refusing that would
// be refusing the disc's own mastering.
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
)),
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
)),
Some(_) => {}
}
match a.get("duration_s").and_then(Value::as_f64) {
Some(d) if d > 0.0 => {}
_ => errors.push(format!(
"{file}: no positive `duration_s` -- a zero-length asset plays as silence"
)),
}
}
}

View File

@@ -12,15 +12,16 @@
//!
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod audio;
mod check;
mod video;
mod screen;
mod video;
use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ui_layout};
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
@@ -76,6 +77,48 @@ struct ManifestVideo {
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
/// What the runtime should have played, so it can report what it did.
/// See `video::Transcoded::duration_s` — the port measured its player
/// presenting 2847 % of a stream's frames, and seconds alone hide that.
duration_s: f64,
fps: f64,
}
/// One exported audio file. Carries the same provenance a video does, plus the
/// measured peak and duration: silence and clipping are the two audio failures
/// that pass every check that is not looking for them.
#[derive(Serialize)]
struct ManifestAudio {
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
/// than a prefix on `name` that a consumer would have to parse.
kind: &'static str,
name: String,
file: String,
command: String,
why: String,
#[serde(skip_serializing_if = "Option::is_none")]
peak_dbfs: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_s: Option<f32>,
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
/// runtime to announce. Absent means nothing is known to be missing --
/// never that the asset was checked and is complete.
///
/// It exists because the export could already say this and the RUNTIME
/// could not. `why` carries the full account, but it is a paragraph aimed
/// at a reader of the manifest; a player hears clean dialogue and has no
/// way to learn that a stream is absent from it. This port already
/// announces the two measured screens NEW GAME jumps over, on the principle
/// that a gap is announced before it is opened. Audio had no equivalent.
#[serde(skip_serializing_if = "Option::is_none")]
incomplete: Option<String>,
/// The game's own cue identifier where one is a NAME MATCH. Absent means
/// nobody has claimed one -- never that the binding is unknown.
#[serde(skip_serializing_if = "Option::is_none")]
name_match: Option<String>,
/// What the runtime does at the end of the file, where that was authored.
#[serde(skip_serializing_if = "Option::is_none")]
loop_mode: Option<String>,
}
#[derive(Serialize)]
@@ -88,6 +131,8 @@ struct Manifest {
screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
audio: Vec<ManifestAudio>,
warnings: Vec<String>,
}
@@ -113,11 +158,13 @@ fn load_names(authored: &Path) -> Result<NameMap> {
#[derive(serde::Deserialize)]
struct File {
archives: NameMap,
// Deserialised to model the on-disc schema, not read in Rust.
// Removing it would silently change what this struct accepts.
#[allow(dead_code)]
#[serde(default)]
also_export: AlsoExport,
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
Ok(serde_json::from_str::<File>(&raw)
.with_context(|| format!("parse {}", path.display()))?
.archives)
@@ -125,8 +172,7 @@ fn load_names(authored: &Path) -> Result<NameMap> {
/// Extra pak entries to export that `is_build` does not accept, keyed by
/// archive. AUTHORED, and each carries its own `why`.
type AlsoExport =
std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
type AlsoExport = std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
fn load_also_export(authored: &Path) -> Result<AlsoExport> {
let path = authored.join("screen_names.json");
@@ -138,8 +184,7 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
#[serde(default)]
also_export: AlsoExport,
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
Ok(serde_json::from_str::<File>(&raw)
.with_context(|| format!("parse {}", path.display()))?
.also_export)
@@ -163,9 +208,10 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
/// exactly four bundles and all four are real screens, with zero fragments. In
/// another archive it would not be, which is why this is an allow-list and not
/// a widened predicate.
fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
-> Vec<(usize, Vec<u8>)>
{
fn screen_builds(
ar: &PakArchive,
also: Option<&std::collections::BTreeMap<String, NameEntry>>,
) -> Vec<(usize, Vec<u8>)> {
let mut out = Vec::new();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
@@ -186,7 +232,11 @@ fn main() -> Result<()> {
} => run_export(&disc, &out, &authored),
Cmd::Check { out } => {
let n = check::run(&out)?;
println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display());
println!(
"{} screen(s) in {} validate against sylpheed.screen/3",
n,
out.display()
);
Ok(())
}
}
@@ -195,13 +245,55 @@ fn main() -> Result<()> {
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
let names = load_names(authored_dir)?;
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
// has to know about; it is not an error, and it is not a log line, because
// the person who needs it reads `manifest.json` and never sees stdout.
let mut warnings: Vec<String> = vec![
"GP_TITLE screen builds only. No other archive, and only the two movies \
MISSION section 6 puts in scope."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
];
// Derived output is regenerated wholesale: clear it, so a screen that stops
// being exported stops existing rather than lingering as a stale file that
// still validates.
//
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
// `video::transcode` has always carried a cache -- it writes a `.cmd`
// sidecar with the exact command, the source size and the channel count, and
// skips the encode when all three still match. Its own doc comment says
// "without it every re-export pays ~4 minutes to produce a byte-identical
// file". **This wipe deleted the sidecar and the output immediately before
// the check, so the cache had never hit once.** Six exports in one session
// paid ~48 minutes of Theora to produce five byte-identical files, and
// nothing reported it: the cache is silent when it works and silent when it
// does not.
//
// The wholesale guarantee is kept rather than weakened -- everything else is
// still cleared outright, and `prune_videos` below deletes any file in
// `video/` that this run did not claim, so a movie that stops being exported
// still stops existing.
if out.exists() {
std::fs::remove_dir_all(&out).context("clear the output tree")?;
for entry in std::fs::read_dir(out).context("clear the output tree")? {
let entry = entry?;
if entry.file_name() == "video" {
continue;
}
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(entry.path())
} else {
std::fs::remove_file(entry.path())
}
.with_context(|| format!("clear {}", entry.path().display()))?;
}
}
std::fs::create_dir_all(&out)?;
std::fs::create_dir_all(out)?;
let archive = "dat/GP_TITLE.pak";
let pak = disc.join(archive);
@@ -228,7 +320,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
None => (format!("build_{entry:02}"), "index", None),
};
let ex = screen::export_build(
&out,
out,
archive,
*entry,
build_idx,
@@ -261,20 +353,151 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// MISSION §6: the boot intro and the one new-game intro only.
let mut videos = Vec::new();
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
// 🔴 The export deviates from a HUMAN decision, and until this warning
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
// justified for one of the two movies and over-broad for the other, and
// which of the three options to take is not the exporter's call -- so it is
// reported on every run rather than left in a doc comment nobody opens.
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
warnings.push(
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
.to_string(),
);
}
for m in video::MOVIES {
match video::transcode(disc, out, m)? {
Some(t) => {
println!(" video {} -> {}", m.src, t.file);
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
videos.push(ManifestVideo {
name: t.name,
file: t.file,
command: t.command,
why: t.why,
duration_s: t.duration_s,
fps: t.fps,
});
}
None => println!(" video {} not on this disc -- skipped", m.src),
}
}
prune_videos(out, &videos)?;
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
// because they were measured off the running game and are on the disc in no
// findable form, the BGM choice because HANDOFF Q10 is a negative and
// nothing states which track a menu plays. See `authored/audio.json`.
let mut audio = Vec::new();
let audio_cfg = audio::load(authored_dir)?;
match &audio_cfg {
None => println!(" no authored/audio.json -- no audio exported"),
Some(cfg) => {
let source = media::DirectorySource::new(disc);
for a in audio::export_cues(&source, out, &cfg.se)? {
println!(" se {:<8} -> {} ({})", a.name, a.file, describe(&a));
audio.push(ManifestAudio::from(a));
}
for (role, spec) in &cfg.bgm {
match audio::export_bgm(&source, out, role, spec)? {
Some(a) => {
println!(
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
a.name,
a.file,
describe(&a),
spec.bank,
a.sub_waves
);
// HANDOFF Q10's census is "exactly two waves of
// identical duration, 32/32 banks on the disc". When
// `media` hands back a different number, SAY SO -- the
// port does not get to decide that one of them is not a
// stem, and silently summing an extra region into the
// music is precisely the media-assembly mistake MISSION
// section 2 names. The decoder's answer is what ships;
// the disagreement is what gets reported.
if a.sub_waves != 2 {
warnings.push(format!(
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
says a music bank is EXACTLY TWO waves of identical duration \
(32/32 banks). All {} are summed, because choosing which to drop \
is a decoding question and this exporter does not answer those. \
See docs/port/BLOCKED.md.",
a.sub_waves, spec.bank, a.sub_waves
));
}
audio.push(ManifestAudio::from(a));
}
// Not an error: the authored bank may simply not be on this
// disc, and the export of everything else is still good.
None => warnings.push(format!(
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
this disc's sound.pak -- no BGM exported for that role.",
spec.bank
)),
}
}
}
}
// The cutscene voices are DERIVED, not authored, so this runs outside the
// `authored/audio.json` block above: the binding comes off the disc (the
// movie manifest in `tables.pak`), and an export with no authored audio
// should still carry the dialogue for the movies it ships.
//
// A movie that resolves to no region is genuinely unvoiced and gets a
// warning rather than a substitute -- for both movies in scope this port
// expects a region, so a warning here is a real signal and not noise.
{
let source = media::DirectorySource::new(disc);
for (stem, len) in &movie_lengths {
// The presentation choice is AUTHORED and this block runs even when
// there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
let weights = audio_cfg
.as_ref()
.map(|c| c.stream_weights.clone())
.unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
// export is known to be missing audio the game plays, and
// the failure sounds like success: one stream decodes to
// clean dialogue, so nobody listening finds out.
if a.kept_waves < a.content_waves {
warnings.push(format!(
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
three XMA contexts, byte sizes matching the disc payloads exactly). \
The export carries ONE. Nothing in the audio reveals this -- a \
single stream is clean audible dialogue. Held rather than summed \
because an equal-gain sum of channel pairs is not a downmix and \
would be a second guess, not a fix. See authored/audio.json voice \
and docs/port/BLOCKED.md.",
a.file, a.sub_waves
));
}
println!(
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
a.name,
a.file,
describe(&a),
a.kept_waves,
a.sub_waves,
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
);
audio.push(ManifestAudio::from(a));
}
None => warnings.push(format!(
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
)),
}
}
}
let manifest = Manifest {
format: "sylpheed.manifest/1",
@@ -283,16 +506,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
disc: disc.display().to_string(),
screens,
videos,
warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
],
audio,
warnings,
};
std::fs::write(
out.join("manifest.json"),
@@ -301,3 +516,79 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
println!("wrote {}/manifest.json", out.display());
Ok(())
}
impl From<audio::Exported> for ManifestAudio {
fn from(a: audio::Exported) -> Self {
ManifestAudio {
kind: a.kind,
name: a.name,
file: a.file,
command: a.command,
why: a.why,
peak_dbfs: a.peak_dbfs,
duration_s: a.duration_s,
incomplete: (a.kept_waves < a.content_waves).then(|| {
format!(
"{} of {} streams. The running game decodes all {} concurrently. \
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
WHICH streams are dropped and why differs per asset; the manifest \
entry's `why` says, and it is not the same story twice.",
a.kept_waves, a.sub_waves, a.sub_waves
)
}),
name_match: a.name_match,
loop_mode: a.loop_mode,
}
}
}
/// The two numbers worth reading on an audio line, in the console.
///
/// Printed rather than left to the manifest because the failure this catches is
/// a SILENT file: the right duration, the right channel count, the right size,
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
/// person notices without being told to look.
fn describe(a: &audio::Exported) -> String {
let peak = match a.peak_dbfs {
Some(p) => format!("peak {p:.1} dBFS"),
None => "peak unmeasured".into(),
};
match a.duration_s {
Some(d) => format!("{d:.3} s, {peak}"),
None => peak,
}
}
/// Delete anything in `video/` this run did not produce.
///
/// `video/` is the one directory the wholesale wipe spares, so that the
/// transcode cache survives to be consulted. This restores the guarantee the
/// wipe exists for: a movie that stops being exported stops existing, rather
/// than lingering as a file the manifest no longer lists.
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
let dir = out.join("video");
if !dir.exists() {
return Ok(());
}
let mut keep: Vec<String> = Vec::new();
for v in kept {
if let Some(name) = Path::new(&v.file).file_name() {
let name = name.to_string_lossy().into_owned();
keep.push(name.clone());
// The cache sidecar goes with the file it stamps.
if let Some(stem) = Path::new(&name).file_stem() {
keep.push(format!("{}.cmd", stem.to_string_lossy()));
}
}
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if keep.contains(&name) {
continue;
}
println!(" video {name} is no longer exported -- removed");
let _ = std::fs::remove_file(entry.path());
}
Ok(())
}

View File

@@ -103,6 +103,14 @@ pub struct FocusElement {
pub id: String,
pub declared: String,
pub sprite: Option<String>,
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
///
/// A leaf's sprite may live in the leaf's own table or in the parent
/// bundle's, so the bit is looked up in the same two places, in the same
/// order, that the PNG is written from.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub pivot: [u32; 2],
pub rest: Rest,
pub keyframes: Vec<Keyframe>,
@@ -112,6 +120,34 @@ pub struct FocusElement {
pub struct Focus {
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
pub record: String,
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
/// units — which is not the same thing as the last keyframe's time.
///
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
/// the period from the largest keyframe time — what the port did until now —
/// runs it 14 % fast and deletes the dark rest entirely.
///
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
/// bare until 2026-09-01, which is a population-scoped statistic reported
/// without its population):
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
/// their own last pose**; on the eight records this port animates, seven
/// exact and `ptbtn00f` the one hold.
///
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
/// held its own guard and byte read, because the field was decoded in an
/// example and a test and exposed in no public API on any ref. It is now
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
/// the local copy is deleted — the doc comment that promised that deletion
/// is the only reason it did not quietly become permanent.
pub loop_length_units: Option<u32>,
/// Back-to-front, in the leaf's own declaration order.
pub elements: Vec<FocusElement>,
}
@@ -141,6 +177,59 @@ pub struct Element {
/// convention and a consumer may still want the bare highlight texture.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Focus>,
/// This element's own `.rat` leaf, when its declared name is itself a
/// record in the bundle.
///
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
/// the title declare scale 100 % and rotation 0 at the parent, and their
/// leaves declare **(100, 600) at +30°** and **(100, 800) at 45°** — and
/// the leaves *move*, x from 639 → 1521 and 1721 → 839. `ui_layout`'s own
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
/// records, which the census never opened."* Neither did this exporter: it
/// opened a leaf only for a FOCUS record, via `highlight_name`.
///
/// That omission is measurable. It is the whole of the title's 1.82 %
/// disagreement with the oracle — the port draws two 400 px sprites upright
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
/// quads across the frame at opposite leans.
///
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
/// ramp on a different span — parent 0→255 over t=70…238, leaf
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
/// question and not the port's to answer. The data is exported so it stops
/// being invisible; `ScreenView` ignores it until the composition rule is
/// known.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf: Option<Focus>,
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
/// what the game draws.
///
/// Decided here rather than in the runtime because it is disc knowledge.
/// The Decoder's rule: *"the discriminator is which record carries the
/// geometry, not a fixed order"* — and the census over this export splits
/// cleanly, with no ambiguous middle:
///
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
/// exactly. That is the BASE-record case `screen.rs` already handled: the
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
/// y=402) and the parent wins. Flag is false; nothing changes.
/// * **16 differ**, and all of them differ in scale or rotation, not by a
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
/// and (100,800) at 45° against an identity parent), two
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
///
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
/// measured. The other two are the same shape and are **not** separately
/// confirmed; they are flagged so the harness can adjudicate them rather
/// than being asserted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub leaf_carries_geometry: bool,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
@@ -161,6 +250,23 @@ pub struct Element {
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
/// `"implied"` = **measured off the running game**, for elements that carry
/// no header. `"none"` = neither; sorts last.
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
/// `0x02`.
///
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
/// A name-keyed map cannot answer for a screen nobody drove the game to,
/// which is why the port was drawing the English menus additive and the
/// Japanese ones alpha-over — asserting by omission that the JP build
/// blends differently. The bit is on the disc for every screen at once.
///
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
/// 0 of 14 additive elements set it and 9 non-additive ones do.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub layer_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
@@ -201,6 +307,35 @@ pub struct Screen {
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
/// and not to be trusted for anything else.
pub buttons: Vec<String>,
/// The instant every element of this screen is settled at, and the width of
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
/// units, absent when the screen has fewer than two keyframe times.
///
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
/// each element at its own `rest()` is right for anything that ends the
/// screen settled and **exactly wrong for a transient**: the title's
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t5456, 0
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
/// it burning forever. There are five of these, and `rest()` draws all five
/// at once, saturating the light arc.
///
/// The window is the **longest interval containing no keyframe time**, over
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
/// that exclusion is what reproduces the Decoder's independently computed
/// `[160, 236]` for the title: including the `ptloop` leaves gives
/// `[269, 540]` instead.
///
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
/// export the widths split with nothing in between — `press_start` 214,
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
#[serde(skip_serializing_if = "Option::is_none")]
pub settle_window: Option<[i64; 3]>,
/// What this file does not answer. A consumer needing one of these must get
/// it from `authored/`.
pub unresolved: Vec<&'static str>,
@@ -281,7 +416,6 @@ pub fn export_build(
Ok(true)
}
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
fn highlight_name(sprite: &str) -> Option<String> {
let (stem, ext) = sprite.rsplit_once('.')?;
@@ -316,6 +450,86 @@ pub fn export_build(
// Contrast with a BASE record, where the leaf duplicates the parent's
// placement and the two can differ by a unit (ptbtn04: parent y=401,
// leaf y=402). There the parent wins. Here there is no parent.
// Reads one record in the bundle as a nested build and returns its
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
// element whose OWN declared name is a record (`ptloop01.rat`). One
// implementation, because the second case was missing for eight
// milestones and a second copy is how it would go missing again.
let read_leaf = |rec: &str,
written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>|
-> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else {
return Ok(None);
};
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None);
};
let mut fes = Vec::new();
for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(
&sprite_dir,
written,
sp,
&bundle[off..off + size],
&leaf.sprites,
)? || write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
missing.push(sp.to_string());
}
let Some(r) = fe.rest() else { continue };
fes.push(FocusElement {
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf,
&bundle[off..off + size],
sp,
)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
scale: [r.scale_x, r.scale_y],
tint_rgba: hex32(r.tint),
fade_argb: hex32(r.fade),
rotation_deg: r.rotation_deg,
t: r.time,
},
keyframes: fe
.keyframes
.iter()
.map(|k| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
rotation_deg: k.rotation_deg,
})
.collect(),
});
}
Ok(if fes.is_empty() {
None
} else {
Some(Focus {
record: rec.to_string(),
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
})
})
};
// An element whose own declared name is a record in this bundle carries
// its geometry THERE, not in its parent entry. See `Element::leaf`.
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
let mut focus = None;
if let Some(rec) = highlight_name(&el.name) {
if let Some(&(off, size)) = b.records.get(&rec) {
@@ -328,9 +542,13 @@ pub fn export_build(
// into the leaf slice) or in the parent's.
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(&sprite_dir, &mut written, sp,
&bundle[off..off + size], &leaf.sprites)?
|| write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)?
if write_from(
&sprite_dir,
&mut written,
sp,
&bundle[off..off + size],
&leaf.sprites,
)? || write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
@@ -341,6 +559,12 @@ pub fn export_build(
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf,
&bundle[off..off + size],
sp,
)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
@@ -365,7 +589,13 @@ pub fn export_build(
});
}
if !fes.is_empty() {
focus = Some(Focus { record: rec, elements: fes });
focus = Some(Focus {
record: rec,
loop_length_units: ui_layout::loop_length_units(
&bundle[off..off + size],
),
elements: fes,
});
}
}
}
@@ -397,10 +627,21 @@ pub fn export_build(
sprite: sprite_out,
focus_sprite,
focus,
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
let p = el.rest();
l.elements.iter().any(|le| {
p.is_none_or(|p| {
le.rest.scale != [p.scale_x, p.scale_y]
|| le.rest.rotation_deg != p.rotation_deg
})
})
}),
leaf,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
parent: el.parent,
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
layer_source,
layer,
focused: el.focused,
@@ -427,6 +668,12 @@ pub fn export_build(
.collect();
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let window = settle_window(&elements);
let order = forced_backdrop_first(
ui_layout::derived_paint_order(&b, bundle),
&elements,
[b.design_w, b.design_h],
);
let screen = Screen {
format: "sylpheed.screen/3",
exporter: exporter.to_string(),
@@ -441,8 +688,9 @@ pub fn export_build(
name_why,
design: [b.design_w, b.design_h],
elements,
paint_order: ui_layout::derived_paint_order(&b, bundle),
paint_order: order,
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
settle_window: window,
unresolved: vec![
// The time unit is measured off the running game, not on the disc.
"keyframe_time_unit",
@@ -473,3 +721,236 @@ pub fn export_build(
missing,
})
}
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
///
/// See [`Screen::settle_window`] for why this is the settled instant and why
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
let mut times: Vec<i64> = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
.collect();
times.sort_unstable();
times.dedup();
if times.len() < 2 {
return None;
}
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
//
// The widest keyframe-free interval is only a settled state if the screen is
// actually PRESENTING something across it. `press_start` is the case that
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
// and the runtime then answered every question about that screen at t=107.
// The result was that the PRESS (A) plate could not be drawn at any instant
// at all, including the boot's own end state, whose entire purpose is to
// show it.
//
// The fix is not a tuned threshold: it is that the heuristic was reading an
// interval where the screen is BLANK as the interval where it has arrived.
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
// under the runtime's 30-unit bar, so it falls back to each element's own
// hold -- which is the plate, opaque, exactly as the disc declares it.
//
// ⚠️ This does not disturb the windows the settle instant was measured on.
// `title` keeps [160, 236]: elements are visible across it, and the
// Decoder's draw stream independently found the game's clock freezing in
// that same interval.
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
let (a, b) = times
.windows(2)
.map(|w| (w[0], w[1]))
.filter(|(a, b)| visible_at((a + b) / 2))
.max_by_key(|(a, b)| b - a)?;
Some([a, b, (a + b) / 2])
}
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
fn alpha_at(e: &Element, t: i64) -> u8 {
let ks = &e.keyframes;
let a = |k: &Keyframe| {
(u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16).unwrap_or(0) >> 24) as i64
};
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return 0;
}
if t <= timed[0].t.unwrap() as i64 {
return a(timed[0]) as u8;
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return a(w[0]) as u8;
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
}
}
a(timed[timed.len() - 1]) as u8
}
/// Scale of one element at instant `t`, in percent per axis, under the same
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
/// that animates passes through every value between its keyframes.
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return [100.0, 100.0];
}
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
if t <= timed[0].t.unwrap() as i64 {
return [g(timed[0], 0), g(timed[0], 1)];
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return [g(w[0], 0), g(w[0], 1)];
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return [
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
];
}
}
let l = timed[timed.len() - 1];
[g(l, 0), g(l, 1)]
}
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
/// file forces it there.
///
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
/// *an element that covers the screen and is fully opaque at some instant cannot
/// paint above anything visible at that instant; where the elements visible
/// during its opaque span are ALL of them, its position is forced to first.*
///
/// It was found because `build_12`/`build_15` are **black at every instant** of
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
/// 39 instants while all 9 other elements live and die inside that span. A
/// screen that is black for its whole life is impossible on its face, which is
/// the only kind of check that survives two renderers sharing an assumption:
/// `sylpheed-cli` agreed with the port here because it agreed about
/// `implied_layer_key`.
///
/// Two measured controls, both prior orders off the running game:
///
/// | primitive | measured | opaque instants | forced below | |
/// |---|---|---|---|---|
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
///
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
/// matches 77 of 80 and fails on exactly the three families that cross it —
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
/// an overlay* and is measured painting first. The name is not the rule.
///
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
/// `.t32` textures must sort first *against their own layer keys*. **An
/// element's alpha says nothing about whether its texture covers the screen** —
/// most of a sprite may be transparent.
///
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
/// ordering — it says nothing about elements that are constrained but not
/// forced. Delete this when a pinned `sylpheed-formats` does it.
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
let screen_end: i64 = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
.map(i64::from)
.max()
.unwrap_or(0);
let forced: Vec<usize> = elements
.iter()
.enumerate()
.filter(|(_, e)| {
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
// sprite". Those coincide in GP_TITLE and the distinction is still
// the whole point, because the negative test guards a SYMPTOM.
//
// The rule needs the element's alpha to BE its pixels' alpha. That
// is true of a `.prm` solid quad and of nothing else. The Decoder
// found this the expensive way twice: first `.t32` sprites (an
// element's alpha says nothing about a texture that is mostly
// transparent), guarded with "no sprite" -- and then `.tbm`, which
// is 38 of their 80 forced-first verdicts and declares fade
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
// make the screen white; no screen is white, so a `.tbm`'s white is
// a modulation ON a texture and its element alpha proves nothing
// about coverage either.
//
// "No sprite" would keep admitting a `.tbm` that this exporter
// happens not to emit a sprite for. `role == "primitive"` cannot.
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
// full-screen element here is `.prm` and pure black, checked -- so
// this changes no verdict today and is a guard against a corpus
// that grows.
// Cheap prefilter only -- the binding coverage test is per-instant,
// in `covers` below. An element scaled ABOVE 100 could cover the
// screen from a smaller declared size, so this deliberately does
// not reject on size.
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
})
.filter(|(i, e)| {
let span: Vec<i64> = e
.keyframes
.iter()
.filter_map(|k| k.t)
.map(i64::from)
.collect();
let Some(&lo) = span.first() else {
return false;
};
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
// Declared size alone is not what the element draws: scale is a
// percent per axis and it animates. `pbafc.prm` is the disc's own
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
// about 17x18 px, a moving glint rather than a wash. A rule that
// read its declared size would call it screen-covering.
//
// Nothing in GP_TITLE needs this: every layerless full-screen
// element here is at scale 100 on every keyframe, so no verdict
// moves. It is in because the data that would break it exists on
// this disc, which is a better reason than a failure would have been.
let covers = |t: i64| {
let sc = scale_at(e, t);
e.size.is_some_and(|s| {
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
})
};
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
// that shows why: it declares ONE keyframe, opaque black full-screen
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
// a single-instant event instead of the thing that is on screen for
// the whole splash. So the span runs to the SCREEN's last keyframe.
let hi = screen_end.max(*span.last().unwrap());
let opaque: Vec<i64> = (lo..=hi)
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
.collect();
if opaque.is_empty() {
return false;
}
// Every OTHER element must be visible somewhere inside that span.
elements
.iter()
.enumerate()
.all(|(j, o)| j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0))
})
.map(|(i, _)| i)
.collect();
if forced.is_empty() {
return order;
}
let mut out = forced.clone();
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
out
}

View File

@@ -63,29 +63,103 @@ pub const MOVIES: &[Movie] = &[
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
/// default *is* this matrix; the point is that the manifest now says so.
///
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
/// why the normalisation is here rather than the textbook coefficients.
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
///
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
/// have). This constant is that matrix scaled by 0.4142 — the same relative
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
/// manifest or the docs said so. Recording the command you ran does not disclose
/// that it is not the command you were given.
///
/// The original justification for the deviation was *"the unnormalised form
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
/// question, because one sample at full scale and two seconds of square wave
/// give the same number. Re-measured properly (float decode, whole file, count
/// the samples that would clamp):
///
/// | | peak | ≥ full scale | > +1 dB over | longest run |
/// |---|---|---|---|---|
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
/// | `S00A`, MISSION §6 | 1.34 dBFS | **0** | 0 | — |
///
/// So the pin really does overload `ADV` — and this constant is over-broad,
/// because `S00A` never needed it. The smallest single scalar under which
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
///
/// **Not changed here.** The level of a mix is what §6 reserves to a human
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
/// with these numbers instead. See `docs/port/DECISIONS.md`.
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
/// How many audio channels the source declares.
fn channels(src: &Path) -> Result<u32> {
let out = Command::new("ffprobe")
.args([
"-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=channels", "-of", "csv=p=0",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=channels",
"-of",
"csv=p=0",
])
.arg(src)
.output()
.context("run ffprobe -- is it on PATH?")?;
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
Ok(String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.unwrap_or(2))
}
/// Duration and frame rate of a finished transcode, straight from the file.
///
/// Probed from the OUTPUT, not the source: what the runtime will play is this
/// file, and the two differ — `ADV` is 137.44 s against a 137.71 s source.
/// Returns zeros rather than failing, because a missing number should make the
/// runtime say "unknown", not stop an export that otherwise succeeded.
fn probe_timebase(out: &Path) -> (f64, f64) {
let probe = |entries: &str, stream: bool| -> String {
let mut c = Command::new("ffprobe");
c.args(["-v", "error"]);
if stream {
c.args(["-select_streams", "v:0"]);
}
c.args(["-show_entries", entries, "-of", "csv=p=0"])
.arg(out);
c.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
};
let secs = probe("format=duration", false).parse().unwrap_or(0.0);
// `r_frame_rate` is a rational, "30/1".
let rate = probe("stream=r_frame_rate", true);
let fps = match rate.split_once('/') {
Some((n, d)) => n.parse::<f64>().unwrap_or(0.0) / d.parse::<f64>().unwrap_or(1.0),
None => rate.parse().unwrap_or(0.0),
};
(secs, fps)
}
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
let mut v: Vec<String> = [
"-hide_banner", "-loglevel", "error", "-y",
"-i", &src.display().to_string(),
"-c:v", "libtheora", "-q:v", "8",
"-c:a", "libvorbis", "-q:a", "5",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
&src.display().to_string(),
"-c:v",
"libtheora",
"-q:v",
"8",
"-c:a",
"libvorbis",
"-q:a",
"5",
]
.iter()
.map(|s| s.to_string())
@@ -108,6 +182,30 @@ pub struct Transcoded {
pub file: String,
pub command: String,
pub why: &'static str,
/// The transcode's own duration and frame rate, probed from the file that
/// was just written.
///
/// Recorded so the RUNTIME can say what it actually presented.
///
/// 🔴 CORRECTED 2026-09-01. This read: *"Godot's video player drops frames to
/// hold its schedule, and it drops a lot of them here — measured at 28 % of [refuted]
/// `S00A`'s frames presented and 47 % of `ADV`'s"*. **Both numbers are
/// retracted.** They came from CONTENDED runs, and the counter is an upper
/// bound on ENGINE frames that is vacuous once the engine outruns the stream
/// — quiet, `ADV` draws 6 480 frames across a 4 123-frame video. On a quiet
/// box the bound is 8890 % for `S00A`, and playback runs **+6.7 %…+6.9 %**
/// long for both films. What survives is that elapsed seconds hide whatever
/// the player does, which is why the count is in the manifest. Without a frame count in the manifest a run can only
/// report elapsed seconds, and elapsed seconds are exactly what stays
/// plausible while three frames in four go missing.
///
/// 🔴 This field exists because the port asserted the opposite. The claim was
/// *"a player that runs long decoded everything"*, argued from the absence of
/// an overrun rather than measured; the measurement was four lines and
/// refuted it. **The instrument is now permanent so the argument cannot be
/// made again from a run that never counted.**
pub duration_s: f64,
pub fps: f64,
}
/// Transcode one movie, skipping the encode when the output already exists and
@@ -131,10 +229,35 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
let argv = args(&src, &ogv, ch);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
// line beside a video looks like something a modder should edit or delete.
//
// The header is NOT part of the cache key: `fresh` compares only the lines
// that describe the encode. Otherwise rewording this comment would re-encode
// four minutes of video to no purpose, which is a cache that punishes
// documentation.
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let want = format!(
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
# encode when the source and the command are both unchanged. Deleting it\n\
# only forces one re-encode. To change the video, override the .ogv under\n\
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
m.stem
);
let cache_key = |s: &str| -> String {
s.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
};
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
&& std::fs::read_to_string(&stamp)
.map(|s| cache_key(&s) == cache_key(&want))
.unwrap_or(false);
if !fresh {
// Encode to a temp name and rename on success. A reader that catches
// this mid-write sees no file at all rather than a valid-looking one
@@ -155,12 +278,29 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
bail!("ffmpeg failed on {}", m.src);
}
std::fs::rename(&partial, &ogv)?;
}
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
//
// It used to be written only inside the `!fresh` branch, which is right for
// the cache and wrong for the file: a change to the header alone -- the part
// deliberately excluded from the key -- would then never reach an existing
// export, because nothing that reads the header can trigger the write that
// updates it. The explanation would be correct in the source and absent on
// disc, which is the same shape as every other documented-but-unexercised
// thing this port has had to find the hard way.
if std::fs::read_to_string(&stamp)
.map(|s| s != want)
.unwrap_or(true)
{
std::fs::write(&stamp, &want)?;
}
let (duration_s, fps) = probe_timebase(&ogv);
Ok(Some(Transcoded {
name: m.stem.to_string(),
file: format!("video/{}.ogv", m.stem),
command,
why: m.why,
duration_s,
fps,
}))
}

View File

@@ -11,7 +11,12 @@ xdvdfs = { workspace = true }
binrw = { workspace = true }
flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe)
ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe
tokio = { workspace = true }
# tokio is a DEV dependency only (see [dev-dependencies] below). Every use in
# this crate is inside a `mod tests`: three runtime builders in ship.rs and one
# `#[tokio::test]` in xiso.rs. As a normal dependency it pulled `tokio/full`,
# whose `net` feature drags in `mio`, which does not build for wasm32 —
# error: This wasm target is unsupported by mio.
# so the unused dependency was breaking the WASM job.
futures = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }

View File

@@ -29,7 +29,9 @@ fn main() {
paks.sort();
for p in &paks {
let Ok(arc) = PakArchive::open(p) else { continue };
let Ok(arc) = PakArchive::open(p) else {
continue;
};
for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue };
if !find(&b, b"ACHIEVEMENTS_REQUIREMENTS") {

View File

@@ -1,9 +1,18 @@
use sylpheed_formats::{game_data as gd, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let a=gd::load_arsenal(&pak);
for (hp,list) in [("NOSE",&a.nose),("ARM1",&a.arm1),("ARM2",&a.arm2),("ARM3",&a.arm3)]{
println!("{hp} ({}): {:?}", list.len(), list.iter().take(8).collect::<Vec<_>>());
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let a = gd::load_arsenal(&pak);
for (hp, list) in [
("NOSE", &a.nose),
("ARM1", &a.arm1),
("ARM2", &a.arm2),
("ARM3", &a.arm3),
] {
println!(
"{hp} ({}): {:?}",
list.len(),
list.iter().take(8).collect::<Vec<_>>()
);
}
}

View File

@@ -1,18 +1,38 @@
use sylpheed_formats::{game_data as gd, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let units=gd::load_units(&pak); let vessels=gd::load_vessels(&pak);
let hp=|id:&str|->String{
units.iter().find(|u|u.id.as_deref()==Some(id)).and_then(|u|u.hp).map(|h|format!("{h:.0}hp"))
.or_else(||vessels.iter().find(|v|v.id.as_deref()==Some(id)).and_then(|v|v.hp).map(|h|format!("{h:.0}HP")))
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let units = gd::load_units(&pak);
let vessels = gd::load_vessels(&pak);
let hp = |id: &str| -> String {
units
.iter()
.find(|u| u.id.as_deref() == Some(id))
.and_then(|u| u.hp)
.map(|h| format!("{h:.0}hp"))
.or_else(|| {
vessels
.iter()
.find(|v| v.id.as_deref() == Some(id))
.and_then(|v| v.hp)
.map(|h| format!("{h:.0}HP"))
})
.unwrap_or("·".into())
};
let rosters=gd::load_unit_rosters(&pak);
for r in rosters.iter().filter(|r|r.stage.is_some()).take(4){
println!("\n{}{} combatants:", r.stage.as_deref().unwrap(), r.units.len());
for u in r.units.iter().filter(|u|u.contains("ADAN")).take(6){
let short=u.trim_start_matches("UN_").split('_').skip(1).collect::<Vec<_>>().join("_");
let rosters = gd::load_unit_rosters(&pak);
for r in rosters.iter().filter(|r| r.stage.is_some()).take(4) {
println!(
"\n{}{} combatants:",
r.stage.as_deref().unwrap(),
r.units.len()
);
for u in r.units.iter().filter(|u| u.contains("ADAN")).take(6) {
let short = u
.trim_start_matches("UN_")
.split('_')
.skip(1)
.collect::<Vec<_>>()
.join("_");
println!(" {short:32} {}", hp(u));
}
}

View File

@@ -8,13 +8,22 @@ fn main() {
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
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 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());

View File

@@ -28,7 +28,10 @@ fn main() {
std::process::exit(1);
};
let (vc, ic) = markers[0];
println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len());
println!(
"{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}",
markers.len()
);
// Where did the decoder put it?
let ours = Xbg7Model::stage_models(&bytes)
@@ -38,7 +41,10 @@ fn main() {
println!("our anchor: {ours:?}");
let starts = debug_vertex_run_starts(&bytes, stride);
println!("{} candidate vertex-run starts for stride {stride}", starts.len());
println!(
"{} candidate vertex-run starts for stride {stride}",
starts.len()
);
// Score every (start, pad): degenerate triangles, winding against the stored
// normals, and whether the run covers the pool exactly.
@@ -67,7 +73,10 @@ fn main() {
]
})
.collect();
if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) {
if pos
.iter()
.any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6))
{
continue;
}
let mut degen = 0usize;
@@ -82,8 +91,16 @@ fn main() {
// stand-in so this stays declaration-agnostic: a consistent mesh
// has all faces pointing away from the centroid on a convex-ish
// hull. Weak, so degeneracy leads the sort.
let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]];
let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]];
let e1 = [
pos[y][0] - pos[x][0],
pos[y][1] - pos[x][1],
pos[y][2] - pos[x][2],
];
let e2 = [
pos[z][0] - pos[x][0],
pos[z][1] - pos[x][1],
pos[z][2] - pos[x][2],
];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
@@ -104,12 +121,19 @@ fn main() {
agree += 1;
}
}
let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
let w = if counted == 0 {
0.0
} else {
agree as f32 / counted as f32
};
rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc));
}
}
rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1)));
println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len());
println!(
"\n{} in-range candidates; best 12 by (degenerate, winding):",
rows.len()
);
for (d, w, vb, pad, mx, cov) in rows.iter().take(12) {
let mark = if Some(*vb) == ours { " <-- ours" } else { "" };
println!(

View File

@@ -7,8 +7,8 @@
//! those float values.
//!
//! Usage: bounds_in_descriptor <container.xpr> <resource>...
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
use std::collections::HashSet;
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
fn main() {
let a: Vec<String> = std::env::args().collect();
@@ -24,7 +24,9 @@ fn main() {
}
}
}
let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { continue };
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,
@@ -34,8 +36,12 @@ fn main() {
);
// 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]),
("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();
@@ -47,7 +53,10 @@ fn main() {
}
o += 4;
}
println!(" {label:5} {v:10.3} at descriptor offsets {:x?}", &at[..at.len().min(6)]);
println!(
" {label:5} {v:10.3} at descriptor offsets {:x?}",
&at[..at.len().min(6)]
);
}
}
}

View File

@@ -1,24 +1,39 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text=TextIndex::build(&pak);
let mut stages=game_data::load_stages(&pak);
stages.retain(|s|s.id.starts_with('S') && s.id.len()==3 && s.id[1..].parse::<u32>().map(|n|n<=16).unwrap_or(false));
stages.sort_by(|a,b|a.id.cmp(&b.id));
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text = TextIndex::build(&pak);
let mut stages = game_data::load_stages(&pak);
stages.retain(|s| {
s.id.starts_with('S')
&& s.id.len() == 3
&& s.id[1..].parse::<u32>().map(|n| n <= 16).unwrap_or(false)
});
stages.sort_by(|a, b| a.id.cmp(&b.id));
println!("═══ CAMPAIGN (S01S16) ═══");
for s in &stages{
let obj=text.objectives(&s.id,1);
for s in &stages {
let obj = text.objectives(&s.id, 1);
println!("\n{} · {}", s.id, s.location.as_deref().unwrap_or("?"));
for o in obj.iter().take(2){ println!("{o}"); }
for o in obj.iter().take(2) {
println!("{o}");
}
}
// roster with real names
let mut chars=game_data::load_characters(&pak);
chars.retain(|c|c.faction.as_deref()==Some("TCAF") && c.unique==Some(true) && c.faces.len()>=4);
let mut chars = game_data::load_characters(&pak);
chars.retain(|c| {
c.faction.as_deref() == Some("TCAF") && c.unique == Some(true) && c.faces.len() >= 4
});
println!("\n═══ PRINCIPAL CAST (TCAF, named) ═══");
for c in &chars{
let id=c.id.as_deref().unwrap_or("");
let name=text.character_name(id.trim_start_matches("Character")).or_else(||c.name_key.as_deref().and_then(|k|text.get(k))).unwrap_or("?");
println!(" {name:12} ({} portraits) [{}]", c.faces.len(), id.trim_start_matches("Character"));
for c in &chars {
let id = c.id.as_deref().unwrap_or("");
let name = text
.character_name(id.trim_start_matches("Character"))
.or_else(|| c.name_key.as_deref().and_then(|k| text.get(k)))
.unwrap_or("?");
println!(
" {name:12} ({} portraits) [{}]",
c.faces.len(),
id.trim_start_matches("Character")
);
}
}

View File

@@ -17,9 +17,9 @@
//!
//! Usage:
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
use std::collections::HashMap;
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
@@ -60,7 +60,13 @@ fn main() {
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 {
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;
@@ -72,7 +78,9 @@ fn main() {
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 };
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| {
@@ -96,7 +104,9 @@ fn main() {
eprintln!("no draw could be placed in this container");
std::process::exit(1);
};
println!("container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)");
println!(
"container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)"
);
// ── Our decoder's view of the same container.
let models = Xbg7Model::stage_models(&bytes);
@@ -104,14 +114,19 @@ fn main() {
for m in &models {
for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset {
by_off
.entry(off)
.or_default()
.push((m.name.clone(), sm.positions.len(), sm.indices.len()));
by_off.entry(off).or_default().push((
m.name.clone(),
sm.positions.len(),
sm.indices.len(),
));
}
}
}
println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.len());
println!(
"decoded {} resources, {} distinct vertex offsets\n",
models.len(),
by_off.len()
);
// Declared-but-not-decoded resources, indexed by their first marker's
// (vertex, index) counts. A drawn buffer our decoder cannot name is the one
@@ -133,13 +148,21 @@ fn main() {
}
// ── The report: one row per drawn BUFFER, aggregating its index batches.
let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
HashMap::new();
let mut per_buf: HashMap<
u32,
(
usize,
Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>,
u32,
),
> = HashMap::new();
for (delta, voff, d) in &hits {
if *delta != base_delta {
continue;
}
let e = per_buf.entry(d.vbase).or_insert((*voff, Vec::new(), d.vcount));
let e = per_buf
.entry(d.vbase)
.or_insert((*voff, Vec::new(), d.vcount));
let ib = d.ib.unwrap();
if !e.1.contains(&ib) {
e.1.push(ib);
@@ -147,7 +170,8 @@ fn main() {
}
let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize);
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = (0usize, 0usize, 0usize, 0usize);
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) =
(0usize, 0usize, 0usize, 0usize);
let mut rows: Vec<(usize, String)> = Vec::new();
for (_, (voff, ibs, vcount)) in per_buf.iter() {
let batches = ibs.len();
@@ -157,8 +181,11 @@ fn main() {
let umax = ibs.iter().map(|i| i.imax).max().unwrap();
let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer
let names = by_off.get(voff);
let dec_idx = names
.and_then(|v| v.iter().find(|(_, p, _)| *p as u32 == *vcount).map(|(_, _, i)| *i as u32));
let dec_idx = names.and_then(|v| {
v.iter()
.find(|(_, p, _)| *p as u32 == *vcount)
.map(|(_, _, i)| *i as u32)
});
// The decoder's assumption, scored: it expects the whole index buffer at
// `vb - 2*idx_count - pad`, pad ≤ 3.
let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo);
@@ -218,5 +245,7 @@ fn main() {
println!(
"index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}"
);
println!("vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}");
println!(
"vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}"
);
}

View File

@@ -12,9 +12,9 @@
//!
//! Usage:
//! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>...
use std::collections::HashMap;
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
@@ -34,7 +34,10 @@ fn main() {
let text = std::fs::read_to_string(log).expect("log");
for d in parse_capture(&text) {
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.map_or(false, |i| i.head_len > 0) && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
if d.ib.map_or(false, |i| i.head_len > 0)
&& d.pos.len() >= 4
&& seen.insert((log.clone(), d.vbase, k))
{
draws.push(d);
}
}
@@ -48,7 +51,13 @@ fn main() {
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 {
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;
@@ -60,7 +69,9 @@ fn main() {
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 };
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| {
@@ -92,10 +103,11 @@ fn main() {
for m in &models {
for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset {
by_off
.entry(off)
.or_default()
.push((m.name.clone(), sm.positions.len(), sm.indices.clone()));
by_off.entry(off).or_default().push((
m.name.clone(),
sm.positions.len(),
sm.indices.clone(),
));
}
}
}
@@ -112,7 +124,10 @@ fn main() {
continue;
}
let ibase = d.ib.unwrap().ibase as i64;
ib_start.entry(d.vbase).and_modify(|e| *e = (*e).min(ibase)).or_insert(ibase);
ib_start
.entry(d.vbase)
.and_modify(|e| *e = (*e).min(ibase))
.or_insert(ibase);
}
let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize);

View File

@@ -1,21 +1,37 @@
//! Identify which stage + ship a capture log came from: match the capture's
//! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr.
//! cargo run --release --example capture_match -- <capture.log> <resource3d dir>
use std::collections::{BTreeMap, HashSet};
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{BTreeMap, HashSet};
fn main() {
let a: Vec<String> = std::env::args().collect();
let text = std::fs::read_to_string(&a[1]).unwrap();
let mut draws = parse_capture(&text);
if draws.is_empty() { draws = parse_drawlog(&text); }
let caps: HashSet<u32> = draws.iter().map(|d| d.vcount).filter(|v| *v >= 100).collect();
eprintln!("{} draws, {} distinct vcounts>=100", draws.len(), caps.len());
if draws.is_empty() {
draws = parse_drawlog(&text);
}
let caps: HashSet<u32> = draws
.iter()
.map(|d| d.vcount)
.filter(|v| *v >= 100)
.collect();
eprintln!(
"{} draws, {} distinct vcounts>=100",
draws.len(),
caps.len()
);
let mut entries: Vec<_> = std::fs::read_dir(&a[2]).unwrap().filter_map(|e| e.ok())
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n.starts_with("Stage_") && n.ends_with(".xpr") })
.map(|e| e.path()).collect();
let mut entries: Vec<_> = std::fs::read_dir(&a[2])
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
let n = e.file_name().to_string_lossy().to_string();
n.starts_with("Stage_") && n.ends_with(".xpr")
})
.map(|e| e.path())
.collect();
entries.sort();
for path in entries {
let bytes = std::fs::read(&path).unwrap();
@@ -28,12 +44,18 @@ fn main() {
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
if vc >= 100 && caps.contains(&(vc as u32)) {
let id = m.name.get(..4).unwrap_or("?").to_string();
hits.entry(id).or_default().push((m.name.clone(), vc as u32));
hits.entry(id)
.or_default()
.push((m.name.clone(), vc as u32));
}
}
let total: usize = hits.values().map(|v| v.len()).sum();
if total >= 3 {
println!("== {} : {} matching resources ==", path.file_name().unwrap().to_string_lossy(), total);
println!(
"== {} : {} matching resources ==",
path.file_name().unwrap().to_string_lossy(),
total
);
for (id, v) in &hits {
let s: Vec<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect();
println!(" {id}: {}", s.join(" "));

View File

@@ -10,8 +10,8 @@
//! `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;
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64
@@ -47,7 +47,9 @@ fn main() {
let mut placed = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
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());
@@ -55,7 +57,12 @@ fn main() {
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
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);
}
@@ -77,9 +84,8 @@ fn main() {
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
})
&& (0..3)
.all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4)
});
if ok {
deltas

View File

@@ -71,15 +71,27 @@ fn main() {
for p in &base_parts {
let mut vcounts = Vec::new();
let mut pos = Vec::new();
for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] {
for cand in [
p.clone(),
format!("{p}_m"),
format!("{p}_l"),
format!("{p}_d"),
] {
if let Some(m) = models.iter().find(|m| m.name == cand) {
let mp: Vec<[f32; 3]> =
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect();
let mp: Vec<[f32; 3]> = m
.meshes
.iter()
.flat_map(|s| s.positions.iter().copied())
.collect();
vcounts.push(mp.len() as u32);
pos.extend(mp);
}
}
parts.push(Part { base: p.clone(), vcounts, pos });
parts.push(Part {
base: p.clone(),
vcounts,
pos,
});
}
// Validate a draw against a part (direct or X-mirrored), like ship_capture.
@@ -88,7 +100,9 @@ fn main() {
return None;
}
let near = |a: &[f32; 3], b: &[f32; 3]| {
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
(a[0] - b[0]).abs() <= 1e-2
&& (a[1] - b[1]).abs() <= 1e-2
&& (a[2] - b[2]).abs() <= 1e-2
};
let (mut direct, mut mirror) = (0usize, 0usize);
for q in &d.pos {
@@ -144,7 +158,11 @@ fn main() {
}
}
}
eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len());
eprintln!(
"{log}: {} validated draws, {} bdy_04 refs",
labeled.len(),
refs.len()
);
}
// Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples.

View File

@@ -22,14 +22,19 @@ fn main() {
let mut rows = vec![];
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
if o.schema_hash != 0x3c9ae32e {
continue;
}
let t = o.tokens();
let stage = t
.iter()
.find_map(|s| s.strip_prefix("EnumUnit_").map(|x| x.trim_end_matches(".tbl").to_string()))
.find_map(|s| {
s.strip_prefix("EnumUnit_")
.map(|x| x.trim_end_matches(".tbl").to_string())
})
.unwrap_or("?".into());
let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string();
rows.push((stage, bg, t.len()));
@@ -47,11 +52,20 @@ fn main() {
let mut all_tokens: Vec<(u32, Vec<String>)> = vec![];
for e in ch.entries() {
let Ok(b) = ch.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
*by_schema.entry(o.schema_hash).or_default() += 1;
all_tokens.push((o.schema_hash, o.tokens().iter().map(|s| s.to_string()).collect()));
all_tokens.push((
o.schema_hash,
o.tokens().iter().map(|s| s.to_string()).collect(),
));
}
println!(" {} entries, {} IDXD objects", ch.entries().len(), all_tokens.len());
println!(
" {} entries, {} IDXD objects",
ch.entries().len(),
all_tokens.len()
);
for (h, n) in &by_schema {
println!(" schema {h:08x} x{n}");
}
@@ -66,10 +80,16 @@ fn main() {
let mut hits: std::collections::BTreeSet<String> = Default::default();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
for t in o.tokens() {
let l = t.to_ascii_lowercase();
if l.contains("challenge") || t.ends_with("_EX") || t.contains("_EX4") || t.contains("_EX5") {
if l.contains("challenge")
|| t.ends_with("_EX")
|| t.contains("_EX4")
|| t.contains("_EX5")
{
hits.insert(format!("{:08x} {t}", o.schema_hash));
}
}

View File

@@ -44,7 +44,9 @@ fn main() {
paks.sort();
for p in &paks {
let Ok(arc) = PakArchive::open(p) else { continue };
let Ok(arc) = PakArchive::open(p) else {
continue;
};
for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue };
if KEYS.iter().filter(|k| find(&b, k.as_bytes())).count() < KEYS.len() {

View File

@@ -15,7 +15,9 @@ fn main() {
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 };
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();

View File

@@ -8,8 +8,8 @@
//! shift chain after the exact-coverage fix.
//!
//! Usage: consensus_check <resource3d_dir> [--list]
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::{BTreeMap, HashMap};
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -25,7 +25,9 @@ fn main() {
// 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 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]);
@@ -47,7 +49,9 @@ fn main() {
(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));
seen.entry(m.name.clone())
.or_default()
.push((where_.clone(), v, t, span));
}
}
@@ -81,7 +85,5 @@ fn main() {
println!("{r}");
}
}
println!(
"{minority} minority decodes across {resources} resources that have a majority"
);
println!("{minority} minority decodes across {resources} resources that have a majority");
}

View File

@@ -21,14 +21,14 @@
//! cargo run --release --example correlate_capture -- \
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit
use std::collections::HashSet;
use std::path::Path;
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{
correlate, parse_capture, parse_drawlog, serialize_table, PartKey,
};
use sylpheed_formats::xiso::open_iso;
use std::collections::HashSet;
use std::path::Path;
fn main() {
let args: Vec<String> = std::env::args().collect();
@@ -58,10 +58,15 @@ fn main() {
// Decode the ship's base parts AND their LOD copies (vcount + leading
// positions are the match keys; LODs share the base part's local frame).
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
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()
r.read_file(&format!("hidden/resource3d/{stage}.xpr"))
.await
.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
@@ -83,7 +88,12 @@ fn main() {
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())
Some(
m.meshes
.iter()
.flat_map(|s| s.positions.iter().copied())
.collect(),
)
};
// One PartKey per (part, variant-vcount present in the capture) — correlate
@@ -94,18 +104,33 @@ fn main() {
// variant is the same part in the same local frame.
let mut keys: Vec<PartKey> = Vec::new();
for part in &base_parts {
let variants =
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
let union: Vec<[f32; 3]> =
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
let variants = [
part.clone(),
format!("{part}_m"),
format!("{part}_l"),
format!("{part}_d"),
];
let union: Vec<[f32; 3]> = variants
.iter()
.filter_map(|v| positions_of(v))
.flatten()
.collect();
let mut any = false;
for cand in &variants {
if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32;
if draws.iter().any(|d| d.vcount == vcount) {
let lod = if cand == part { "full" } else { cand.rsplit('_').next().unwrap_or("?") };
let lod = if cand == part {
"full"
} else {
cand.rsplit('_').next().unwrap_or("?")
};
eprintln!(" {part:20} try vcount={vcount:6} [{lod}]");
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
keys.push(PartKey {
part: part.clone(),
vcount,
ref_pos: union.clone(),
});
any = true;
}
}
@@ -120,9 +145,15 @@ fn main() {
return;
};
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
eprintln!(
"\nreference = {} → ship-relative placement:",
ship.reference
);
for p in &ship.parts {
eprintln!(" {:18} T=[{:9.1}{:9.1}{:9.1}]", p.part, p.t[0], p.t[1], p.t[2]);
eprintln!(
" {:18} T=[{:9.1}{:9.1}{:9.1}]",
p.part, p.t[0], p.t[1], p.t[2]
);
}
for part in &base_parts {
if !ship.parts.iter().any(|p| &p.part == part) {

View File

@@ -16,19 +16,23 @@
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
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]) }
if n % 2 == 1 {
v[n / 2]
} else {
0.5 * (v[n / 2 - 1] + v[n / 2])
}
}
fn main() {
@@ -54,13 +58,22 @@ fn main() {
draws = parse_drawlog(&text);
}
let frames = segment_frames(&draws);
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
println!(
"{} draws → {} camera-consistent blocks",
draws.len(),
frames.len()
);
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
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()
r.read_file(&format!("hidden/resource3d/{stage}.xpr"))
.await
.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
@@ -81,7 +94,12 @@ fn main() {
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())
Some(
m.meshes
.iter()
.flat_map(|s| s.positions.iter().copied())
.collect(),
)
};
// part -> [T per frame], and how many frames placed it at all.
@@ -91,20 +109,33 @@ fn main() {
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();
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() });
keys.push(PartKey {
part: part.clone(),
vcount,
ref_pos: union.clone(),
});
}
}
}
}
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
let Some(ship) = correlate(id, fr, &keys, ref_sub) else {
continue;
};
if ship.parts.len() < min_parts {
continue;
}
@@ -114,7 +145,10 @@ fn main() {
// 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);
println!(
" block {fi:2}: skipped — reference fell back to {}",
ship.reference
);
continue;
}
used_frames += 1;
@@ -173,7 +207,8 @@ fn main() {
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)
v.iter().cloned().fold(f32::MIN, f32::max)
- v.iter().cloned().fold(f32::MAX, f32::min)
})
.collect();
let verdict = if cl.len() < 2 {
@@ -187,14 +222,21 @@ fn main() {
// (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();
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]])),
(
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()
@@ -219,8 +261,12 @@ fn main() {
// 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 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
@@ -269,12 +315,19 @@ fn main() {
.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" };
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());
println!(
"\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
scene.len(),
samples.len()
);
}

View File

@@ -6,8 +6,8 @@
//! 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;
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -22,7 +22,9 @@ fn main() {
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 };
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() {
@@ -32,14 +34,21 @@ fn main() {
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())));
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 { " " });
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) {

View File

@@ -12,7 +12,13 @@ use sylpheed_formats::{movie_subtitle, PakArchive};
fn movie_secs(path: &str) -> Option<f32> {
let out = Command::new("ffprobe")
.args([
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
path,
])
.output()
.ok()?;
@@ -27,7 +33,9 @@ fn main() {
let mut over = 0;
let mut worst: Vec<(String, f32, f32)> = Vec::new();
let dir = format!("{disc}/dat/movie");
let Ok(rd) = std::fs::read_dir(&dir) else { return };
let Ok(rd) = std::fs::read_dir(&dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.extension().is_none_or(|x| x != "wmv") {
@@ -39,7 +47,9 @@ fn main() {
continue;
}
let last = cues.iter().map(|(_, t)| *t).fold(0.0f32, f32::max);
let Some(secs) = movie_secs(&p.to_string_lossy()) else { continue };
let Some(secs) = movie_secs(&p.to_string_lossy()) else {
continue;
};
checked += 1;
if last > secs {
over += 1;

View File

@@ -16,12 +16,16 @@ use sylpheed_formats::{pak, ratc, ui_layout};
const OFFS: [usize; 4] = [28, 36, 44, 56];
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() { return 0; }
if o + 4 > b.len() {
return 0;
}
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let path = std::env::args().nth(1).expect("usage: decl_word_probe <pak> [entry]");
let path = std::env::args()
.nth(1)
.expect("usage: decl_word_probe <pak> [entry]");
let want: Option<usize> = std::env::args().nth(2).and_then(|s| s.parse().ok());
let ar = pak::PakArchive::open(&path).expect("open pak");
let entries: Vec<_> = ar.entries().to_vec();
@@ -29,34 +33,62 @@ fn main() {
let mut hit = [0usize; 4];
let mut tot = 0usize;
for (i, e) in entries.iter().enumerate() {
if want.is_some_and(|w| w != i) { continue; }
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
let Some(kids) = ratc::parse(&bytes) else { continue };
// Index space to test against: the T8aD children, in child order.
let t8: Vec<&ratc::RatcChild> = kids.iter().filter(|c| c.kind == "T8aD").collect();
if build.elements.iter().all(|el| el.sprite.is_some() || el.kind & 0x10 != 0) {
if want.is_some_and(|w| w != i) {
continue;
}
println!("== entry {i} ({} elements, {} T8aD children)", build.elements.len(), t8.len());
for (n, c) in t8.iter().enumerate() { println!(" child[{n:2}] {}", c.name); }
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
let Some(kids) = ratc::parse(&bytes) else {
continue;
};
// Index space to test against: the T8aD children, in child order.
let t8: Vec<&ratc::RatcChild> = kids.iter().filter(|c| c.kind == "T8aD").collect();
if build
.elements
.iter()
.all(|el| el.sprite.is_some() || el.kind & 0x10 != 0)
{
continue;
}
println!(
"== entry {i} ({} elements, {} T8aD children)",
build.elements.len(),
t8.len()
);
for (n, c) in t8.iter().enumerate() {
println!(" child[{n:2}] {}", c.name);
}
for el in &build.elements {
if el.kind & 0x10 != 0 { continue; }
if el.kind & 0x10 != 0 {
continue;
}
let d = &bytes[0x20 + el.index * 60..0x20 + (el.index + 1) * 60];
let words: Vec<u32> = OFFS.iter().map(|&o| be32(d, o)).collect();
// The control: for a RESOLVED element, which T8aD child is it?
let truth = el.sprite.as_ref()
let truth = el
.sprite
.as_ref()
.and_then(|s| t8.iter().position(|c| &c.name == s));
if let Some(t) = truth {
tot += 1;
for (k, w) in words.iter().enumerate() {
if *w as usize == t { hit[k] += 1; }
if *w as usize == t {
hit[k] += 1;
}
}
}
println!(
" [{:2}] {:26} sprite={:?} child={:?} +28={} +36={} +44={} +56={}",
el.index, el.name, el.sprite, truth,
words[0] as i32, words[1] as i32, words[2] as i32, words[3] as i32
el.index,
el.name,
el.sprite,
truth,
words[0] as i32,
words[1] as i32,
words[2] as i32,
words[3] as i32
);
}
}

View File

@@ -1,59 +1,108 @@
use sylpheed_formats::{ratc, idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
use sylpheed_formats::{idxd::IdxdObject, ratc, PakArchive};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
// 1. RATC child-type census across the big RATC paks
let mut childtypes:BTreeMap<String,u64>=BTreeMap::new();
let mut ratc_unparsed=0u64;
for pk in ["GP_READY_ROOM","GP_MOVIE_THEATER","GP_DIALOG","GP_DEBRIEFING_PILOTLOG","GP_TITLE","GP_BUNK"]{
let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue};
if !ratc::is_ratc(&b){continue;}
match ratc::parse(&b){
Some(kids)=> for k in kids{
let ext=k.name.rsplit('.').next().unwrap_or("?").to_lowercase();
*childtypes.entry(ext).or_default()+=1;
},
None=>ratc_unparsed+=1,
let mut childtypes: BTreeMap<String, u64> = BTreeMap::new();
let mut ratc_unparsed = 0u64;
for pk in [
"GP_READY_ROOM",
"GP_MOVIE_THEATER",
"GP_DIALOG",
"GP_DEBRIEFING_PILOTLOG",
"GP_TITLE",
"GP_BUNK",
] {
let Ok(arc) = PakArchive::open(format!("{disc}/dat/{pk}.pak")) else {
continue;
};
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
if !ratc::is_ratc(&b) {
continue;
}
match ratc::parse(&b) {
Some(kids) => {
for k in kids {
let ext = k.name.rsplit('.').next().unwrap_or("?").to_lowercase();
*childtypes.entry(ext).or_default() += 1;
}
}
None => ratc_unparsed += 1,
}
}
}
println!("=== RATC child-type census (big RATC paks) ===");
let mut cv:Vec<_>=childtypes.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
for (e,c) in &cv{ println!(" .{e:8} ×{c}"); }
let mut cv: Vec<_> = childtypes.into_iter().collect();
cv.sort_by_key(|x| std::cmp::Reverse(x.1));
for (e, c) in &cv {
println!(" .{e:8} ×{c}");
}
println!(" (RATC bundles that failed to parse: {ratc_unparsed})");
// 2. The 00000002 mystery format (GP_MAIN_GAME_E)
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue};
if b.len()>=4 && b[0..4]==[0,0,0,2]{
let hx:String=b[..48.min(b.len())].iter().map(|x|format!("{x:02x}")).collect::<Vec<_>>().join(" ");
let asc:String=b[..64.min(b.len())].iter().map(|&x|if(0x20..0x7f).contains(&x){x as char}else{'.'}).collect();
println!("\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",b.len());
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
if b.len() >= 4 && b[0..4] == [0, 0, 0, 2] {
let hx: String = b[..48.min(b.len())]
.iter()
.map(|x| format!("{x:02x}"))
.collect::<Vec<_>>()
.join(" ");
let asc: String = b[..64.min(b.len())]
.iter()
.map(|&x| {
if (0x20..0x7f).contains(&x) {
x as char
} else {
'.'
}
})
.collect();
println!(
"\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",
b.len()
);
break;
}
}
// 3. Sample the top undecoded IDXD schemas: first tokens (guess semantics)
println!("\n=== top undecoded IDXD schemas — sample tokens (semantic hints) ===");
let targets:[u32;6]=[0xb412e6d8,0x026379ab,0x43faa517,0x3c5b0549,0x6ab4825a,0x0426e81d];
for want in targets{
let mut shown=false;
for e in arc.entries(){
if shown{break;}
let Ok(b)=arc.read(e) else{continue};
if b.len()<12 || &b[0..4]!=b"IDXD"{continue;}
let s=u32::from_be_bytes([b[8],b[9],b[10],b[11]]);
if s!=want{continue;}
if let Ok(o)=IdxdObject::parse(&b){
let t=o.tokens();
let sample:Vec<String>=t.iter().take(14).map(|s|{let s=s.chars().take(18).collect::<String>();s}).collect();
let targets: [u32; 6] = [
0xb412e6d8, 0x026379ab, 0x43faa517, 0x3c5b0549, 0x6ab4825a, 0x0426e81d,
];
for want in targets {
let mut shown = false;
for e in arc.entries() {
if shown {
break;
}
let Ok(b) = arc.read(e) else { continue };
if b.len() < 12 || &b[0..4] != b"IDXD" {
continue;
}
let s = u32::from_be_bytes([b[8], b[9], b[10], b[11]]);
if s != want {
continue;
}
if let Ok(o) = IdxdObject::parse(&b) {
let t = o.tokens();
let sample: Vec<String> = t
.iter()
.take(14)
.map(|s| {
let s = s.chars().take(18).collect::<String>();
s
})
.collect();
println!(" {want:08x}: {sample:?}");
shown=true;
shown = true;
}
}
if !shown{ println!(" {want:08x}: (parse failed / not found)"); }
if !shown {
println!(" {want:08x}: (parse failed / not found)");
}
}
}

View File

@@ -13,8 +13,11 @@ fn is_number(s: &str) -> bool {
}
fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
}
fn is_value(s: &str) -> bool {
@@ -29,7 +32,9 @@ fn main() {
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else {
continue;
};
let toks = obj.tokens().to_vec();
let mut hits: Vec<String> = vec![];
for (i, t) in toks.iter().enumerate() {
@@ -44,7 +49,12 @@ fn main() {
});
}
if !hits.is_empty() {
println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
println!(
"0x{:08x} {:<44} {}",
obj.schema_hash,
obj.identity(),
hits.join(" ")
);
}
}
}

View File

@@ -20,7 +20,9 @@ fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
}
@@ -71,7 +73,11 @@ fn main() {
}
let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
let valued = prev.map(|p| is_value(p)).unwrap_or(false);
let v = if valued { Some(toks[i - 1].clone()) } else { None };
let v = if valued {
Some(toks[i - 1].clone())
} else {
None
};
seen_here.entry(t.clone()).or_insert(v);
}
for (k, v) in seen_here {
@@ -107,10 +113,7 @@ fn main() {
0xb412_e6d8 => "MESSAGE",
_ => "?",
};
let defaulted: Vec<_> = keys
.iter()
.filter(|(_, s)| s.1 > 0)
.collect();
let defaulted: Vec<_> = keys.iter().filter(|(_, s)| s.1 > 0).collect();
if defaulted.is_empty() {
continue;
}

View File

@@ -49,12 +49,16 @@ fn main() {
if r + 12 > desc.len() {
break;
}
let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
let (o, code, usage) =
(be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 {
println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}");
break;
}
println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF);
println!(
" off {o:>3} code 0x{:06X} usage {usage}",
code & 0xFF_FFFF
);
stride = stride.max(o + 4);
r += 12;
}

View File

@@ -1,13 +1,30 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text=TextIndex::build(&pak);
let msgs=game_data::load_demo_messages(&pak);
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text = TextIndex::build(&pak);
let msgs = game_data::load_demo_messages(&pak);
println!("{} dialogue lines total\n", msgs.len());
for m in msgs.iter().filter(|m|m.character.is_some()&&!m.page_keys.is_empty()).take(8){
let who=m.character.as_deref().unwrap_or("?").trim_start_matches("Character");
let line:String=m.page_keys.iter().filter_map(|k|text.get(k)).collect::<Vec<_>>().join(" ");
println!(" {who:10} [{}] “{}", m.voice_clip.as_deref().unwrap_or("-"), line.chars().take(64).collect::<String>());
for m in msgs
.iter()
.filter(|m| m.character.is_some() && !m.page_keys.is_empty())
.take(8)
{
let who = m
.character
.as_deref()
.unwrap_or("?")
.trim_start_matches("Character");
let line: String = m
.page_keys
.iter()
.filter_map(|k| text.get(k))
.collect::<Vec<_>>()
.join(" ");
println!(
" {who:10} [{}] “{}",
m.voice_clip.as_deref().unwrap_or("-"),
line.chars().take(64).collect::<String>()
);
}
}

View File

@@ -1,29 +1,60 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let tables:[(u32,&str);4]=[(0x0426e81d,"Player / physics+scoring"),(0x6ab4825a,"Weapon"),(0x43faa517,"Unit / craft"),(0x3c5b0549,"Vessel / capital ship")];
for (want,label) in tables{
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let tables: [(u32, &str); 4] = [
(0x0426e81d, "Player / physics+scoring"),
(0x6ab4825a, "Weapon"),
(0x43faa517, "Unit / craft"),
(0x3c5b0549, "Vessel / capital ship"),
];
for (want, label) in tables {
// collect all records of this schema
let mut recs:Vec<IdxdObject>=vec![];
for e in arc.entries(){ let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; if o.schema_hash==want{recs.push(o);} }
let mut recs: Vec<IdxdObject> = vec![];
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
if o.schema_hash == want {
recs.push(o);
}
}
// field-union (explicit-valued keys), with occurrence count
let mut cols:BTreeMap<String,u32>=BTreeMap::new();
for o in &recs{ for (k,_) in o.resolved_fields(){ *cols.entry(k.into()).or_default()+=1; } }
let mut cols: BTreeMap<String, u32> = BTreeMap::new();
for o in &recs {
for (k, _) in o.resolved_fields() {
*cols.entry(k.into()).or_default() += 1;
}
}
println!("\n╔══ {label} [{want:08x}] {} records ══", recs.len());
let mut cv:Vec<_>=cols.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
let colstr:String=cv.iter().take(28).map(|(k,c)|format!("{k}({c})")).collect::<Vec<_>>().join(" ");
let mut cv: Vec<_> = cols.into_iter().collect();
cv.sort_by_key(|x| std::cmp::Reverse(x.1));
let colstr: String = cv
.iter()
.take(28)
.map(|(k, c)| format!("{k}({c})"))
.collect::<Vec<_>>()
.join(" ");
println!("║ numeric/enum fields: {colstr}");
// dump 2 sample records: identity + explicit fields
for o in recs.iter().take(2){
let id=o.get_raw("ID").unwrap_or("?");
let name=o.get_raw("Name").unwrap_or("");
for o in recs.iter().take(2) {
let id = o.get_raw("ID").unwrap_or("?");
let name = o.get_raw("Name").unwrap_or("");
print!("║ • {id}");
if !name.is_empty(){print!(" «{name}»");}
if !name.is_empty() {
print!(" «{name}»");
}
println!();
let fields:Vec<String>=o.resolved_fields().iter().map(|(k,v)|format!("{k}={v}")).collect();
for chunk in fields.chunks(5){ println!("{}", chunk.join(" ")); }
let fields: Vec<String> = o
.resolved_fields()
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
for chunk in fields.chunks(5) {
println!("{}", chunk.join(" "));
}
}
}
}

View File

@@ -1,11 +1,29 @@
use sylpheed_formats::{game_data, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let chars=game_data::load_characters(&pak);
let mut byfac:BTreeMap<String,Vec<String>>=Default::default();
for c in &chars{ byfac.entry(c.faction.clone().unwrap_or("·".into())).or_default().push(format!("{}({})",c.id.clone().unwrap_or_default().trim_start_matches("Character"),c.faces.len())); }
println!("{} characters across {} factions:",chars.len(),byfac.len());
for (f,mut v) in byfac{ v.sort(); println!(" [{f}] {}", v.join(" ")); }
use sylpheed_formats::{game_data, PakArchive};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let chars = game_data::load_characters(&pak);
let mut byfac: BTreeMap<String, Vec<String>> = Default::default();
for c in &chars {
byfac
.entry(c.faction.clone().unwrap_or("·".into()))
.or_default()
.push(format!(
"{}({})",
c.id.clone()
.unwrap_or_default()
.trim_start_matches("Character"),
c.faces.len()
));
}
println!(
"{} characters across {} factions:",
chars.len(),
byfac.len()
);
for (f, mut v) in byfac {
v.sort();
println!(" [{f}] {}", v.join(" "));
}
}

View File

@@ -1,14 +1,83 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn short(s:&str)->String{ s.trim_start_matches("Weapon_").trim_start_matches("UN_").trim_start_matches("UnitName_UN_").into() }
fn g<'a>(o:&'a IdxdObject,k:&str)->String{ o.get_f32(k).map(|v|{if v==v.trunc(){format!("{}",v as i64)}else{format!("{v}")}}).or_else(||o.get_raw(k).filter(|x|x.chars().next().map(|c|c.is_ascii_digit()).unwrap_or(false)).map(|s|s.to_string())).unwrap_or("·".into()) }
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let recs=|want:u32|->Vec<IdxdObject>{ arc.entries().iter().filter_map(|e|arc.read(e).ok()).filter_map(|b|IdxdObject::parse(&b).ok()).filter(|o|o.schema_hash==want).collect() };
println!("### WEAPONS (name | target | load | int | power | vel | range | trig)");
for o in recs(0x6ab4825a).iter().take(16){ println!("{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), o.get_raw("TargetType").unwrap_or("·"), g(o,"LoadingCount"),g(o,"Interval"),g(o,"Power"),g(o,"Velocity"),g(o,"MaximumRange"),g(o,"TriggerShotCount")); }
println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)");
for o in recs(0x43faa517).iter().take(16){ println!("{:<38}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"CruisingVelocity"),g(o,"Acceleration"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"ScorePoint")); }
println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)");
for o in recs(0x3c5b0549).iter(){ println!("{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"Size_X"),g(o,"Size_Z"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"BridgeCount"),g(o,"HatchCount"),g(o,"ShieldGeneratorCount"),g(o,"ThrusterCount")); }
fn short(s: &str) -> String {
s.trim_start_matches("Weapon_")
.trim_start_matches("UN_")
.trim_start_matches("UnitName_UN_")
.into()
}
fn g<'a>(o: &'a IdxdObject, k: &str) -> String {
o.get_f32(k)
.map(|v| {
if v == v.trunc() {
format!("{}", v as i64)
} else {
format!("{v}")
}
})
.or_else(|| {
o.get_raw(k)
.filter(|x| {
x.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
})
.map(|s| s.to_string())
})
.unwrap_or("·".into())
}
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let recs = |want: u32| -> Vec<IdxdObject> {
arc.entries()
.iter()
.filter_map(|e| arc.read(e).ok())
.filter_map(|b| IdxdObject::parse(&b).ok())
.filter(|o| o.schema_hash == want)
.collect()
};
println!("### WEAPONS (name | target | load | int | power | vel | range | trig)");
for o in recs(0x6ab4825a).iter().take(16) {
println!(
"{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
short(o.get_raw("ID").unwrap_or("?")),
o.get_raw("TargetType").unwrap_or("·"),
g(o, "LoadingCount"),
g(o, "Interval"),
g(o, "Power"),
g(o, "Velocity"),
g(o, "MaximumRange"),
g(o, "TriggerShotCount")
);
}
println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)");
for o in recs(0x43faa517).iter().take(16) {
println!(
"{:<38}\t{}\t{}\t{}\t{}\t{}\t{}",
short(o.get_raw("ID").unwrap_or("?")),
g(o, "HP"),
g(o, "CruisingVelocity"),
g(o, "Acceleration"),
g(o, "RadarRange"),
g(o, "TurretCount"),
g(o, "ScorePoint")
);
}
println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)");
for o in recs(0x3c5b0549).iter() {
println!(
"{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
short(o.get_raw("ID").unwrap_or("?")),
g(o, "HP"),
g(o, "Size_X"),
g(o, "Size_Z"),
g(o, "RadarRange"),
g(o, "TurretCount"),
g(o, "BridgeCount"),
g(o, "HatchCount"),
g(o, "ShieldGeneratorCount"),
g(o, "ThrusterCount")
);
}
}

View File

@@ -1,15 +1,21 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text=TextIndex::build(&pak);
for n in 1..=16u32{
let sid=format!("S{n:02}");
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text = TextIndex::build(&pak);
for n in 1..=16u32 {
let sid = format!("S{n:02}");
// primary objective across phases (first that resolves)
let obj:Vec<String>=(1..=3).flat_map(|p|text.objectives(&sid,p)).map(|s|s.to_string()).collect();
let lose:Vec<String>=(1..=3).flat_map(|p|text.lose_conditions(&sid,p)).map(|s|s.to_string()).collect();
let full_obj=obj.join(" ");
let full_lose=lose.into_iter().take(2).collect::<Vec<_>>().join(" ");
let obj: Vec<String> = (1..=3)
.flat_map(|p| text.objectives(&sid, p))
.map(|s| s.to_string())
.collect();
let lose: Vec<String> = (1..=3)
.flat_map(|p| text.lose_conditions(&sid, p))
.map(|s| s.to_string())
.collect();
let full_obj = obj.join(" ");
let full_lose = lose.into_iter().take(2).collect::<Vec<_>>().join(" ");
println!("{sid}\t{full_obj}\t{full_lose}");
}
}

View File

@@ -3,8 +3,8 @@
//! `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;
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -20,7 +20,9 @@ fn main() {
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 };
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 {

View File

@@ -8,13 +8,16 @@
//! however big, is part of the silhouette.
//!
//! Usage: envelope_screen <resource3d_dir> [protrusion_fraction]
use std::collections::{BTreeSet, HashSet};
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 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()
@@ -25,7 +28,9 @@ fn main() {
let mut flagged = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
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()))
@@ -40,7 +45,9 @@ fn main() {
// 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 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 {
@@ -102,5 +109,8 @@ fn main() {
}
}
}
println!("{flagged} parts protrude more than {:.0}% of their ship's size", 100.0 * limit);
println!(
"{flagged} parts protrude more than {:.0}% of their ship's size",
100.0 * limit
);
}

View File

@@ -5,8 +5,8 @@
//! 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;
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
@@ -27,9 +27,16 @@ fn main() {
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!(
"{n}: full decode at 0x{:x}, filtered at 0x{:x}",
off(f),
off(s)
);
}
}
}
println!("{differ} of {} resources decode differently when filtered", names.len());
println!(
"{differ} of {} resources decode differently when filtered",
names.len()
);
}

View File

@@ -7,8 +7,8 @@
//! mirrored pair.
//!
//! Usage: find_mirror <container.xpr> <resource>...
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
use sylpheed_formats::mesh::Xbg7Model;
fn main() {
let a: Vec<String> = std::env::args().collect();
@@ -18,7 +18,12 @@ fn main() {
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();
let pos: Vec<[f32; 3]> = m
.meshes
.iter()
.flat_map(|s| s.positions.clone())
.take(8)
.collect();
if pos.len() < 8 {
continue;
}

View File

@@ -1,16 +1,22 @@
use sylpheed_formats::{game_data as gd, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let rosters=gd::load_pilot_rosters(&pak);
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let rosters = gd::load_pilot_rosters(&pak);
// distinct rosters by their pilot set
let mut seen=std::collections::BTreeSet::new(); let mut shown=0;
let mut seen = std::collections::BTreeSet::new();
let mut shown = 0;
println!("{} pilot-roster configs; distinct line-ups:", rosters.len());
for r in &rosters{
let key:String=r.pilots().iter().map(|(c,p)|format!("{c}:{p}")).collect::<Vec<_>>().join(",");
if seen.insert(key) && shown<8 {
shown+=1;
let flt:Vec<String>=r.pilots().iter().map(|(c,p)|format!("{c}={p}")).collect();
for r in &rosters {
let key: String = r
.pilots()
.iter()
.map(|(c, p)| format!("{c}:{p}"))
.collect::<Vec<_>>()
.join(",");
if seen.insert(key) && shown < 8 {
shown += 1;
let flt: Vec<String> = r.pilots().iter().map(|(c, p)| format!("{c}={p}")).collect();
println!(" {}", flt.join(" "));
}
}

View File

@@ -1,11 +1,14 @@
//! 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};
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
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 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()
@@ -20,7 +23,9 @@ fn main() {
if done >= cap {
break;
}
let Ok(bytes) = std::fs::read(f) else { continue };
let Ok(bytes) = std::fs::read(f) else {
continue;
};
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;

View File

@@ -1,2 +1,7 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
for l in sylpheed_formats::mesh::debug_grouped_report(&b,&a[2],a[3].parse().unwrap()){println!("{l}")}}
fn main() {
let a: Vec<String> = std::env::args().collect();
let b = std::fs::read(&a[1]).unwrap();
for l in sylpheed_formats::mesh::debug_grouped_report(&b, &a[2], a[3].parse().unwrap()) {
println!("{l}")
}
}

View File

@@ -1,43 +1,144 @@
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_subtitle as ms, movie_voice, slb, PakArchive};
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn ct(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let man=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
let lpak=PakArchive::open(format!("{disc}/dat/movie/eng.pak")).unwrap();
let reg=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|ct(b,b"eng\\Movie\\VOICE_ADV.slb"))).unwrap();
let ids=movie_voice::registry_voice_ids(&reg);
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let ents=PakArchive::parse_toc(&stoc).unwrap();
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::process::Command;
use sylpheed_formats::{
hash::name_hash, movie_manifest, movie_subtitle as ms, movie_voice, slb, PakArchive,
};
fn rg(disc: &str, g: u64, n: usize) -> Vec<u8> {
let mut segs = vec![];
let mut cum = 0u64;
for i in 0..5 {
let p = format!("{disc}/dat/sound.p{i:02}");
if let Ok(m) = fs::metadata(&p) {
segs.push((cum, m.len(), p));
cum += m.len();
}
}
let mut out = vec![];
let (mut need, mut pos) = (n, g);
for (base, len, path) in &segs {
if need == 0 || pos >= base + len || pos < *base {
continue;
}
let local = pos - base;
let take = need.min((len - local) as usize);
let mut f = fs::File::open(path).unwrap();
f.seek(SeekFrom::Start(local)).unwrap();
let mut b = vec![0u8; take];
f.read_exact(&mut b).unwrap();
out.extend_from_slice(&b);
need -= take;
pos += take as u64;
}
out
}
fn ct(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
fn dur(w: &str) -> String {
let o = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
w,
])
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let tpak = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let man = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.unwrap();
let lpak = PakArchive::open(format!("{disc}/dat/movie/eng.pak")).unwrap();
let reg = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| ct(b, b"eng\\Movie\\VOICE_ADV.slb"))
})
.unwrap();
let ids = movie_voice::registry_voice_ids(&reg);
let stoc = fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let ents = PakArchive::parse_toc(&stoc).unwrap();
// demo->token from bound hokyu
let hok:Vec<_>=movie_manifest::parse(&man).into_iter().filter(|m|m.movie.starts_with("hokyu_")).collect();
let resolve=|movie:&str|->Option<String>{
movie_manifest::voice_token(&man,movie).or_else(||{
let want=ms::track_voice_cues(&lpak,movie).first().map(|&(d,_)|d)?;
hok.iter().find_map(|e|{let t=e.voice_token.clone().filter(|_|e.movie.starts_with("hokyu_"))?; ms::track_voice_cues(&lpak,&e.movie).iter().any(|&(d,_)|d==want).then_some(t)})
let hok: Vec<_> = movie_manifest::parse(&man)
.into_iter()
.filter(|m| m.movie.starts_with("hokyu_"))
.collect();
let resolve = |movie: &str| -> Option<String> {
movie_manifest::voice_token(&man, movie).or_else(|| {
let want = ms::track_voice_cues(&lpak, movie)
.first()
.map(|&(d, _)| d)?;
hok.iter().find_map(|e| {
let t = e
.voice_token
.clone()
.filter(|_| e.movie.starts_with("hokyu_"))?;
ms::track_voice_cues(&lpak, &e.movie)
.iter()
.any(|&(d, _)| d == want)
.then_some(t)
})
})
};
for e in &hok{
let mv=&e.movie;
let bound=e.voice_token.is_some();
let tok=resolve(mv);
let demo=ms::track_voice_cues(&lpak,mv).first().map(|&(d,_)|d);
let mut d="".to_string();
if let Some(tok)=&tok{
if let Some(&id)=ids.get(tok){
if let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|dir|{let h=name_hash(&format!("eng\\{dir}\\{tok}.slb"));ents.binary_search_by_key(&h,|x|x.name_hash).ok().map(|i|ents[i].offset as u64)}){
let ws=(anchor.saturating_sub(2*1024*1024))&!3; let win=rg(&disc,ws,8*1024*1024);
if let Some(el)=movie_voice::find_descriptor(&win,id){let end=ws+el as u64;
let start=movie_voice::find_descriptor(&win,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&win,el)).map(|o|ws+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
let region=rg(&disc,start,(end-start)as usize); let mut rf=slb::to_xma_riffs(&region); if rf.is_empty(){rf=slb::to_xma_riff_best(&region).into_iter().collect();}
if let Some(r)=rf.first(){let xp=format!("/tmp/hd_{mv}.xma.wav");let wp=format!("/tmp/hd_{mv}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
for e in &hok {
let mv = &e.movie;
let bound = e.voice_token.is_some();
let tok = resolve(mv);
let demo = ms::track_voice_cues(&lpak, mv).first().map(|&(d, _)| d);
let mut d = "".to_string();
if let Some(tok) = &tok {
if let Some(&id) = ids.get(tok) {
if let Some(anchor) = ["Movie", "etc", "Voice"].iter().find_map(|dir| {
let h = name_hash(&format!("eng\\{dir}\\{tok}.slb"));
ents.binary_search_by_key(&h, |x| x.name_hash)
.ok()
.map(|i| ents[i].offset as u64)
}) {
let ws = (anchor.saturating_sub(2 * 1024 * 1024)) & !3;
let win = rg(&disc, ws, 8 * 1024 * 1024);
if let Some(el) = movie_voice::find_descriptor(&win, id) {
let end = ws + el as u64;
let start = movie_voice::find_descriptor(&win, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&win, el))
.map(|o| ws + o as u64)
.filter(|&s| s < end && end - s < 1_500_000)
.unwrap_or(anchor);
let region = rg(&disc, start, (end - start) as usize);
let mut rf = slb::to_xma_riffs(&region);
if rf.is_empty() {
rf = slb::to_xma_riff_best(&region).into_iter().collect();
}
if let Some(r) = rf.first() {
let xp = format!("/tmp/hd_{mv}.xma.wav");
let wp = format!("/tmp/hd_{mv}.wav");
fs::write(&xp, r).unwrap();
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "error", "-y", "-i", &xp, &wp])
.status();
d = dur(&wp);
}
}
}
}
}
println!("{mv:16} {:8} demo={:?} -> {:11} voice={d}s", if bound{"BOUND"}else{"unbound"}, demo, tok.unwrap_or("(silent)".into()));
println!(
"{mv:16} {:8} demo={:?} -> {:11} voice={d}s",
if bound { "BOUND" } else { "unbound" },
demo,
tok.unwrap_or("(silent)".into())
);
}
}

View File

@@ -1,36 +1,148 @@
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::process::Command;
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, slb, PakArchive};
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn contains(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
fn hokyu_fallback(m:&str)->Option<String>{if !m.starts_with("hokyu_"){return None}let ls=m.contains("_LS_");let ds=m.contains("_DS_");let a=m.ends_with('A');let h=m.ends_with('H');Some(match(ls,ds,a,h){(true,_,true,_)=>"VOICE_D_450",(true,_,_,true)=>"VOICE_D_453",(_,true,true,_)=>"VOICE_D_452",(_,true,_,true)=>"VOICE_D_454",_=>return None}.into())}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
let code="eng";
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
let ids=movie_voice::registry_voice_ids(&registry);
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let entries=PakArchive::parse_toc(&stoc).unwrap();
let hokyu:Vec<String>=movie_manifest::parse(&manifest).into_iter().filter(|m|m.movie.starts_with("hokyu_")).map(|m|m.movie).collect();
for movie in &hokyu{
let bound=movie_manifest::voice_token(&manifest,movie);
let token=bound.clone().or_else(||hokyu_fallback(movie));
let Some(token)=token else{println!("{movie:16} NO TOKEN"); continue};
let src = if bound.is_some(){"bound"}else{"fallback"};
let Some(&id)=ids.get(&token) else{println!("{movie:16} {token} not in registry");continue};
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else{println!("{movie:16} no anchor");continue};
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
let window=rg(&disc,win_start,8*1024*1024);
let Some(el)=movie_voice::find_descriptor(&window,id) else{println!("{movie:16} desc not found");continue};
let end=win_start+el as u64;
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&window,el)).map(|o|win_start+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
let region=rg(&disc,start,(end-start)as usize);
let mut riffs=slb::to_xma_riffs(&region); if riffs.is_empty(){riffs=slb::to_xma_riff_best(&region).into_iter().collect();}
let mut d="?".into();
if let Some(r)=riffs.first(){let xp=format!("/tmp/hf_{movie}.xma.wav");let wp=format!("/tmp/hf_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
fn rg(disc: &str, g: u64, n: usize) -> Vec<u8> {
let mut segs = vec![];
let mut cum = 0u64;
for i in 0..5 {
let p = format!("{disc}/dat/sound.p{i:02}");
if let Ok(m) = fs::metadata(&p) {
segs.push((cum, m.len(), p));
cum += m.len();
}
}
let mut out = vec![];
let (mut need, mut pos) = (n, g);
for (base, len, path) in &segs {
if need == 0 || pos >= base + len || pos < *base {
continue;
}
let local = pos - base;
let take = need.min((len - local) as usize);
let mut f = fs::File::open(path).unwrap();
f.seek(SeekFrom::Start(local)).unwrap();
let mut b = vec![0u8; take];
f.read_exact(&mut b).unwrap();
out.extend_from_slice(&b);
need -= take;
pos += take as u64;
}
out
}
fn contains(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
fn dur(w: &str) -> String {
let o = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
w,
])
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn hokyu_fallback(m: &str) -> Option<String> {
if !m.starts_with("hokyu_") {
return None;
}
let ls = m.contains("_LS_");
let ds = m.contains("_DS_");
let a = m.ends_with('A');
let h = m.ends_with('H');
Some(
match (ls, ds, a, h) {
(true, _, true, _) => "VOICE_D_450",
(true, _, _, true) => "VOICE_D_453",
(_, true, true, _) => "VOICE_D_452",
(_, true, _, true) => "VOICE_D_454",
_ => return None,
}
.into(),
)
}
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let tpak = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.unwrap();
let code = "eng";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| contains(b, format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))
})
.unwrap();
let ids = movie_voice::registry_voice_ids(&registry);
let stoc = fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let entries = PakArchive::parse_toc(&stoc).unwrap();
let hokyu: Vec<String> = movie_manifest::parse(&manifest)
.into_iter()
.filter(|m| m.movie.starts_with("hokyu_"))
.map(|m| m.movie)
.collect();
for movie in &hokyu {
let bound = movie_manifest::voice_token(&manifest, movie);
let token = bound.clone().or_else(|| hokyu_fallback(movie));
let Some(token) = token else {
println!("{movie:16} NO TOKEN");
continue;
};
let src = if bound.is_some() { "bound" } else { "fallback" };
let Some(&id) = ids.get(&token) else {
println!("{movie:16} {token} not in registry");
continue;
};
let Some(anchor) = ["Movie", "etc", "Voice"].iter().find_map(|d| {
let h = name_hash(&format!("{code}\\{d}\\{token}.slb"));
entries
.binary_search_by_key(&h, |e| e.name_hash)
.ok()
.map(|i| entries[i].offset as u64)
}) else {
println!("{movie:16} no anchor");
continue;
};
let win_start = (anchor.saturating_sub(2 * 1024 * 1024)) & !3;
let window = rg(&disc, win_start, 8 * 1024 * 1024);
let Some(el) = movie_voice::find_descriptor(&window, id) else {
println!("{movie:16} desc not found");
continue;
};
let end = win_start + el as u64;
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, el))
.map(|o| win_start + o as u64)
.filter(|&s| s < end && end - s < 1_500_000)
.unwrap_or(anchor);
let region = rg(&disc, start, (end - start) as usize);
let mut riffs = slb::to_xma_riffs(&region);
if riffs.is_empty() {
riffs = slb::to_xma_riff_best(&region).into_iter().collect();
}
let mut d = "?".into();
if let Some(r) = riffs.first() {
let xp = format!("/tmp/hf_{movie}.xma.wav");
let wp = format!("/tmp/hf_{movie}.wav");
fs::write(&xp, r).unwrap();
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "error", "-y", "-i", &xp, &wp])
.status();
d = dur(&wp);
}
let mv = dur(&format!("{disc}/dat/movie/{movie}.wmv"));
println!("{movie:16} {src:8} {token:12} id={id} voice={d}s movie={mv}s");
}
}

View File

@@ -35,7 +35,9 @@ fn is_enum_value(s: &str) -> bool {
fn is_key_like(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
&& !is_enum_value(s)
}
@@ -55,15 +57,18 @@ fn main() {
let pak = PakArchive::open(&pak_path).expect("open pak");
for entry in pak.entries() {
let Ok(bytes) = pak.read(entry) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else {
continue;
};
let toks = obj.tokens();
// Only entries that actually declare one of the requested sub-record
// types (the pool-start heuristic can prefix one stray byte, so match a
// short suffix rather than equality -- same rule as the splitter below).
if !toks
.iter()
.any(|t| types.iter().any(|ty| t == ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2)))
{
if !toks.iter().any(|t| {
types
.iter()
.any(|ty| t == ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2))
}) {
continue;
}

View File

@@ -12,7 +12,9 @@ fn main() {
.collect();
files.sort();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let Ok(bytes) = std::fs::read(f) else {
continue;
};
let w = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::stage_models(&bytes) {
for (k, sm) in m.meshes.iter().enumerate() {

View File

@@ -36,8 +36,16 @@ fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize)
if a >= pos.len() || b >= pos.len() || c >= pos.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let e1 = [
pos[b][0] - pos[a][0],
pos[b][1] - pos[a][1],
pos[b][2] - pos[a][2],
];
let e2 = [
pos[c][0] - pos[a][0],
pos[c][1] - pos[a][1],
pos[c][2] - pos[a][2],
];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
@@ -53,7 +61,11 @@ fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize)
agree += 1;
}
}
let frac = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
let frac = if counted == 0 {
0.0
} else {
agree as f32 / counted as f32
};
(degen, frac.max(1.0 - frac), counted)
}
@@ -82,7 +94,8 @@ fn main() {
if n < 6 || sm.normals.is_empty() || vb < n * 2 + 2 {
continue;
}
let read_at = |start: usize| -> Vec<u32> { (0..n).map(|k| be16(&bytes, start + k * 2)).collect() };
let read_at =
|start: usize| -> Vec<u32> { (0..n).map(|k| be16(&bytes, start + k * 2)).collect() };
let l0 = read_at(vb - n * 2);
let l2 = read_at(vb - n * 2 - 2);
// Sanity: l0 must be what the decoder emitted, or the assumption that we

View File

@@ -18,11 +18,11 @@
//! <capture.log> <Stage_SNN> [top_n] [--all]
//! `--all` lists every capture vcount, not just the `top_n` (default 40) largest.
use std::collections::{HashMap, HashSet};
use std::path::Path;
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();
@@ -47,10 +47,15 @@ fn main() {
// 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();
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()
r.read_file(&format!("hidden/resource3d/{stage}.xpr"))
.await
.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
@@ -101,7 +106,11 @@ fn main() {
100.0 * matched_draws as f64 / draws.len().max(1) as f64
);
let shown = if all { vcounts.len() } else { top_n.min(vcounts.len()) };
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];
@@ -134,9 +143,19 @@ fn main() {
.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());
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() });
println!(
" {name:28} vcount {v:6} {}",
if n > 0 {
format!("DRAWN ×{n}")
} else {
"".into()
}
);
}
}
}
@@ -145,12 +164,24 @@ fn main() {
// 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()))
.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() });
println!(
" {name:28} vcount {v:6} {}",
if n > 0 {
format!("DRAWN ×{n}")
} else {
"not drawn".into()
}
);
}
}

View File

@@ -1,50 +1,125 @@
use sylpheed_formats::PakArchive;
use std::collections::BTreeMap;
fn be32(b:&[u8],o:usize)->u32{ if o+4<=b.len(){u32::from_be_bytes([b[o],b[o+1],b[o+2],b[o+3]])}else{0} }
fn magic(b:&[u8])->String{
if b.len()<4 {return "(<4B)".into();}
let m=&b[0..4];
if m.iter().all(|&c|(0x20..0x7f).contains(&c)){ String::from_utf8_lossy(m).into() }
else if m==b"\x89PNG"{"PNG".into()} else if m==[0,1,0,0]{"ttf".into()}
else { format!("{:02x}{:02x}{:02x}{:02x}",m[0],m[1],m[2],m[3]) }
use sylpheed_formats::PakArchive;
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 <= b.len() {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
} else {
0
}
}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
fn magic(b: &[u8]) -> String {
if b.len() < 4 {
return "(<4B)".into();
}
let m = &b[0..4];
if m.iter().all(|&c| (0x20..0x7f).contains(&c)) {
String::from_utf8_lossy(m).into()
} else if m == b"\x89PNG" {
"PNG".into()
} else if m == [0, 1, 0, 0] {
"ttf".into()
} else {
format!("{:02x}{:02x}{:02x}{:02x}", m[0], m[1], m[2], m[3])
}
}
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
// Known/understood IDXD schemas (semantic parsers we have)
let known_schema:BTreeMap<u32,&str>=[(0x067025b9,"ADVERTISE_MOVIE (movie_manifest)"),(0x13cb84ba,"sound registry / sounds.tbl")].into();
let mut fmt_count:BTreeMap<String,(u64,u64)>=BTreeMap::new(); // fmt -> (count, bytes)
let mut schema_census:BTreeMap<u32,(u64,u64,String)>=BTreeMap::new(); // schema -> (count,bytes,sample pak)
let mut unknown_magics:BTreeMap<String,(u64,Vec<String>)>=BTreeMap::new();
let paks:Vec<String>={let mut v=vec![]; for e in std::fs::read_dir(format!("{disc}/dat")).unwrap(){let p=e.unwrap().path(); if p.extension().map(|x|x=="pak").unwrap_or(false){v.push(p.file_stem().unwrap().to_string_lossy().into());}} v.sort(); v};
println!("pak entries decompressed formats");
for pk in &paks{
let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
let mut per:BTreeMap<String,u64>=BTreeMap::new();
let mut tot=0u64; let n=arc.entries().len();
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue};
tot+=b.len() as u64;
let mut f=magic(&b);
if f=="IDXD"{ let s=be32(&b,8); schema_census.entry(s).or_insert((0,0,pk.clone())).0+=1; schema_census.get_mut(&s).unwrap().1+=b.len() as u64; f=format!("IDXD:{s:08x}"); }
else if !["XPR2","IXUD","LSTA","RATC","RIFF","XBG7","OTTO","PNG","ttf","DDS ","T8AD"].contains(&f.as_str()){
let ent=unknown_magics.entry(f.clone()).or_insert((0,vec![])); ent.0+=1; if ent.1.len()<3 && !ent.1.contains(pk){ent.1.push(pk.clone());}
let known_schema: BTreeMap<u32, &str> = [
(0x067025b9, "ADVERTISE_MOVIE (movie_manifest)"),
(0x13cb84ba, "sound registry / sounds.tbl"),
]
.into();
let mut fmt_count: BTreeMap<String, (u64, u64)> = BTreeMap::new(); // fmt -> (count, bytes)
let mut schema_census: BTreeMap<u32, (u64, u64, String)> = BTreeMap::new(); // schema -> (count,bytes,sample pak)
let mut unknown_magics: BTreeMap<String, (u64, Vec<String>)> = BTreeMap::new();
let paks: Vec<String> = {
let mut v = vec![];
for e in std::fs::read_dir(format!("{disc}/dat")).unwrap() {
let p = e.unwrap().path();
if p.extension().map(|x| x == "pak").unwrap_or(false) {
v.push(p.file_stem().unwrap().to_string_lossy().into());
}
*per.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f.clone()}).or_default()+=1;
let g=fmt_count.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f}).or_insert((0,0)); g.0+=1; g.1+=b.len() as u64;
}
let mut fs:Vec<_>=per.into_iter().collect(); fs.sort_by_key(|x|std::cmp::Reverse(x.1));
let top:String=fs.iter().take(4).map(|(k,v)|format!("{k}×{v}")).collect::<Vec<_>>().join(" ");
println!("{pk:26} {n:>7} {:>10}KB {top}", tot/1024);
v.sort();
v
};
println!("pak entries decompressed formats");
for pk in &paks {
let Ok(arc) = PakArchive::open(format!("{disc}/dat/{pk}.pak")) else {
continue;
};
let mut per: BTreeMap<String, u64> = BTreeMap::new();
let mut tot = 0u64;
let n = arc.entries().len();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
tot += b.len() as u64;
let mut f = magic(&b);
if f == "IDXD" {
let s = be32(&b, 8);
schema_census.entry(s).or_insert((0, 0, pk.clone())).0 += 1;
schema_census.get_mut(&s).unwrap().1 += b.len() as u64;
f = format!("IDXD:{s:08x}");
} else if ![
"XPR2", "IXUD", "LSTA", "RATC", "RIFF", "XBG7", "OTTO", "PNG", "ttf", "DDS ",
"T8AD",
]
.contains(&f.as_str())
{
let ent = unknown_magics.entry(f.clone()).or_insert((0, vec![]));
ent.0 += 1;
if ent.1.len() < 3 && !ent.1.contains(pk) {
ent.1.push(pk.clone());
}
}
*per.entry(if f.starts_with("IDXD:") {
"IDXD".into()
} else {
f.clone()
})
.or_default() += 1;
let g = fmt_count
.entry(if f.starts_with("IDXD:") {
"IDXD".into()
} else {
f
})
.or_insert((0, 0));
g.0 += 1;
g.1 += b.len() as u64;
}
let mut fs: Vec<_> = per.into_iter().collect();
fs.sort_by_key(|x| std::cmp::Reverse(x.1));
let top: String = fs
.iter()
.take(4)
.map(|(k, v)| format!("{k}×{v}"))
.collect::<Vec<_>>()
.join(" ");
println!("{pk:26} {n:>7} {:>10}KB {top}", tot / 1024);
}
println!("\n=== FORMAT TOTALS (across all paks) ===");
let mut fv:Vec<_>=fmt_count.into_iter().collect(); fv.sort_by_key(|x|std::cmp::Reverse(x.1.1));
for (f,(c,b)) in &fv{ println!(" {f:12} {c:>6} entries {:>8}KB", b/1024); }
println!("\n=== IDXD SCHEMA CENSUS ({} distinct schemas) ===", schema_census.len());
let mut sv:Vec<_>=schema_census.into_iter().collect(); sv.sort_by_key(|x|std::cmp::Reverse(x.1.1));
for (s,(c,b,pk)) in &sv{
let tag=known_schema.get(s).copied().unwrap_or("??? UNDECODED");
println!(" {s:08x} {c:>5} ent {:>7}KB e.g. {pk:22} {tag}", b/1024);
let mut fv: Vec<_> = fmt_count.into_iter().collect();
fv.sort_by_key(|x| std::cmp::Reverse(x.1 .1));
for (f, (c, b)) in &fv {
println!(" {f:12} {c:>6} entries {:>8}KB", b / 1024);
}
println!(
"\n=== IDXD SCHEMA CENSUS ({} distinct schemas) ===",
schema_census.len()
);
let mut sv: Vec<_> = schema_census.into_iter().collect();
sv.sort_by_key(|x| std::cmp::Reverse(x.1 .1));
for (s, (c, b, pk)) in &sv {
let tag = known_schema.get(s).copied().unwrap_or("??? UNDECODED");
println!(
" {s:08x} {c:>5} ent {:>7}KB e.g. {pk:22} {tag}",
b / 1024
);
}
println!("\n=== UNKNOWN / UNCLASSIFIED MAGICS ===");
for (m,(c,pks)) in &unknown_magics{ println!(" {m:12} ×{c:<5} in {pks:?}"); }
for (m, (c, pks)) in &unknown_magics {
println!(" {m:12} ×{c:<5} in {pks:?}");
}
}

View File

@@ -15,7 +15,10 @@ fn main() {
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())))
.filter_map(|s| {
s.split_once('=')
.map(|(i, v)| (i.to_string(), v.to_string()))
})
.collect();
// live: va -> offset -> f32
@@ -28,8 +31,7 @@ fn main() {
}
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>())
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);
}
@@ -42,9 +44,15 @@ fn main() {
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 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;
@@ -58,7 +66,11 @@ fn main() {
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;
*votes
.entry(key.clone())
.or_default()
.entry(*off)
.or_default() += 1;
found = true;
}
}

View File

@@ -13,8 +13,8 @@
//! fixing, but the geometry is already recoverable, so a capture is not needed.
//!
//! Usage: miss_targets <resource3d_dir>
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, BTreeSet};
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -30,14 +30,18 @@ fn main() {
let mut ok: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut miss: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let Ok(bytes) = std::fs::read(f) else {
continue;
};
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
let declared: BTreeSet<String> = xbg7_resource_names(&bytes).into_iter().collect();
if declared.is_empty() {
continue;
}
let decoded: BTreeSet<String> =
Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
let decoded: BTreeSet<String> = Xbg7Model::stage_models(&bytes)
.into_iter()
.map(|m| m.name)
.collect();
for n in &declared {
if decoded.contains(n) {
ok.entry(n.clone()).or_default().insert(where_.clone());
@@ -52,7 +56,10 @@ fn main() {
let recoverable: Vec<&String> = miss.keys().filter(|n| ok.contains_key(*n)).collect();
println!("resources that decode NOWHERE: {}", never.len());
println!("resources that miss somewhere but decode elsewhere: {}\n", recoverable.len());
println!(
"resources that miss somewhere but decode elsewhere: {}\n",
recoverable.len()
);
// Rank containers by how many never-decoding resources they hold.
let mut per_container: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
@@ -73,7 +80,10 @@ fn main() {
// reachable through whatever loads that container.
let excl: Vec<(&&String, &&BTreeSet<String>)> =
never.iter().filter(|(_, w)| w.len() == 1).collect();
println!("\nof the never-decoding, {} live in exactly one container", excl.len());
println!(
"\nof the never-decoding, {} live in exactly one container",
excl.len()
);
let mut by_one: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, w) in &excl {
by_one.entry(w.iter().next().unwrap()).or_default().push(n);
@@ -81,6 +91,15 @@ fn main() {
let mut b: Vec<_> = by_one.iter().collect();
b.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
for (w, v) in b.iter().take(10) {
println!(" {:<24} {:>3} {}", w, v.len(), v.iter().take(5).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
println!(
" {:<24} {:>3} {}",
w,
v.len(),
v.iter()
.take(5)
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}

View File

@@ -1,46 +1,96 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
// 1. All stages: BackGroundID + phases + stage tag (from EnumUnit_SNN)
println!("=== STAGES (StageResource 3c9ae32e) ===");
let mut rows=vec![];
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
if o.schema_hash!=0x3c9ae32e {continue;}
let t=o.tokens();
let bg=o.get_raw("BackGroundID").unwrap_or("?").to_string();
let stage=t.iter().find_map(|s|s.strip_prefix("EnumUnit_").map(|x|x.trim_end_matches(".tbl").to_string())).unwrap_or("?".into());
let phases=t.iter().filter(|s|s.starts_with("Phase_")).count();
let unitgrp=t.iter().any(|s|s.starts_with("UnitGroup_"));
let route=t.iter().any(|s|s.starts_with("Route_"));
let formation=t.iter().any(|s|s.starts_with("FormationSet_"));
rows.push((stage,bg,phases,unitgrp,route,formation));
let mut rows = vec![];
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
if o.schema_hash != 0x3c9ae32e {
continue;
}
let t = o.tokens();
let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string();
let stage = t
.iter()
.find_map(|s| {
s.strip_prefix("EnumUnit_")
.map(|x| x.trim_end_matches(".tbl").to_string())
})
.unwrap_or("?".into());
let phases = t.iter().filter(|s| s.starts_with("Phase_")).count();
let unitgrp = t.iter().any(|s| s.starts_with("UnitGroup_"));
let route = t.iter().any(|s| s.starts_with("Route_"));
let formation = t.iter().any(|s| s.starts_with("FormationSet_"));
rows.push((stage, bg, phases, unitgrp, route, formation));
}
rows.sort();
for (s,bg,p,ug,rt,fm) in &rows{ println!(" {s:8} location={bg:14} phases={p} [squadrons:{} route:{} formations:{}]", if *ug{"Y"}else{"·"},if *rt{"Y"}else{"·"},if *fm{"Y"}else{"·"}); }
for (s, bg, p, ug, rt, fm) in &rows {
println!(
" {s:8} location={bg:14} phases={p} [squadrons:{} route:{} formations:{}]",
if *ug { "Y" } else { "·" },
if *rt { "Y" } else { "·" },
if *fm { "Y" } else { "·" }
);
}
// 2. Parse objectives table into (stage,phase) -> key counts
println!("\n=== OBJECTIVES (033b5b7e) — per stage/phase key groups ===");
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
if o.schema_hash!=0x033b5b7e {continue;}
let t=o.tokens();
let mut groups:std::collections::BTreeMap<String,u32>=Default::default();
for tok in t{ if let Some(p)=tok.find("_Objective_").or_else(||tok.find("_Lose_")).or_else(||tok.find("_Hint_")){ let key=&tok[..p]; *groups.entry(key.trim_start_matches(|c:char|!c.is_ascii_alphabetic()).into()).or_default()+=1; } }
let stages:std::collections::BTreeSet<String>=groups.keys().filter_map(|k|k.get(..3).map(|s|s.to_string())).collect();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
if o.schema_hash != 0x033b5b7e {
continue;
}
let t = o.tokens();
let mut groups: std::collections::BTreeMap<String, u32> = Default::default();
for tok in t {
if let Some(p) = tok
.find("_Objective_")
.or_else(|| tok.find("_Lose_"))
.or_else(|| tok.find("_Hint_"))
{
let key = &tok[..p];
*groups
.entry(
key.trim_start_matches(|c: char| !c.is_ascii_alphabetic())
.into(),
)
.or_default() += 1;
}
}
let stages: std::collections::BTreeSet<String> = groups
.keys()
.filter_map(|k| k.get(..3).map(|s| s.to_string()))
.collect();
println!(" covers {} stages: {:?}", stages.len(), stages);
println!(" sample S01_P1 group counts: {:?}", groups.iter().filter(|(k,_)|k.starts_with("S01_P1")).collect::<Vec<_>>());
println!(
" sample S01_P1 group counts: {:?}",
groups
.iter()
.filter(|(k, _)| k.starts_with("S01_P1"))
.collect::<Vec<_>>()
);
break;
}
// 3. Resolve objective TEXT: search all pak entries for the key "S01_P1_Objective_00" as an ID with English text
println!("\n=== OBJECTIVE TEXT resolution probe ===");
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens();
if let Some(i)=t.iter().position(|s|s=="S01_P1_Objective_00"){
let lo=i.saturating_sub(1); let hi=(i+3).min(t.len());
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let t = o.tokens();
if let Some(i) = t.iter().position(|s| s == "S01_P1_Objective_00") {
let lo = i.saturating_sub(1);
let hi = (i + 3).min(t.len());
println!(" schema {:08x}: ...{:?}...", o.schema_hash, &t[lo..hi]);
}
}

View File

@@ -10,7 +10,9 @@
use sylpheed_formats::{pak, ui_layout};
fn main() {
let path = std::env::args().nth(1).expect("usage: name_resolution <pak> [entry]");
let path = std::env::args()
.nth(1)
.expect("usage: name_resolution <pak> [entry]");
let want: Option<usize> = std::env::args().nth(2).and_then(|s| s.parse().ok());
let ar = pak::PakArchive::open(&path).expect("open pak");
let entries: Vec<_> = ar.entries().to_vec();
@@ -19,16 +21,24 @@ fn main() {
continue;
}
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
let unresolved: Vec<&ui_layout::Element> = build
.elements
.iter()
.filter(|el| el.sprite.is_none() && el.kind & 0x10 == 0)
.collect();
let claimed: std::collections::HashSet<&str> =
build.elements.iter().filter_map(|e| e.sprite.as_deref()).collect();
let unclaimed: Vec<&String> =
build.sprites.keys().filter(|k| !claimed.contains(k.as_str())).collect();
let claimed: std::collections::HashSet<&str> = build
.elements
.iter()
.filter_map(|e| e.sprite.as_deref())
.collect();
let unclaimed: Vec<&String> = build
.sprites
.keys()
.filter(|k| !claimed.contains(k.as_str()))
.collect();
if want.is_none() && unresolved.is_empty() && unclaimed.is_empty() {
continue;
}
@@ -39,12 +49,21 @@ fn main() {
.collect();
println!(
"entry {i:3} {:2} elements UNRESOLVED {:?} UNCLAIMED {:?}",
build.elements.len(), un, uc
build.elements.len(),
un,
uc
);
if want.is_some() {
for el in &build.elements {
let mark = if el.sprite.is_none() && el.kind & 0x10 == 0 { " <-- UNRESOLVED" } else { "" };
println!(" [{:2}] kind {:#06x} {:28} -> {:?}{mark}", el.index, el.kind, el.name, el.sprite);
let mark = if el.sprite.is_none() && el.kind & 0x10 == 0 {
" <-- UNRESOLVED"
} else {
""
};
println!(
" [{:2}] kind {:#06x} {:28} -> {:?}{mark}",
el.index, el.kind, el.name, el.sprite
);
}
}
}

View File

@@ -40,7 +40,11 @@ fn main() {
j += 1;
}
let name = std::str::from_utf8(&d[i..j]).unwrap_or("?").to_string();
if name.ends_with("_col") || name.ends_with("_spc") || name.ends_with("_gls") || name.ends_with("_lum") {
if name.ends_with("_col")
|| name.ends_with("_spc")
|| name.ends_with("_gls")
|| name.ends_with("_lum")
{
i = j.max(i + 1);
continue;
}
@@ -74,10 +78,18 @@ fn main() {
if p == 0 || p + 24 > d.len() {
continue;
}
let a = if p >= 0x48 { be_f64(d, p - 0x48) } else { f64::NAN };
let a = if p >= 0x48 {
be_f64(d, p - 0x48)
} else {
f64::NAN
};
let b = be_f64(d, p + 8);
let head = if p >= 0x50 { be32(d, p - 0x50) } else { 0 };
let t_a = if p >= 0x50 { be_f64(d, p - 0x50) } else { f64::NAN };
let t_a = if p >= 0x50 {
be_f64(d, p - 0x50)
} else {
f64::NAN
};
let t_b = be_f64(d, p);
println!(
" slot{k:2} @{p:#x} head={head:08X}: A={a:12.5} (t={t_a:8.3}) B={b:12.5} (t={t_b:8.3})"

View File

@@ -12,9 +12,15 @@ fn main() {
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]
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]
);
}
}

View File

@@ -22,8 +22,16 @@ fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
if a == b || b == c || a == c || a.max(b).max(c) >= pos.len() || a >= nrm.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let e1 = [
pos[b][0] - pos[a][0],
pos[b][1] - pos[a][1],
pos[b][2] - pos[a][2],
];
let e2 = [
pos[c][0] - pos[a][0],
pos[c][1] - pos[a][1],
pos[c][2] - pos[a][2],
];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
@@ -63,7 +71,9 @@ fn main() {
let mut weak_wind: Vec<(f32, f32, String)> = Vec::new();
let mut worst: Vec<(usize, String, String)> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let Ok(bytes) = std::fs::read(f) else {
continue;
};
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
let mut c_moved = 0usize;
for m in Xbg7Model::stage_models(&bytes) {
@@ -102,7 +112,9 @@ fn main() {
// noise between two equally clean readings.
let w0 = winding(&pad0, &sm.positions, &sm.normals);
let w2: f32 = {
let l2: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 - 2 + k * 2)).collect();
let l2: Vec<u32> = (0..n)
.map(|k| be16(&bytes, vb - n * 2 - 2 + k * 2))
.collect();
winding(&l2, &sm.positions, &sm.normals)
};
weak_wind.push((w0, w2, m.name.clone()));
@@ -115,9 +127,17 @@ fn main() {
}
println!("\n{moved} of {total} single-block resources moved off the pad-0 run");
println!("{still_degen} decoded runs still contain a degenerate triangle");
println!("of the moved, {weak} had a pad-0 run with no degenerate triangle (moved on winding alone)");
let clear = weak_wind.iter().filter(|(w0, w2, _)| *w2 - *w0 > 0.15).count();
let close = weak_wind.iter().filter(|(w0, w2, _)| (*w2 - *w0).abs() <= 0.05).count();
println!(
"of the moved, {weak} had a pad-0 run with no degenerate triangle (moved on winding alone)"
);
let clear = weak_wind
.iter()
.filter(|(w0, w2, _)| *w2 - *w0 > 0.15)
.count();
let close = weak_wind
.iter()
.filter(|(w0, w2, _)| (*w2 - *w0).abs() <= 0.05)
.count();
println!(" of those: {clear} show the shift signature (pad-2 winding > pad-0 by >0.15), {close} are within 0.05 (noise)");
for (w0, w2, n) in weak_wind.iter().take(8) {
println!(" {n}: pad0 wind {w0:.3} -> pad2 {w2:.3}");

View File

@@ -11,84 +11,151 @@ use sylpheed_formats::{pak, ui_layout};
fn measured(names: &[&str]) -> Option<(&'static str, Vec<usize>)> {
const TITLE: [&str; 24] = [
"ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32",
"ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32",
"pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm",
"ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32",
"ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32",
"ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32",
"ptlogo1.t32",
"ptlogo2.t32",
"ptlogo1.t32",
"ptlogo2.t32",
"ptlogo1.t32",
"ptlogo2.t32",
"pteff01.t32",
"ptlogo_tm.t32",
"pteff00.prm",
"ptbase2.t32",
"pteff04.t32",
"ptloop01.rat",
"ptloop02.rat",
"pteff02.prm",
"ptlogo_back2eff1.t32",
"ptlogo_back2eff2.t32",
"ptlogo_back2eff3.t32",
"ptlogo_back2eff4.t32",
"ptlogo_back2eff5.t32",
"ptlogo_back2.t32",
"ptlogo_back2eff.t32",
"ptcopyright.t32",
"ptlogoall_eff.t32",
"ptlogoall_eff2.t32",
];
const SPLASH: [&str; 7] = [
"palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32",
"palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32",
"palogo_eff0.prm",
"palogo_gamearts.t32",
"palogo_gamearts_eff.t32",
"palogo_seta.t32",
"palogo_seta_eff.t32",
"palogo_anima.t32",
"palogo_anima_eff.t32",
];
const MENU: [&str; 16] = [
"pteff00.prm", "ptbase.t32", "pteff05.t32", "ptloop01.rat",
"ptloop02.rat", "pteff02.prm", "ptframe1.t32", "ptframe2.t32",
"pteff10.t32", "pteff12.t32", "ptbtn01.rat", "ptbtn02.rat",
"ptbtn03.rat", "ptbtn04.rat", "ptbtn05.rat", "ptmsg.t32",
"pteff00.prm",
"ptbase.t32",
"pteff05.t32",
"ptloop01.rat",
"ptloop02.rat",
"pteff02.prm",
"ptframe1.t32",
"ptframe2.t32",
"pteff10.t32",
"pteff12.t32",
"ptbtn01.rat",
"ptbtn02.rat",
"ptbtn03.rat",
"ptbtn04.rat",
"ptbtn05.rat",
"ptmsg.t32",
];
if names == TITLE {
return Some(("title", vec![9,11,12,10,13,6,20,19,14,15,18,16,17,0,2,4,7,1,3,5,22,23,21,8]));
return Some((
"title",
vec![
9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21,
8,
],
));
}
if names == SPLASH {
return Some(("splash", vec![0, 2, 4, 6, 1, 3, 5]));
}
if names == SPLASH { return Some(("splash", vec![0,2,4,6,1,3,5])); }
if names == MENU {
return Some(("main menu", vec![1,3,4,2,5,8,9,6,7,15,10,11,12,13,14,0]));
return Some((
"main menu",
vec![1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
));
}
None
}
fn main() {
let path = std::env::args().nth(1).expect("usage: paint_order_audit <pak>");
let path = std::env::args()
.nth(1)
.expect("usage: paint_order_audit <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
let mut checked = 0;
let entries: Vec<_> = ar.entries().to_vec();
for (i, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect();
// Every build: how exposed is it to tie-breaking? A tie between
// OVERLAPPING elements is where a derived order can go visibly wrong.
let keys_all: Vec<u32> = build.elements.iter()
let keys_all: Vec<u32> = build
.elements
.iter()
.map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX))
.collect();
let mut tie_pairs = 0;
for a in 0..keys_all.len() {
for b in (a + 1)..keys_all.len() {
if keys_all[a] == keys_all[b] && keys_all[a] != u32::MAX { tie_pairs += 1; }
if keys_all[a] == keys_all[b] && keys_all[a] != u32::MAX {
tie_pairs += 1;
}
}
}
// Of the tied pairs, how many OVERLAP? Only those can paint visibly
// differently under an arbitrary tie-break. Rect from the declared
// pivot (= half the sprite for a .t32) at the resting placement.
let rect = |e: &ui_layout::Element| -> Option<(i32,i32,i32,i32)> {
let rect = |e: &ui_layout::Element| -> Option<(i32, i32, i32, i32)> {
let kf = e.rest()?;
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 { return None; }
if w == 0 || h == 0 {
return None;
}
Some((kf.x, kf.y, w, h))
};
let mut tie_overlap = 0;
for a in 0..keys_all.len() {
for b in (a + 1)..keys_all.len() {
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; }
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX {
continue;
}
let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b]))
else { continue };
else {
continue;
};
let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0);
let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1);
if ox > 0 && oy > 0 { tie_overlap += 1; }
if ox > 0 && oy > 0 {
tie_overlap += 1;
}
}
}
let Some((label, want)) = measured(&names) else {
println!("entry {i:2} (no measured order) {} elements, {tie_pairs} tied pairs, \
{tie_overlap} of them OVERLAPPING", build.elements.len());
println!(
"entry {i:2} (no measured order) {} elements, {tie_pairs} tied pairs, \
{tie_overlap} of them OVERLAPPING",
build.elements.len()
);
// Name them: these are the only pairs whose order can show.
for a in 0..keys_all.len() {
for b in (a + 1)..keys_all.len() {
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; }
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX {
continue;
}
let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b]))
else { continue };
else {
continue;
};
let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0);
let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1);
if ox > 0 && oy > 0 {
@@ -101,7 +168,9 @@ fn main() {
};
checked += 1;
let got = ui_layout::derived_paint_order(&build, &bytes);
let keys: Vec<u32> = build.elements.iter()
let keys: Vec<u32> = build
.elements
.iter()
.map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX))
.collect();
let exact = got == want;
@@ -110,7 +179,9 @@ fn main() {
// tie the sort cannot resolve) versus a genuine key-order conflict?
let pos_got: Vec<usize> = {
let mut p = vec![0; got.len()];
for (r, &e) in got.iter().enumerate() { p[e] = r; }
for (r, &e) in got.iter().enumerate() {
p[e] = r;
}
p
};
let (mut inv, mut tied) = (0, 0);
@@ -119,12 +190,17 @@ fn main() {
let (x, y) = (want[a], want[b]);
if pos_got[x] > pos_got[y] {
inv += 1;
if keys[x] == keys[y] { tied += 1; }
if keys[x] == keys[y] {
tied += 1;
}
}
}
}
println!("entry {i:2} {label:10} {} elements", want.len());
println!(" derived == measured : {}", if exact { "YES" } else { "NO" });
println!(
" derived == measured : {}",
if exact { "YES" } else { "NO" }
);
println!(" inverted pairs : {inv} (of which same-layer-key ties: {tied})");
if !exact {
println!(" measured: {want:?}");

View File

@@ -1,13 +1,18 @@
fn main(){
let a:Vec<String>=std::env::args().collect();
let bytes=std::fs::read(&a[1]).unwrap();
let filter=a.get(2).cloned().unwrap_or_default();
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let filter = a.get(2).cloned().unwrap_or_default();
for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) {
if !filter.is_empty() && !n.contains(&filter) { continue }
if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) {
if !filter.is_empty() && !n.contains(&filter) {
continue;
}
if let Some((m, stride)) = sylpheed_formats::mesh::debug_resource_params(&bytes, &n) {
let strides = sylpheed_formats::mesh::debug_decl_strides(&bytes, &n);
println!("{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}",
&m[..m.len().min(4)], strides);
println!(
"{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}",
&m[..m.len().min(4)],
strides
);
}
}
}

View File

@@ -15,7 +15,9 @@
use sylpheed_formats::{pak, ui_layout};
fn main() {
let pak_path = std::env::args().nth(1).expect("usage: <GP_TITLE.pak> <outdir> <entry>...");
let pak_path = std::env::args()
.nth(1)
.expect("usage: <GP_TITLE.pak> <outdir> <entry>...");
let ar = pak::PakArchive::open(&pak_path).expect("open");
let entries: Vec<_> = ar.entries().to_vec();
let outdir = std::env::args().nth(2).expect("outdir");
@@ -23,30 +25,43 @@ fn main() {
for spec in std::env::args().skip(3) {
let idx: usize = spec.parse().unwrap();
let bytes = ar.read(&entries[idx]).expect("read");
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
// plateau-less = rest() had to guess: no two adjacent keyframes share a pose
// Default: suppress plateau-less elements. With SUPPRESS_SUBSTR set,
// suppress every element whose NAME contains it instead — used to test
// the entry→hold→exit model's prediction that the splash glows are all
// finished by the moment the logos are up.
let by_name = std::env::var("SUPPRESS_SUBSTR").ok();
let mask: Vec<bool> = build.elements.iter().map(|e| {
if let Some(sub) = &by_name {
return !e.name.to_lowercase().contains(sub.as_str());
}
let k = &e.keyframes;
(0..k.len().saturating_sub(1)).any(|i| {
k[i].fade == k[i+1].fade && k[i].scale_x == k[i+1].scale_x
&& k[i].scale_y == k[i+1].scale_y && k[i].x == k[i+1].x && k[i].y == k[i+1].y
let mask: Vec<bool> = build
.elements
.iter()
.map(|e| {
if let Some(sub) = &by_name {
return !e.name.to_lowercase().contains(sub.as_str());
}
let k = &e.keyframes;
(0..k.len().saturating_sub(1)).any(|i| {
k[i].fade == k[i + 1].fade
&& k[i].scale_x == k[i + 1].scale_x
&& k[i].scale_y == k[i + 1].scale_y
&& k[i].x == k[i + 1].x
&& k[i].y == k[i + 1].y
})
})
}).collect();
.collect();
let suppressed = mask.iter().filter(|m| !**m).count();
let opts = ui_layout::ComposeOptions::default();
let a = ui_layout::compose(&build, &bytes, opts, None);
let b = ui_layout::compose(&build, &bytes, opts, Some(&mask));
std::fs::write(format!("{outdir}/entry{idx:02}_asis.raw"), &a.rgba).unwrap();
std::fs::write(format!("{outdir}/entry{idx:02}_suppressed.raw"), &b.rgba).unwrap();
println!("entry {idx:2} {}x{} elements {:2} plateau-less suppressed {suppressed}",
a.width, a.height, build.elements.len());
println!(
"entry {idx:2} {}x{} elements {:2} plateau-less suppressed {suppressed}",
a.width,
a.height,
build.elements.len()
);
}
}

View File

@@ -10,9 +10,13 @@ fn main() {
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 Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let Some(id) = o.get_raw("ID") else { continue };
if !id.contains(&want) { 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() {
@@ -20,7 +24,11 @@ fn main() {
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!(
" [{j}]{} {:?}",
if j == i { " <-- key" } else { " " },
t[j]
);
}
println!();
}

View File

@@ -2,5 +2,8 @@ fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).unwrap();
let vb: usize = a[3].parse().unwrap();
println!("{:?}", sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3));
println!(
"{:?}",
sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3)
);
}

View File

@@ -21,7 +21,9 @@ fn main() {
for (ei, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(kids) = ratc::parse(&bytes) else { continue };
let Some(kids) = ratc::parse(&bytes) else {
continue;
};
// Only the title-family bundles carry ptbtn records.
if !kids.iter().any(|c| c.name == "ptbtn01f.rat") {
continue;

View File

@@ -41,15 +41,21 @@ fn main() {
let mut agree = 0usize;
let mut disagree: Vec<(String, usize, String, String)> = Vec::new();
for path in std::env::args().skip(1) {
let Ok(ar) = pak::PakArchive::open(&path) else { continue };
let Ok(ar) = pak::PakArchive::open(&path) else {
continue;
};
let short = path.rsplit('/').next().unwrap_or(&path).to_string();
let entries: Vec<_> = ar.entries().to_vec();
for (i, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(kids) = ratc::parse(&bytes) else { continue };
let Some(kids) = ratc::parse(&bytes) else {
continue;
};
for c in &kids {
children += 1;
let Some(o) = opt_name(&bytes, c.offset) else { continue };
let Some(o) = opt_name(&bytes, c.offset) else {
continue;
};
with_opt += 1;
if o == c.name {
agree += 1;

View File

@@ -70,15 +70,21 @@ fn main() {
let mut rows: Vec<(String, usize, usize, String, String, Why)> = Vec::new();
let mut first_child_of_bundle = 0usize;
for path in std::env::args().skip(1) {
let Ok(ar) = pak::PakArchive::open(&path) else { continue };
let Ok(ar) = pak::PakArchive::open(&path) else {
continue;
};
let short = path.rsplit('/').next().unwrap_or(&path).to_string();
let entries: Vec<_> = ar.entries().to_vec();
for (ei, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(kids) = ratc::parse(&bytes) else { continue };
let Some(kids) = ratc::parse(&bytes) else {
continue;
};
for (ci, c) in kids.iter().enumerate() {
total += 1;
let Some(why) = classify(&bytes, c.offset) else { continue };
let Some(why) = classify(&bytes, c.offset) else {
continue;
};
if ci == 0 {
first_child_of_bundle += 1;
}

View File

@@ -1,36 +1,136 @@
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::process::Command;
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, slb, PakArchive};
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
fn contains(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let manifest=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
let code="eng";
let registry=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|contains(b,format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))).unwrap();
let ids=movie_voice::registry_voice_ids(&registry);
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let entries=PakArchive::parse_toc(&stoc).unwrap();
for movie in ["ADV","RT01A","RT01B","RT01C_1","S00A","S01A","hokyu_LS_s02A","hokyu_LS_s09A","hokyu_LS_s02H","hokyu_DS_s07H"]{
let Some(token)=movie_manifest::voice_token(&manifest,movie) else { println!("{movie:16} no token"); continue };
let Some(&id)=ids.get(&token) else { println!("{movie:16} token {token} not in registry"); continue };
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else { println!("{movie:16} no anchor for {token}"); continue };
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
let window=rg(&disc,win_start,8*1024*1024);
let Some(end_local)=movie_voice::find_descriptor(&window,id) else { println!("{movie:16} id {id}: desc NOT FOUND"); continue };
let end=win_start+end_local as u64;
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1))
.or_else(||movie_voice::find_descriptor_before(&window,end_local))
.map(|o|win_start+o as u64)
.filter(|&s|s<end && end-s<1_500_000)
fn rg(disc: &str, g: u64, n: usize) -> Vec<u8> {
let mut segs = vec![];
let mut cum = 0u64;
for i in 0..5 {
let p = format!("{disc}/dat/sound.p{i:02}");
if let Ok(m) = fs::metadata(&p) {
segs.push((cum, m.len(), p));
cum += m.len();
}
}
let mut out = vec![];
let (mut need, mut pos) = (n, g);
for (base, len, path) in &segs {
if need == 0 || pos >= base + len || pos < *base {
continue;
}
let local = pos - base;
let take = need.min((len - local) as usize);
let mut f = fs::File::open(path).unwrap();
f.seek(SeekFrom::Start(local)).unwrap();
let mut b = vec![0u8; take];
f.read_exact(&mut b).unwrap();
out.extend_from_slice(&b);
need -= take;
pos += take as u64;
}
out
}
fn contains(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
fn dur(w: &str) -> String {
let o = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
w,
])
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let tpak = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.unwrap();
let code = "eng";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| contains(b, format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))
})
.unwrap();
let ids = movie_voice::registry_voice_ids(&registry);
let stoc = fs::read(format!("{disc}/dat/sound.pak")).unwrap();
let entries = PakArchive::parse_toc(&stoc).unwrap();
for movie in [
"ADV",
"RT01A",
"RT01B",
"RT01C_1",
"S00A",
"S01A",
"hokyu_LS_s02A",
"hokyu_LS_s09A",
"hokyu_LS_s02H",
"hokyu_DS_s07H",
] {
let Some(token) = movie_manifest::voice_token(&manifest, movie) else {
println!("{movie:16} no token");
continue;
};
let Some(&id) = ids.get(&token) else {
println!("{movie:16} token {token} not in registry");
continue;
};
let Some(anchor) = ["Movie", "etc", "Voice"].iter().find_map(|d| {
let h = name_hash(&format!("{code}\\{d}\\{token}.slb"));
entries
.binary_search_by_key(&h, |e| e.name_hash)
.ok()
.map(|i| entries[i].offset as u64)
}) else {
println!("{movie:16} no anchor for {token}");
continue;
};
let win_start = (anchor.saturating_sub(2 * 1024 * 1024)) & !3;
let window = rg(&disc, win_start, 8 * 1024 * 1024);
let Some(end_local) = movie_voice::find_descriptor(&window, id) else {
println!("{movie:16} id {id}: desc NOT FOUND");
continue;
};
let end = win_start + end_local as u64;
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
.map(|o| win_start + o as u64)
.filter(|&s| s < end && end - s < 1_500_000)
.unwrap_or(anchor);
let region=rg(&disc,start,(end-start) as usize);
let mut riffs=slb::to_xma_riffs(&region);
if riffs.is_empty(){ riffs=slb::to_xma_riff_best(&region).into_iter().collect(); }
let mut d="?".into();
if let Some(r)=riffs.first(){ let xp=format!("/tmp/ra2_{movie}.xma.wav");let wp=format!("/tmp/ra2_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
println!("{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s", (end-start)/1024, riffs.len());
let region = rg(&disc, start, (end - start) as usize);
let mut riffs = slb::to_xma_riffs(&region);
if riffs.is_empty() {
riffs = slb::to_xma_riff_best(&region).into_iter().collect();
}
let mut d = "?".into();
if let Some(r) = riffs.first() {
let xp = format!("/tmp/ra2_{movie}.xma.wav");
let wp = format!("/tmp/ra2_{movie}.wav");
fs::write(&xp, r).unwrap();
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "error", "-y", "-i", &xp, &wp])
.status();
d = dur(&wp);
}
let mv = dur(&format!("{disc}/dat/movie/{movie}.wmv"));
println!(
"{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s",
(end - start) / 1024,
riffs.len()
);
}
}

View File

@@ -6,10 +6,10 @@
//! harvested CSV.
//!
//! Usage: roster_target <disc-root> <unit-runtime-fields.csv>
use std::collections::{BTreeMap, BTreeSet};
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let a: Vec<String> = std::env::args().collect();
@@ -31,15 +31,22 @@ fn main() {
let stage = format!("S{i:02}");
for pre in ["", "unit\\", "battle\\", "stage\\", "enemy\\"] {
for suf in [".tbl", ""] {
by_hash.insert(name_hash(&format!("{pre}EnumUnit_{stage}{suf}")), stage.clone());
by_hash.insert(
name_hash(&format!("{pre}EnumUnit_{stage}{suf}")),
stage.clone(),
);
}
}
}
let mut rows: Vec<(usize, String, Vec<String>)> = Vec::new();
for e in pak.entries() {
let Some(stage) = by_hash.get(&e.name_hash).cloned() else { continue };
let Some(stage) = by_hash.get(&e.name_hash).cloned() else {
continue;
};
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let mut units: Vec<String> = Vec::new();
for id in o.tokens().iter().filter(|s| s.starts_with("UN_")) {
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
@@ -47,7 +54,11 @@ fn main() {
units.push(id.clone());
}
}
let missing: Vec<String> = units.iter().filter(|u| !have.contains(*u)).cloned().collect();
let missing: Vec<String> = units
.iter()
.filter(|u| !have.contains(*u))
.cloned()
.collect();
rows.push((missing.len(), stage, missing));
}
rows.sort_by_key(|(n, _, _)| std::cmp::Reverse(*n));

View File

@@ -1,18 +1,37 @@
//! RE probe: dump tables.pak screen-config records that mention a given token.
//! Usage: screen_configs <disc-root> <substring>
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
fn main(){
let a:Vec<String>=std::env::args().collect();
let needle=a.get(2).cloned().unwrap_or_else(||"CHALLENGE".into());
let arc=PakArchive::open(format!("{}/dat/tables.pak",a[1])).unwrap();
for (i,e) in arc.entries().iter().enumerate(){
let Ok(b)=arc.read(e) else{continue};
let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens();
if !t.iter().any(|s|s.to_lowercase().contains(&needle.to_lowercase())){continue}
let path=t.iter().find(|s|s.contains(".pak+")).cloned().unwrap_or_default();
if !path.is_empty() && !path.contains("+eng"){continue}
println!("\n=== entry #{i} schema {:08x} [{path}] {} tokens ===",o.schema_hash,t.len());
for (j,tok) in t.iter().enumerate(){println!(" {j:3} {tok}");}
fn main() {
let a: Vec<String> = std::env::args().collect();
let needle = a.get(2).cloned().unwrap_or_else(|| "CHALLENGE".into());
let arc = PakArchive::open(format!("{}/dat/tables.pak", a[1])).unwrap();
for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let t = o.tokens();
if !t
.iter()
.any(|s| s.to_lowercase().contains(&needle.to_lowercase()))
{
continue;
}
let path = t
.iter()
.find(|s| s.contains(".pak+"))
.cloned()
.unwrap_or_default();
if !path.is_empty() && !path.contains("+eng") {
continue;
}
println!(
"\n=== entry #{i} schema {:08x} [{path}] {} tokens ===",
o.schema_hash,
t.len()
);
for (j, tok) in t.iter().enumerate() {
println!(" {j:3} {tok}");
}
}
}

View File

@@ -24,7 +24,10 @@ fn main() {
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"),
Some(name) => arc
.read_by_name(&name)
.expect("entry present")
.expect("decompress"),
None => arc
.entries()
.iter()
@@ -57,23 +60,36 @@ fn main() {
// 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 }
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 }
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)));
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");
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];
@@ -88,20 +104,32 @@ fn main() {
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) }
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(" ")
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() },
if parent == u32::MAX {
"-".into()
} else {
parent.to_string()
},
format!("{kind:#x}"),
format!("({px},{py})"),
p.len(),

View File

@@ -9,7 +9,11 @@ fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp".into());
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(&disc);
for (name, off, pkts) in [("move", 0x1ec0usize, 4usize), ("back", 0x0ec0, 2), ("confirm", 0x5d6c0, 6)] {
for (name, off, pkts) in [
("move", 0x1ec0usize, 4usize),
("back", 0x0ec0, 2),
("confirm", 0x5d6c0, 6),
] {
match media::se_wave_riff(&src, "Static.slb", off, pkts, 1, 48000) {
Ok(riff) => {
let p = format!("{out}/{name}.riff");

View File

@@ -17,13 +17,38 @@ fn main() {
// bank → the movies bound to it, from the record table (see movie-subtitle-link).
let banks: BTreeMap<&str, Vec<&str>> = BTreeMap::from([
("VOICE_D_450", vec!["hokyu_LS_s02A", "hokyu_LS_s03A", "hokyu_LS_s06A"]),
(
"VOICE_D_450",
vec!["hokyu_LS_s02A", "hokyu_LS_s03A", "hokyu_LS_s06A"],
),
(
"VOICE_D_451",
vec!["hokyu_LS_s09A", "hokyu_LS_s11A", "hokyu_LS_s15A", "hokyu_LS_s24A", "hokyu_LS_s27A"],
vec![
"hokyu_LS_s09A",
"hokyu_LS_s11A",
"hokyu_LS_s15A",
"hokyu_LS_s24A",
"hokyu_LS_s27A",
],
),
(
"VOICE_D_452",
vec![
"hokyu_DS_s02A",
"hokyu_DS_s07A",
"hokyu_DS_s08A",
"hokyu_DS_s13A",
],
),
(
"VOICE_D_453",
vec![
"hokyu_LS_s02H",
"hokyu_LS_s03H",
"hokyu_LS_s06H",
"hokyu_LS_s09H",
],
),
("VOICE_D_452", vec!["hokyu_DS_s02A", "hokyu_DS_s07A", "hokyu_DS_s08A", "hokyu_DS_s13A"]),
("VOICE_D_453", vec!["hokyu_LS_s02H", "hokyu_LS_s03H", "hokyu_LS_s06H", "hokyu_LS_s09H"]),
("VOICE_D_454", vec!["hokyu_DS_s07H", "hokyu_DS_s14H"]),
]);

View File

@@ -13,9 +13,9 @@
//! Usage:
//! cargo run --release --example shared_vbase_check -- \
//! <Stage_SNN.xpr> <capture.log>...
use std::collections::{BTreeMap, BTreeSet};
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) {
@@ -101,7 +101,10 @@ fn main() {
}
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);
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);
}
@@ -125,7 +128,9 @@ fn main() {
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()));
ours.entry(o)
.or_default()
.push((m.name.clone(), sub.positions.len()));
}
}
}
@@ -156,15 +161,25 @@ fn main() {
}
// 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");
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(", ")
v.iter()
.map(|(n, c)| format!("{n}({c})"))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "— NOBODY".into());
let same = by_count
@@ -185,8 +200,12 @@ fn main() {
}
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();
let pos: Vec<(i64, i64, i64)> = m
.meshes
.iter()
.flat_map(|s| s.positions.iter().copied())
.map(key)
.collect();
if pos.is_empty() {
continue;
}
@@ -205,7 +224,11 @@ fn main() {
// 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());
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();
@@ -222,7 +245,9 @@ fn main() {
.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]])))
&& d.pos
.iter()
.all(|p| want.contains(&key([-p[0], p[1], p[2]])))
})
.map(|d| d.vbase)
.collect();
@@ -249,12 +274,19 @@ fn main() {
"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();
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(" ") }
if shown.is_empty() {
"NOT FOUND".into()
} else {
shown.join(" ")
}
);
}
}

View File

@@ -15,7 +15,9 @@ fn be32(d: &[u8], o: usize) -> u32 {
/// Count joint-track records whose key count != 1 in a composite's descriptor.
fn multikey_tracks(bytes: &[u8], comp: &str) -> usize {
let Some((desc, desc_end)) = xbg7_descriptor_range(bytes, comp) else { return 0 };
let Some((desc, desc_end)) = xbg7_descriptor_range(bytes, comp) else {
return 0;
};
let d = &bytes[desc..desc_end];
let mut n = 0usize;
let mut i = 0usize;
@@ -61,7 +63,11 @@ fn main() {
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let n = p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
n.starts_with("Stage_") && n.ends_with(".xpr")
})
.collect();
@@ -73,7 +79,10 @@ fn main() {
let names = xbg7_resource_names(&bytes);
// Multi-key animation tracks per composite (breaks the single-key read).
for n in names.iter().filter(|n| n.contains("rou_") && !n.contains("break")) {
for n in names
.iter()
.filter(|n| n.contains("rou_") && !n.contains("break"))
{
let mk = multikey_tracks(&bytes, n);
if mk > 0 {
println!("MULTIKEY {stage} {n}: {mk} tracks");
@@ -93,7 +102,9 @@ fn main() {
// Per-placement world centroid.
let mut cents: Vec<(String, [f32; 3])> = Vec::new();
for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
let Some(m) = models.iter().find(|m| m.name == p.resource) else {
continue;
};
let (mut c, mut n) = ([0.0f32; 3], 0f32);
for sub in &m.meshes {
for v in &sub.positions {

View File

@@ -1,9 +1,9 @@
//! Dump a ship's static placement + scene-graph nodes for a stage container FILE
//! (works offline from an extracted disc, no ISO needed).
//! cargo run --release --example ship_dump -- <Stage_SNN.xpr path> <ship_id>
use std::collections::HashSet;
use sylpheed_formats::mesh::{scene_world_nodes, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
@@ -12,7 +12,11 @@ fn main() {
let names = xbg7_resource_names(&bytes);
// Base parts + their vertex counts.
let want: HashSet<String> = names.iter().filter(|n| is_base_part(n) && ship_id_of(n)==Some(id.as_str())).cloned().collect();
let want: HashSet<String> = names
.iter()
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
.cloned()
.collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("== base parts ({}) ==", want.len());
for m in &models {
@@ -32,19 +36,50 @@ fn main() {
// assemble_ship result: centroids + extent.
for ext in [false, true] {
let placed = assemble_ship(&bytes, id, ext);
println!("== assemble_ship(external={ext}) -> {} parts ==", placed.len());
let mut lo=[f32::MAX;3]; let mut hi=[f32::MIN;3];
println!(
"== assemble_ship(external={ext}) -> {} parts ==",
placed.len()
);
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for p in &placed {
let src = models.iter().find(|m| m.name==p.resource);
let (mut c, mut n) = ([0.0f32;3], 0f32);
if let Some(src)=src { for sub in &src.meshes { for v in &sub.positions {
let w=p.apply(*v); for k in 0..3 { c[k]+=w[k]; lo[k]=lo[k].min(w[k]); hi[k]=hi[k].max(w[k]); } n+=1.0; }}}
let n=n.max(1.0);
println!(" {:20} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
p.resource, p.t[0],p.t[1],p.t[2], c[0]/n,c[1]/n,c[2]/n);
let src = models.iter().find(|m| m.name == p.resource);
let (mut c, mut n) = ([0.0f32; 3], 0f32);
if let Some(src) = src {
for sub in &src.meshes {
for v in &sub.positions {
let w = p.apply(*v);
for k in 0..3 {
c[k] += w[k];
lo[k] = lo[k].min(w[k]);
hi[k] = hi[k].max(w[k]);
}
n += 1.0;
}
}
}
let n = n.max(1.0);
println!(
" {:20} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
p.resource,
p.t[0],
p.t[1],
p.t[2],
c[0] / n,
c[1] / n,
c[2] / n
);
}
if placed.iter().any(|p| models.iter().any(|m| m.name==p.resource)) {
println!(" EXTENT=[{:.0} {:.0} {:.0}]", hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]);
if placed
.iter()
.any(|p| models.iter().any(|m| m.name == p.resource))
{
println!(
" EXTENT=[{:.0} {:.0} {:.0}]",
hi[0] - lo[0],
hi[1] - lo[1],
hi[2] - lo[2]
);
}
}
}

View File

@@ -25,7 +25,9 @@ fn main() {
// World triangles + per-part bounds.
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
let Some(m) = models.iter().find(|m| m.name == p.resource) else {
continue;
};
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for sub in &m.meshes {
@@ -59,12 +61,18 @@ fn main() {
for s in 0..SEG {
tris.push([apex, ring[(s + 1) % SEG], ring[s]]);
}
println!(" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]", f.resource, f.t[0], f.t[1], f.t[2]);
println!(
" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]",
f.resource, f.t[0], f.t[1], f.t[2]
);
}
println!("total {} tris from {} placements", tris.len(), placed.len());
// Orthographic z-buffered flat renders: top (X/Z, look down Y) and side (Z/Y, look down X).
for (name, ax, ay, az) in [("top", 0usize, 2usize, 1usize), ("side", 2usize, 1usize, 0usize)] {
for (name, ax, ay, az) in [
("top", 0usize, 2usize, 1usize),
("side", 2usize, 1usize, 0usize),
] {
let size = 900usize;
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for t in &tris {
@@ -105,18 +113,29 @@ fn main() {
let lum = (n[az].abs() / nl * 200.0 + 40.0) as u8;
// bbox raster
let minx = p.iter().map(|v| v[0]).fold(f32::MAX, f32::min).max(0.0) as usize;
let maxx = (p.iter().map(|v| v[0]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
let maxx = (p
.iter()
.map(|v| v[0])
.fold(f32::MIN, f32::max)
.min(size as f32 - 1.0)) as usize;
let miny = p.iter().map(|v| v[1]).fold(f32::MAX, f32::min).max(0.0) as usize;
let maxy = (p.iter().map(|v| v[1]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
let det = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
let maxy = (p
.iter()
.map(|v| v[1])
.fold(f32::MIN, f32::max)
.min(size as f32 - 1.0)) as usize;
let det = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1])
- (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
if det.abs() < 1e-6 {
continue;
}
for y in miny..=maxy {
for x in minx..=maxx {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let w0 = ((p[1][0] - fx) * (p[2][1] - fy) - (p[2][0] - fx) * (p[1][1] - fy)) / det;
let w1 = ((p[2][0] - fx) * (p[0][1] - fy) - (p[0][0] - fx) * (p[2][1] - fy)) / det;
let w0 =
((p[1][0] - fx) * (p[2][1] - fy) - (p[2][0] - fx) * (p[1][1] - fy)) / det;
let w1 =
((p[2][0] - fx) * (p[0][1] - fy) - (p[0][0] - fx) * (p[2][1] - fy)) / det;
let w2 = 1.0 - w0 - w1;
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
continue;

View File

@@ -22,8 +22,12 @@ fn main() {
if !IdxdObject::is_idxd(&bytes) {
continue;
}
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let Some(generic) = obj.record("Generic") else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else {
continue;
};
let Some(generic) = obj.record("Generic") else {
continue;
};
if generic.get("Size_X").is_none() {
continue;
}
@@ -45,11 +49,17 @@ fn main() {
}
}
}
println!("{:<14} {:>10} {:>10} {:>10} {:>10}", "pair", "seen+diff", "seen+eq", "MISS+diff", "MISS+eq");
println!(
"{:<14} {:>10} {:>10} {:>10} {:>10}",
"pair", "seen+diff", "seen+eq", "MISS+diff", "MISS+eq"
);
for (i, (field, sibling)) in pairs.iter().enumerate() {
println!(
"{:<14} {:>10} {:>10} {:>10} {:>10}",
format!("{field}/{sibling}").chars().take(14).collect::<String>(),
format!("{field}/{sibling}")
.chars()
.take(14)
.collect::<String>(),
tally[i][0][0],
tally[i][0][1],
tally[i][1][0],

View File

@@ -7,13 +7,16 @@
//! this does the same comparison numerically.
//!
//! Usage: slab_screen <resource3d_dir> [factor]
use std::collections::BTreeMap;
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 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()
@@ -24,13 +27,17 @@ fn main() {
let mut flagged = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
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 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 {
@@ -49,7 +56,10 @@ fn main() {
// 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));
by_ship
.entry(id.to_string())
.or_default()
.push((m.name.clone(), thin));
}
for (id, parts) in &by_ship {
if parts.len() < 3 {

View File

@@ -87,9 +87,13 @@ fn main() {
);
for (movie, n) in bound {
let path = format!("eng\\etc\\VOICE_D_{n}.slb");
let Some(entry) = snd.find_by_name(&path) else { continue };
let Some(entry) = snd.find_by_name(&path) else {
continue;
};
let bytes = snd.read(entry).expect("read");
let Some(first_riff) = bytes.windows(4).position(|w| w == b"RIFF") else { continue };
let Some(first_riff) = bytes.windows(4).position(|w| w == b"RIFF") else {
continue;
};
if first_riff <= slb::HEADERLESS_DATA_OFFSET {
println!("{movie:<16} {:>6} (no leading region)", format!("D_{n}"));
continue;
@@ -102,7 +106,11 @@ fn main() {
.iter()
.map(|(_, t)| *t)
.fold(0.0f32, f32::max);
let implied = if cue > 0.0 { samples as f32 / cue } else { f32::NAN };
let implied = if cue > 0.0 {
samples as f32 / cue
} else {
f32::NAN
};
println!(
"{movie:<16} {:>6} {stereo:>9} {mono:>9} {samples:>10} {cue:>9.2} {implied:>12.0}",
format!("D_{n}")

View File

@@ -13,7 +13,9 @@ fn main() {
for e in snd.entries() {
let Ok(b) = snd.read(e) else { continue };
total += 1;
let Some(ri) = b.windows(4).position(|w| w == b"RIFF") else { continue };
let Some(ri) = b.windows(4).position(|w| w == b"RIFF") else {
continue;
};
has_riff += 1;
if ri > slb::HEADERLESS_DATA_OFFSET
&& (ri - slb::HEADERLESS_DATA_OFFSET) % slb::XMA1_PACKET == 0

View File

@@ -1,31 +1,82 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap;
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
// schemas whose type0/content is squadron/formation/route/group/position
let mut hit:BTreeMap<u32,(u32,String,String)>=BTreeMap::new();
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default();
let joined:String=t.iter().take(20).map(|s|s.as_str()).collect::<Vec<_>>().join(" ");
if ["Squadron","Formation","Route","UnitGroup","Position","Spawn","Wave","Frame","NullFrame"].iter().any(|k|t0.contains(k)||joined.contains(k)){
let ent=hit.entry(o.schema_hash).or_insert((0,t0.clone(),joined.chars().take(90).collect()));
ent.0+=1;
let mut hit: BTreeMap<u32, (u32, String, String)> = BTreeMap::new();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let t = o.tokens();
let t0 = t.first().cloned().unwrap_or_default();
let joined: String = t
.iter()
.take(20)
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" ");
if [
"Squadron",
"Formation",
"Route",
"UnitGroup",
"Position",
"Spawn",
"Wave",
"Frame",
"NullFrame",
]
.iter()
.any(|k| t0.contains(k) || joined.contains(k))
{
let ent = hit.entry(o.schema_hash).or_insert((
0,
t0.clone(),
joined.chars().take(90).collect(),
));
ent.0 += 1;
}
}
let mut v:Vec<_>=hit.into_iter().collect(); v.sort_by_key(|x|std::cmp::Reverse(x.1.0));
for (s,(c,t0,sample)) in &v{ println!("[{s:08x}] ×{c:<4} type0={t0:<18.18} :: {sample}"); }
let mut v: Vec<_> = hit.into_iter().collect();
v.sort_by_key(|x| std::cmp::Reverse(x.1 .0));
for (s, (c, t0, sample)) in &v {
println!("[{s:08x}] ×{c:<4} type0={t0:<18.18} :: {sample}");
}
// dump a Squadron/UnitGroup blob fully to see if it has positions (binary floats) or refs
println!("\n─── sample squadron/formation blob (first matching) ───");
for e in arc.entries(){
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default();
if t0.contains("Squadron")||t0.contains("Formation")||t0.contains("UnitGroup"){
println!("schema {:08x} type0={t0} count={} tokens={}, blob {}B", o.schema_hash,o.count,t.len(),b.len());
for (i,tk) in t.iter().take(30).enumerate(){ print!("{i:>2}:{tk:<20.20}"); if i%4==3{println!();} } println!();
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let t = o.tokens();
let t0 = t.first().cloned().unwrap_or_default();
if t0.contains("Squadron") || t0.contains("Formation") || t0.contains("UnitGroup") {
println!(
"schema {:08x} type0={t0} count={} tokens={}, blob {}B",
o.schema_hash,
o.count,
t.len(),
b.len()
);
for (i, tk) in t.iter().take(30).enumerate() {
print!("{i:>2}:{tk:<20.20}");
if i % 4 == 3 {
println!();
}
}
println!();
// scan blob for float-like values (reasonable coords) in the binary region
let nfloats=(0..b.len().saturating_sub(4)).step_by(4).filter(|&i|{let f=f32::from_be_bytes([b[i],b[i+1],b[i+2],b[i+3]]); f.is_finite()&&f.abs()>1.0&&f.abs()<1e6}).count();
let nfloats = (0..b.len().saturating_sub(4))
.step_by(4)
.filter(|&i| {
let f = f32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]);
f.is_finite() && f.abs() > 1.0 && f.abs() < 1e6
})
.count();
println!(" ~{nfloats} plausible BE-float words in blob (positions live in binary region if high)");
break;
}

View File

@@ -1,11 +1,36 @@
use sylpheed_formats::{game_data, PakArchive};
fn main(){
let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let sq=game_data::load_squadrons(&pak);
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let sq = game_data::load_squadrons(&pak);
println!("{} squadron definitions", sq.len());
for s in sq.iter().filter(|s|s.side.as_deref()==Some("TCAF")).take(6){
let mem:Vec<String>=s.members.iter().map(|m|format!("{}/{}",m.unit.trim_start_matches("UN_").chars().take(18).collect::<String>(),m.pilot.clone().unwrap_or("·".into()))).collect();
println!(" {:8} {:22} {:24} x{} {:?}", s.id.clone(), s.formation_id.clone().unwrap_or_default(), s.ai_id.clone().unwrap_or_default(), s.count.unwrap_or(0), mem);
for s in sq
.iter()
.filter(|s| s.side.as_deref() == Some("TCAF"))
.take(6)
{
let mem: Vec<String> = s
.members
.iter()
.map(|m| {
format!(
"{}/{}",
m.unit
.trim_start_matches("UN_")
.chars()
.take(18)
.collect::<String>(),
m.pilot.clone().unwrap_or("·".into())
)
})
.collect();
println!(
" {:8} {:22} {:24} x{} {:?}",
s.id.clone(),
s.formation_id.clone().unwrap_or_default(),
s.ai_id.clone().unwrap_or_default(),
s.count.unwrap_or(0),
mem
);
}
}

View File

@@ -8,6 +8,9 @@ fn main() {
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());
println!(
" 0x{o:x} in candidate list: {}",
starts.binary_search(&o).is_ok()
);
}
}

View File

@@ -1,7 +1,15 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
let stride:usize=a[2].parse().unwrap(); let want:usize=a[3].parse().unwrap();
let s=sylpheed_formats::mesh::debug_vertex_run_starts(&b,stride);
println!("{} candidate starts at stride {}", s.len(), stride);
println!("contains {:#x}: {}", want, s.contains(&want));
let near:Vec<String>=s.iter().filter(|&&o| o.abs_diff(want)<0x200).map(|o|format!("{o:#x}")).collect();
println!("nearby: {}", near.join(" "));}
fn main() {
let a: Vec<String> = std::env::args().collect();
let b = std::fs::read(&a[1]).unwrap();
let stride: usize = a[2].parse().unwrap();
let want: usize = a[3].parse().unwrap();
let s = sylpheed_formats::mesh::debug_vertex_run_starts(&b, stride);
println!("{} candidate starts at stride {}", s.len(), stride);
println!("contains {:#x}: {}", want, s.contains(&want));
let near: Vec<String> = s
.iter()
.filter(|&&o| o.abs_diff(want) < 0x200)
.map(|o| format!("{o:#x}"))
.collect();
println!("nearby: {}", near.join(" "));
}

View File

@@ -1,14 +1,21 @@
//! 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;
use sylpheed_formats::mesh::{debug_resource_params, Xbg7Model};
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());
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]);

View File

@@ -5,12 +5,17 @@ fn main() {
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);
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}"),
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"),
}
}

View File

@@ -8,8 +8,8 @@
//! mis-anchor no count-based metric can see).
//!
//! Usage: twin_mirror_audit <resource3d_dir>
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
use sylpheed_formats::mesh::Xbg7Model;
fn key(p: [f32; 3]) -> (i64, i64, i64) {
(
@@ -39,13 +39,19 @@ fn main() {
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 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 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() {
@@ -108,7 +114,10 @@ fn main() {
}
}
}
println!("twin pairs of equal vertex count: {}", same + mirrored + unrelated + related);
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}");

View File

@@ -9,13 +9,18 @@ use sylpheed_formats::ratc;
fn main() {
let mut args = std::env::args().skip(1);
let pak = args.next().expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
let pak = args
.next()
.expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
let arc = PakArchive::open(&pak).expect("open pak");
let rest: Vec<String> = args.collect();
if rest.first().map(|s| s == "--dump").unwrap_or(false) {
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
let bytes = arc
.read_by_hash(h)
.expect("entry present")
.expect("decompress");
std::fs::write(&rest[2], &bytes).unwrap();
println!("wrote {} bytes to {}", bytes.len(), rest[2]);
return;
@@ -25,11 +30,20 @@ fn main() {
// --child 0xHASH <child-name> <out>: carve one RATC child out by its
// listed offset/size, so a 165-byte layout record can be hexdumped alone.
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
let bytes = arc
.read_by_hash(h)
.expect("entry present")
.expect("decompress");
let kids = ratc::parse(&bytes).expect("ratc");
let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found");
let k = kids
.iter()
.find(|k| k.name == rest[2])
.expect("child not found");
std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap();
println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]);
println!(
"wrote {} B ({} @ {:#x}) to {}",
k.size, k.name, k.offset, rest[3]
);
return;
}

View File

@@ -3,8 +3,8 @@
//! 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;
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -20,7 +20,9 @@ fn main() {
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 Ok(bytes) = std::fs::read(f) else {
continue;
};
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;

View File

@@ -13,8 +13,12 @@ fn main() {
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 };
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;
}

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