From b5df02a4f74c80b93b93cd1a26296ee08040d826 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sun, 30 Aug 2026 20:54:16 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- .../examples/record_loop_length_api.rs | 63 +++++++++++++++++++ crates/sylpheed-formats/src/ui_layout.rs | 24 +++++++ 2 files changed, 87 insertions(+) create mode 100644 crates/sylpheed-formats/examples/record_loop_length_api.rs diff --git a/crates/sylpheed-formats/examples/record_loop_length_api.rs b/crates/sylpheed-formats/examples/record_loop_length_api.rs new file mode 100644 index 00000000..56b5767d --- /dev/null +++ b/crates/sylpheed-formats/examples/record_loop_length_api.rs @@ -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"); +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index e63fbc7d..9b8510c3 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -498,6 +498,30 @@ fn opt_link(rec: &[u8]) -> Option { (!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.12–2.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 { + 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 field is **not** 16 bytes. Capping it there truncates every longer name —