formats: expose loop_length_units -- the port was reading +0x08 itself

sylpheed-port reports that a record's loop length is on no public ref at all
(example, test and docs/re/ only), so their screen.rs parses the four bytes with
its own RATC guard. That is my field to publish.

One function serves both levels, since a nested .rat leaf is itself a RATC bundle
with the same header shape. Returns None for a non-RATC or short slice so callers
need no guard of their own.

Verified against the disc, controls first: rejects a non-RATC slice, rejects one
too short for the field, reads big-endian at +0x08 -- then reproduces every
published value (ptbtn00f 120, ptloop01 600, ptloop02 720) over 65 GP_TITLE
records with 0 violations of +0x08 >= largest keyframe time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
sylph-decoder
2026-08-30 20:54:16 +00:00
parent 7a9f5d3b82
commit b5df02a4f7
2 changed files with 87 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
//! Verify the newly-public `ui_layout::loop_length_units` against the disc.
//!
//! `sylpheed-port` reads a record's `+0x08` itself, guarded on the RATC magic,
//! because the field was exposed on no public ref at all — example, test and
//! `docs/re/` only. This checks the public function reproduces the numbers the
//! finding was written from before the port depends on it.
//!
//! CONTROL FIRST: the function must return `None` for a non-RATC slice and for a
//! slice too short to hold the field. An accessor that returns a number for
//! anything cannot be trusted to return the right one.
//!
//! cargo run -p sylpheed-formats --example record_loop_length_api
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
// ---- controls -------------------------------------------------------
assert_eq!(ui_layout::loop_length_units(b"NOTR\x00\x00\x00\x00\x00\x00\x00\x78"), None,
"control FAILED: accepted a non-RATC slice");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00"), None,
"control FAILED: accepted a slice too short for +0x08");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00\x00\x00\x00\x00\x00\x78"), Some(120),
"control FAILED: did not read +0x08 big-endian");
println!("controls pass: rejects non-RATC, rejects short, reads BE at +0x08");
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
// The records the finding names, with their published values.
let expect: &[(&str, u32)] = &[("ptbtn00f.rat", 120), ("ptloop01.rat", 600),
("ptloop02.rat", 720)];
let mut seen = 0usize;
let (mut recs, mut viol) = (0usize, 0usize);
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (name, (off, size)) in &b.records {
let rec = &by[*off..(*off + *size).min(by.len())];
let Some(len) = ui_layout::loop_length_units(rec) else { continue };
recs += 1;
// the disc-wide invariant the finding rests on
let largest = ui_layout::parse_build(rec)
.map(|l| l.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0))
.unwrap_or(0);
if len < largest { viol += 1; }
for (want_name, want) in expect {
if name == want_name && seen < 16 {
seen += 1;
let ok = if len == *want { "OK" } else { "MISMATCH" };
println!(" entry {ei:2} {name:16} +0x08 = {len:4} \
(published {want}) largest kf {largest:4} {ok}");
assert_eq!(len, *want, "{name} disagrees with the published value");
}
}
}
}
println!("\n{recs} records read through the public fn; \
{viol} violate +0x08 >= largest keyframe time");
assert!(seen > 0, "found none of the named records — the check proved nothing");
}

View File

@@ -498,6 +498,30 @@ fn opt_link(rec: &[u8]) -> Option<String> {
(!s.is_empty()).then_some(s) (!s.is_empty()).then_some(s)
} }
/// A RATC record's animation **loop length** in keyframe units — its `+0x08`.
///
/// Works at either level: a nested `.rat` leaf is itself a RATC bundle with the
/// same header shape as the one containing it, so this reads a whole screen
/// build's length and a single record's length through one path.
///
/// **Why it is public.** The loop length is not the largest keyframe time —
/// `ptbtn00f.rat`, the `PRESS Ⓐ` plate glow, declares **120** while its last
/// keyframe is at **105**, and that 15-unit slack is the plate holding dark
/// between cycles. A consumer that infers the period from the keyframes gets
/// 105 (1.750 s) against a real pulse measured four times at 2.122.34 s.
/// Decoded disc-wide: 1 781 records, **0** violations of
/// `+0x08 >= largest keyframe time` — see
/// `docs/re/structures/ui-record-loop-length.md`.
///
/// Returns `None` for anything that is not a RATC record, so it is safe to call
/// on an arbitrary slice; callers do not need their own magic guard.
pub fn loop_length_units(rec: &[u8]) -> Option<u32> {
if rec.len() < 0x0c || rec[0..4] != *b"RATC" {
return None;
}
Some(be32(rec, 0x08))
}
/// The sprite a `.rat` record places: a NUL-terminated name at `0x20`. /// The sprite a `.rat` record places: a NUL-terminated name at `0x20`.
/// ///
/// The field is **not** 16 bytes. Capping it there truncates every longer name — /// The field is **not** 16 bytes. Capping it there truncates every longer name —