port: land the play-tested work, and only that
Takes the port branch up to77320d5e-- the state the human play-tested on 2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio` is 366 commits and 938 files, and most of that must not land. WHAT COMES IN (76 files, all human-confirmed working): * the logo splash animation.08ed3dd1found it: `pose_at` ASSIGNED the settle instant instead of clamping to it, so the splash never animated at all -- and the same bug manufactured a passing harness result, because the harness photographed t past the settle. Confirmed by play-test: "cannot notice any obvious difference from the actual game." * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad binding), stick latched with hysteresis at the game's own 61% digitise threshold. This is what made (A), video-skip and Extras work at all. * menu navigation and flow, menu audio, the exporter, the authored declarations, and 23 verification tools under tools/port/. WHAT IS DELIBERATELY LEFT ON THE BRANCH: * everything afterc0ae460a-- the F5/F6 title-timing investigation, whose own tip commit calls itself a "hand-off for one-minute human checks". Unchecked by definition; it goes through the new review gate like anything else. * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested. * the F1 repeat mechanism, which its own commit calls "deliberately inert". WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED: 545 MB of extracted game content was committed on that branch -- 850 sprite, audio and transcoded video files under `export-probe/` and `export-probe2/`, plus 246 MB of loose .wav and .tsv at the repo root. This repository's own rule, in this file, is "never game content". The rule was not missing. It was written, and it was tightened on that very branch, with a careful comment explaining why BOTH `export/` and `data/base/` had to be listed -- while the exporter was writing to a third name that nobody had thought to list. Enumerating names is the thing that failed. So the ignore rules now describe the SHAPE: any top-level `export*/`, game media by extension, and loose capture output at the root. Verified both ways -- it catches all four offenders and ignores nothing currently tracked. Verified: `cargo check --workspace` clean; all nine GDScript files parse in project context, with a positive control (an injected syntax error is detected, 3 lines) so the clean result means something. `tools/port/check-all` was NOT run -- it needs the container, the export tree and a display.
This commit is contained in:
45
crates/sylpheed-export/examples/bank_chunks.rs
Normal file
45
crates/sylpheed-export/examples/bank_chunks.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
47
crates/sylpheed-export/examples/bgm_size_census.rs
Normal file
47
crates/sylpheed-export/examples/bgm_size_census.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! 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.");
|
||||
}
|
||||
87
crates/sylpheed-export/examples/dialog_pairs.rs
Normal file
87
crates/sylpheed-export/examples/dialog_pairs.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! 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 sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
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.");
|
||||
}
|
||||
67
crates/sylpheed-export/examples/dialog_rows.rs
Normal file
67
crates/sylpheed-export/examples/dialog_rows.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! 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.");
|
||||
}
|
||||
49
crates/sylpheed-export/examples/rat_leaf.rs
Normal file
49
crates/sylpheed-export/examples/rat_leaf.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
crates/sylpheed-export/examples/record_loop_control.rs
Normal file
130
crates/sylpheed-export/examples/record_loop_control.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! 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 sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// 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 } }
|
||||
65
crates/sylpheed-export/examples/record_population.rs
Normal file
65
crates/sylpheed-export/examples/record_population.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! 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");
|
||||
}
|
||||
46
crates/sylpheed-export/examples/static_with_cycle.rs
Normal file
46
crates/sylpheed-export/examples/static_with_cycle.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! 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.");
|
||||
}
|
||||
48
crates/sylpheed-export/examples/voice_chunks.rs
Normal file
48
crates/sylpheed-export/examples/voice_chunks.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user