Merge remote-tracking branch 'origin/main' into auto/frame-blend-draw-path
# Conflicts: # crates/sylpheed-cli/src/main.rs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
65
crates/sylpheed-export/examples/bank_chunks.rs
Normal file
65
crates/sylpheed-export/examples/bank_chunks.rs
Normal 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
56
crates/sylpheed-export/examples/bgm_size_census.rs
Normal file
56
crates/sylpheed-export/examples/bgm_size_census.rs
Normal 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.");
|
||||
}
|
||||
122
crates/sylpheed-export/examples/dialog_pairs.rs
Normal file
122
crates/sylpheed-export/examples/dialog_pairs.rs
Normal 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.");
|
||||
}
|
||||
93
crates/sylpheed-export/examples/dialog_rows.rs
Normal file
93
crates/sylpheed-export/examples/dialog_rows.rs
Normal 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.");
|
||||
}
|
||||
66
crates/sylpheed-export/examples/rat_leaf.rs
Normal file
66
crates/sylpheed-export/examples/rat_leaf.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
crates/sylpheed-export/examples/record_loop_control.rs
Normal file
188
crates/sylpheed-export/examples/record_loop_control.rs
Normal 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
|
||||
}
|
||||
}
|
||||
91
crates/sylpheed-export/examples/record_population.rs
Normal file
91
crates/sylpheed-export/examples/record_population.rs
Normal 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");
|
||||
}
|
||||
67
crates/sylpheed-export/examples/static_with_cycle.rs
Normal file
67
crates/sylpheed-export/examples/static_with_cycle.rs
Normal 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.");
|
||||
}
|
||||
59
crates/sylpheed-export/examples/voice_chunks.rs
Normal file
59
crates/sylpheed-export/examples/voice_chunks.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1194
crates/sylpheed-export/src/audio.rs
Normal file
1194
crates/sylpheed-export/src/audio.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 28–47 % 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(())
|
||||
}
|
||||
|
||||
@@ -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 t54–56, 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
|
||||
}
|
||||
|
||||
@@ -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 88–90 % 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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (S01–S16) ═══");
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(" "));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®);
|
||||
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(®);
|
||||
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(®ion); if rf.is_empty(){rf=slb::to_xma_riff_best(®ion).into_iter().collect();}
|
||||
if let Some(r)=rf.first(){let xp=format!("/tmp/hd_{mv}.xma.wav");let wp=format!("/tmp/hd_{mv}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
for e in &hok {
|
||||
let mv = &e.movie;
|
||||
let bound = e.voice_token.is_some();
|
||||
let tok = resolve(mv);
|
||||
let demo = ms::track_voice_cues(&lpak, mv).first().map(|&(d, _)| d);
|
||||
let mut d = "—".to_string();
|
||||
if let Some(tok) = &tok {
|
||||
if let Some(&id) = ids.get(tok) {
|
||||
if let Some(anchor) = ["Movie", "etc", "Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("eng\\{dir}\\{tok}.slb"));
|
||||
ents.binary_search_by_key(&h, |x| x.name_hash)
|
||||
.ok()
|
||||
.map(|i| ents[i].offset as u64)
|
||||
}) {
|
||||
let ws = (anchor.saturating_sub(2 * 1024 * 1024)) & !3;
|
||||
let win = rg(&disc, ws, 8 * 1024 * 1024);
|
||||
if let Some(el) = movie_voice::find_descriptor(&win, id) {
|
||||
let end = ws + el as u64;
|
||||
let start = movie_voice::find_descriptor(&win, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&win, el))
|
||||
.map(|o| ws + o as u64)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.unwrap_or(anchor);
|
||||
let region = rg(&disc, start, (end - start) as usize);
|
||||
let mut rf = slb::to_xma_riffs(®ion);
|
||||
if rf.is_empty() {
|
||||
rf = slb::to_xma_riff_best(®ion).into_iter().collect();
|
||||
}
|
||||
if let Some(r) = rf.first() {
|
||||
let xp = format!("/tmp/hd_{mv}.xma.wav");
|
||||
let wp = format!("/tmp/hd_{mv}.wav");
|
||||
fs::write(&xp, r).unwrap();
|
||||
let _ = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-v", "error", "-y", "-i", &xp, &wp])
|
||||
.status();
|
||||
d = dur(&wp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{mv:16} {:8} demo={:?} -> {:11} voice={d}s", if bound{"BOUND"}else{"unbound"}, demo, tok.unwrap_or("(silent)".into()));
|
||||
println!(
|
||||
"{mv:16} {:8} demo={:?} -> {:11} voice={d}s",
|
||||
if bound { "BOUND" } else { "unbound" },
|
||||
demo,
|
||||
tok.unwrap_or("(silent)".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®istry);
|
||||
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries=PakArchive::parse_toc(&stoc).unwrap();
|
||||
let hokyu:Vec<String>=movie_manifest::parse(&manifest).into_iter().filter(|m|m.movie.starts_with("hokyu_")).map(|m|m.movie).collect();
|
||||
for movie in &hokyu{
|
||||
let bound=movie_manifest::voice_token(&manifest,movie);
|
||||
let token=bound.clone().or_else(||hokyu_fallback(movie));
|
||||
let Some(token)=token else{println!("{movie:16} NO TOKEN"); continue};
|
||||
let src = if bound.is_some(){"bound"}else{"fallback"};
|
||||
let Some(&id)=ids.get(&token) else{println!("{movie:16} {token} not in registry");continue};
|
||||
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else{println!("{movie:16} no anchor");continue};
|
||||
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
|
||||
let window=rg(&disc,win_start,8*1024*1024);
|
||||
let Some(el)=movie_voice::find_descriptor(&window,id) else{println!("{movie:16} desc not found");continue};
|
||||
let end=win_start+el as u64;
|
||||
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&window,el)).map(|o|win_start+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
|
||||
let region=rg(&disc,start,(end-start)as usize);
|
||||
let mut riffs=slb::to_xma_riffs(®ion); if riffs.is_empty(){riffs=slb::to_xma_riff_best(®ion).into_iter().collect();}
|
||||
let mut d="?".into();
|
||||
if let Some(r)=riffs.first(){let xp=format!("/tmp/hf_{movie}.xma.wav");let wp=format!("/tmp/hf_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
fn rg(disc: &str, g: u64, n: usize) -> Vec<u8> {
|
||||
let mut segs = vec![];
|
||||
let mut cum = 0u64;
|
||||
for i in 0..5 {
|
||||
let p = format!("{disc}/dat/sound.p{i:02}");
|
||||
if let Ok(m) = fs::metadata(&p) {
|
||||
segs.push((cum, m.len(), p));
|
||||
cum += m.len();
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
let (mut need, mut pos) = (n, g);
|
||||
for (base, len, path) in &segs {
|
||||
if need == 0 || pos >= base + len || pos < *base {
|
||||
continue;
|
||||
}
|
||||
let local = pos - base;
|
||||
let take = need.min((len - local) as usize);
|
||||
let mut f = fs::File::open(path).unwrap();
|
||||
f.seek(SeekFrom::Start(local)).unwrap();
|
||||
let mut b = vec![0u8; take];
|
||||
f.read_exact(&mut b).unwrap();
|
||||
out.extend_from_slice(&b);
|
||||
need -= take;
|
||||
pos += take as u64;
|
||||
}
|
||||
out
|
||||
}
|
||||
fn contains(h: &[u8], n: &[u8]) -> bool {
|
||||
h.windows(n.len()).any(|w| w == n)
|
||||
}
|
||||
fn dur(w: &str) -> String {
|
||||
let o = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
w,
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
}
|
||||
fn hokyu_fallback(m: &str) -> Option<String> {
|
||||
if !m.starts_with("hokyu_") {
|
||||
return None;
|
||||
}
|
||||
let ls = m.contains("_LS_");
|
||||
let ds = m.contains("_DS_");
|
||||
let a = m.ends_with('A');
|
||||
let h = m.ends_with('H');
|
||||
Some(
|
||||
match (ls, ds, a, h) {
|
||||
(true, _, true, _) => "VOICE_D_450",
|
||||
(true, _, _, true) => "VOICE_D_453",
|
||||
(_, true, true, _) => "VOICE_D_452",
|
||||
(_, true, _, true) => "VOICE_D_454",
|
||||
_ => return None,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap();
|
||||
let tpak = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.unwrap();
|
||||
let code = "eng";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| contains(b, format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))
|
||||
})
|
||||
.unwrap();
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries = PakArchive::parse_toc(&stoc).unwrap();
|
||||
let hokyu: Vec<String> = movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.filter(|m| m.movie.starts_with("hokyu_"))
|
||||
.map(|m| m.movie)
|
||||
.collect();
|
||||
for movie in &hokyu {
|
||||
let bound = movie_manifest::voice_token(&manifest, movie);
|
||||
let token = bound.clone().or_else(|| hokyu_fallback(movie));
|
||||
let Some(token) = token else {
|
||||
println!("{movie:16} NO TOKEN");
|
||||
continue;
|
||||
};
|
||||
let src = if bound.is_some() { "bound" } else { "fallback" };
|
||||
let Some(&id) = ids.get(&token) else {
|
||||
println!("{movie:16} {token} not in registry");
|
||||
continue;
|
||||
};
|
||||
let Some(anchor) = ["Movie", "etc", "Voice"].iter().find_map(|d| {
|
||||
let h = name_hash(&format!("{code}\\{d}\\{token}.slb"));
|
||||
entries
|
||||
.binary_search_by_key(&h, |e| e.name_hash)
|
||||
.ok()
|
||||
.map(|i| entries[i].offset as u64)
|
||||
}) else {
|
||||
println!("{movie:16} no anchor");
|
||||
continue;
|
||||
};
|
||||
let win_start = (anchor.saturating_sub(2 * 1024 * 1024)) & !3;
|
||||
let window = rg(&disc, win_start, 8 * 1024 * 1024);
|
||||
let Some(el) = movie_voice::find_descriptor(&window, id) else {
|
||||
println!("{movie:16} desc not found");
|
||||
continue;
|
||||
};
|
||||
let end = win_start + el as u64;
|
||||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, el))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.unwrap_or(anchor);
|
||||
let region = rg(&disc, start, (end - start) as usize);
|
||||
let mut riffs = slb::to_xma_riffs(®ion);
|
||||
if riffs.is_empty() {
|
||||
riffs = slb::to_xma_riff_best(®ion).into_iter().collect();
|
||||
}
|
||||
let mut d = "?".into();
|
||||
if let Some(r) = riffs.first() {
|
||||
let xp = format!("/tmp/hf_{movie}.xma.wav");
|
||||
let wp = format!("/tmp/hf_{movie}.wav");
|
||||
fs::write(&xp, r).unwrap();
|
||||
let _ = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-v", "error", "-y", "-i", &xp, &wp])
|
||||
.status();
|
||||
d = dur(&wp);
|
||||
}
|
||||
let mv = dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!("{movie:16} {src:8} {token:12} id={id} voice={d}s movie={mv}s");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(®istry);
|
||||
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries=PakArchive::parse_toc(&stoc).unwrap();
|
||||
for movie in ["ADV","RT01A","RT01B","RT01C_1","S00A","S01A","hokyu_LS_s02A","hokyu_LS_s09A","hokyu_LS_s02H","hokyu_DS_s07H"]{
|
||||
let Some(token)=movie_manifest::voice_token(&manifest,movie) else { println!("{movie:16} no token"); continue };
|
||||
let Some(&id)=ids.get(&token) else { println!("{movie:16} token {token} not in registry"); continue };
|
||||
let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|d|{let h=name_hash(&format!("{code}\\{d}\\{token}.slb"));entries.binary_search_by_key(&h,|e|e.name_hash).ok().map(|i|entries[i].offset as u64)}) else { println!("{movie:16} no anchor for {token}"); continue };
|
||||
let win_start=(anchor.saturating_sub(2*1024*1024))&!3;
|
||||
let window=rg(&disc,win_start,8*1024*1024);
|
||||
let Some(end_local)=movie_voice::find_descriptor(&window,id) else { println!("{movie:16} id {id}: desc NOT FOUND"); continue };
|
||||
let end=win_start+end_local as u64;
|
||||
let start=movie_voice::find_descriptor(&window,id.wrapping_sub(1))
|
||||
.or_else(||movie_voice::find_descriptor_before(&window,end_local))
|
||||
.map(|o|win_start+o as u64)
|
||||
.filter(|&s|s<end && end-s<1_500_000)
|
||||
fn rg(disc: &str, g: u64, n: usize) -> Vec<u8> {
|
||||
let mut segs = vec![];
|
||||
let mut cum = 0u64;
|
||||
for i in 0..5 {
|
||||
let p = format!("{disc}/dat/sound.p{i:02}");
|
||||
if let Ok(m) = fs::metadata(&p) {
|
||||
segs.push((cum, m.len(), p));
|
||||
cum += m.len();
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
let (mut need, mut pos) = (n, g);
|
||||
for (base, len, path) in &segs {
|
||||
if need == 0 || pos >= base + len || pos < *base {
|
||||
continue;
|
||||
}
|
||||
let local = pos - base;
|
||||
let take = need.min((len - local) as usize);
|
||||
let mut f = fs::File::open(path).unwrap();
|
||||
f.seek(SeekFrom::Start(local)).unwrap();
|
||||
let mut b = vec![0u8; take];
|
||||
f.read_exact(&mut b).unwrap();
|
||||
out.extend_from_slice(&b);
|
||||
need -= take;
|
||||
pos += take as u64;
|
||||
}
|
||||
out
|
||||
}
|
||||
fn contains(h: &[u8], n: &[u8]) -> bool {
|
||||
h.windows(n.len()).any(|w| w == n)
|
||||
}
|
||||
fn dur(w: &str) -> String {
|
||||
let o = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
w,
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
}
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap();
|
||||
let tpak = PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.unwrap();
|
||||
let code = "eng";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| contains(b, format!("{code}\\Movie\\VOICE_ADV.slb").as_bytes()))
|
||||
})
|
||||
.unwrap();
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||
let entries = PakArchive::parse_toc(&stoc).unwrap();
|
||||
for movie in [
|
||||
"ADV",
|
||||
"RT01A",
|
||||
"RT01B",
|
||||
"RT01C_1",
|
||||
"S00A",
|
||||
"S01A",
|
||||
"hokyu_LS_s02A",
|
||||
"hokyu_LS_s09A",
|
||||
"hokyu_LS_s02H",
|
||||
"hokyu_DS_s07H",
|
||||
] {
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, movie) else {
|
||||
println!("{movie:16} no token");
|
||||
continue;
|
||||
};
|
||||
let Some(&id) = ids.get(&token) else {
|
||||
println!("{movie:16} token {token} not in registry");
|
||||
continue;
|
||||
};
|
||||
let Some(anchor) = ["Movie", "etc", "Voice"].iter().find_map(|d| {
|
||||
let h = name_hash(&format!("{code}\\{d}\\{token}.slb"));
|
||||
entries
|
||||
.binary_search_by_key(&h, |e| e.name_hash)
|
||||
.ok()
|
||||
.map(|i| entries[i].offset as u64)
|
||||
}) else {
|
||||
println!("{movie:16} no anchor for {token}");
|
||||
continue;
|
||||
};
|
||||
let win_start = (anchor.saturating_sub(2 * 1024 * 1024)) & !3;
|
||||
let window = rg(&disc, win_start, 8 * 1024 * 1024);
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else {
|
||||
println!("{movie:16} id {id}: desc NOT FOUND");
|
||||
continue;
|
||||
};
|
||||
let end = win_start + end_local as u64;
|
||||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.unwrap_or(anchor);
|
||||
let region=rg(&disc,start,(end-start) as usize);
|
||||
let mut riffs=slb::to_xma_riffs(®ion);
|
||||
if riffs.is_empty(){ riffs=slb::to_xma_riff_best(®ion).into_iter().collect(); }
|
||||
let mut d="?".into();
|
||||
if let Some(r)=riffs.first(){ let xp=format!("/tmp/ra2_{movie}.xma.wav");let wp=format!("/tmp/ra2_{movie}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||
let mv=dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!("{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s", (end-start)/1024, riffs.len());
|
||||
let region = rg(&disc, start, (end - start) as usize);
|
||||
let mut riffs = slb::to_xma_riffs(®ion);
|
||||
if riffs.is_empty() {
|
||||
riffs = slb::to_xma_riff_best(®ion).into_iter().collect();
|
||||
}
|
||||
let mut d = "?".into();
|
||||
if let Some(r) = riffs.first() {
|
||||
let xp = format!("/tmp/ra2_{movie}.xma.wav");
|
||||
let wp = format!("/tmp/ra2_{movie}.wav");
|
||||
fs::write(&xp, r).unwrap();
|
||||
let _ = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-v", "error", "-y", "-i", &xp, &wp])
|
||||
.status();
|
||||
d = dur(&wp);
|
||||
}
|
||||
let mv = dur(&format!("{disc}/dat/movie/{movie}.wmv"));
|
||||
println!(
|
||||
"{movie:16} id={id:5} region={}KB riffs={} voice={d}s movie={mv}s",
|
||||
(end - start) / 1024,
|
||||
riffs.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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(" ")
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" "));
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user