The placement region is `frames` records of `{u32 time; 36-byte pose}` after an
8-byte header, so the time word PRECEDES the pose it belongs to. Our parser's
40-byte window opened at the pose, four bytes into the record, and then read the
word at its `+36` as that pose's time -- which is the NEXT pose's. Every pose
field was right; only the time association slipped by one.
Two things the corpus has carried for weeks are that off-by-one and nothing
else: "a group's data stops 4 bytes short of its final block's time slot", and
"the last keyframe carries no time". The group is not short (8 + frames*40 is
exact) and no time is missing -- the first pose's time is the lead-in word at
`header + 8` that `parse_placements` skipped without asking what it was.
Disc-wide, 33 archives, 13 991 groups, each test with a control:
A lead-in prepended to the shifted times is non-decreasing 13991/13991
B a non-zero lead-in is strictly below the next time 5058/5058
control (another group's lead-in, same bundle) 70.9%
C multi-segment alpha ramp at a constant rate, corrected 857/1540
the same, under the old reading 0/1042
C is the one that cannot be argued with: interpolation between keyframes is
linear, and under the old reading not one multi-keyframe ramp on the disc comes
out at a constant rate.
Adoption is free on every static composite, which is what the corpus previously
declined it over. `SYLPHEED_KF_TIME_SHIFT=1` moved GP_TITLE build 7 by 13.1% of
its pixels because it left pose 0 untimed; with the lead-in restored, all 12
GP_TITLE builds render byte-identically, and across 217 builds in six archives
only two elements pick a different rest pose -- both times between two poses
that are equally invisible.
`SYLPHEED_KF_TIME_SHIFT` is gone; `SYLPHEED_KF_TIME_LEGACY=1` restores the old
reading for A/B work.
ui_header_time_disc needed one line: 546 bundles whose every group is a single
static pose now report max_time = 0 where they previously reported no time at
all. Excluding them, the result it guards strengthened -- the bound holds over
2 859 bundles instead of 2 313, still with zero violations.
Not established: the executable's own parser. Reach is written down.
docs/re/ui-keyframe-record-layout.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
175 lines
6.9 KiB
Rust
175 lines
6.9 KiB
Rust
//! Is the bundle header's `+0x08` really an animation **duration in frames**?
|
|
//!
|
|
//! The header sweep found `+0x04` takes only `0x3C0000` and `0x1E0000` — exactly
|
|
//! `60.0` and `30.0` in 16.16 — and `+0x08` takes 30 / 1200 / 120 / 60, which
|
|
//! *looks* like a frame rate and a length. That reading came from the values
|
|
//! alone, and this checks it against something the file states independently:
|
|
//! the **keyframe times** in the placement region.
|
|
//!
|
|
//! If `+0x08` is the length of the bundle's animation, the largest keyframe time
|
|
//! in the bundle should never exceed it, and should reach it on bundles that
|
|
//! animate all the way through. If instead the times run past it, the reading is
|
|
//! wrong and the amber has to come off the other way.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
|
|
|
fn disc_root() -> Option<PathBuf> {
|
|
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
|
let p = PathBuf::from(p);
|
|
if p.join("dat").is_dir() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
let default = Path::new(
|
|
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
|
);
|
|
if default.join("dat").is_dir() {
|
|
return Some(default.to_path_buf());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
|
|
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
|
.expect("dat/")
|
|
.flatten()
|
|
.map(|e| e.path())
|
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
|
.collect();
|
|
paks.sort();
|
|
for p in &paks {
|
|
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
|
let Ok(arc) = PakArchive::open(p) else { continue };
|
|
for e in arc.entries() {
|
|
let Ok(bytes) = arc.read(e) else { continue };
|
|
if ratc::is_ratc(&bytes) {
|
|
f(&name, &bytes);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn be32(b: &[u8], at: usize) -> u32 {
|
|
u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
|
|
}
|
|
|
|
#[test]
|
|
fn header_0x08_against_the_keyframe_times() {
|
|
let Some(root) = disc_root() else {
|
|
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
|
return;
|
|
};
|
|
|
|
let (mut animated, mut within, mut exact, mut over) = (0usize, 0, 0, 0);
|
|
let mut worst: Vec<String> = Vec::new();
|
|
// How the ratio max_time / header_0x08 is distributed, in tenths.
|
|
let mut ratio: HashMap<u32, usize> = HashMap::new();
|
|
// Does the rate word co-vary with anything?
|
|
let mut by_rate: HashMap<u32, (usize, u32)> = HashMap::new(); // rate -> (bundles, max seen 0x08)
|
|
// The 16.16 frame-rate reading rests on twelve bundles at 30.0. If those are
|
|
// VARIANTS of 60.0 bundles - same elements, different rate - the reading
|
|
// gains a real discriminator; if they are unrelated one-offs it does not.
|
|
let mut odd_rate: Vec<String> = Vec::new();
|
|
|
|
for_each_build(&root, |pak, bytes| {
|
|
if bytes.len() < 0x20 {
|
|
return;
|
|
}
|
|
let Some(build) = ui_layout::parse_build(bytes) else {
|
|
return;
|
|
};
|
|
if build.from_fallback {
|
|
return;
|
|
}
|
|
let dur = be32(bytes, 0x08);
|
|
let rate = be32(bytes, 0x04);
|
|
let e = by_rate.entry(rate).or_default();
|
|
e.0 += 1;
|
|
e.1 = e.1.max(dur);
|
|
|
|
if rate != 0x3C_0000 && odd_rate.len() < 20 {
|
|
let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).take(6).collect();
|
|
odd_rate.push(format!(
|
|
"{pak}: rate {rate:#x} dur {dur} elements {} {:?}",
|
|
build.elements.len(),
|
|
names
|
|
));
|
|
}
|
|
let max_time = build
|
|
.elements
|
|
.iter()
|
|
.flat_map(|el| el.keyframes.iter())
|
|
.filter_map(|k| k.time)
|
|
.max();
|
|
let Some(max_time) = max_time else { return };
|
|
if dur == 0 {
|
|
return;
|
|
}
|
|
animated += 1;
|
|
if max_time <= dur {
|
|
within += 1;
|
|
if max_time == dur {
|
|
exact += 1;
|
|
}
|
|
} else {
|
|
over += 1;
|
|
if worst.len() < 10 {
|
|
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
|
|
}
|
|
}
|
|
// ⚠️ Bundles whose every group is a single static pose contribute
|
|
// `max_time == 0` and say nothing about whether `+0x08` is a length.
|
|
// Before 2026-08-29 they were invisible here, because the old keyframe
|
|
// time reading left a one-frame group's only pose untimed; the corrected
|
|
// record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the
|
|
// group's lead-in time, which is 0. They are excluded rather than
|
|
// allowed to swamp the histogram's zero bucket — 546 of them do.
|
|
if max_time > 0 {
|
|
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
|
*ratio.entry(r.min(30)).or_default() += 1;
|
|
}
|
|
});
|
|
|
|
eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}");
|
|
eprintln!(" max keyframe time <= +0x08: {within} of which EXACTLY equal: {exact}");
|
|
eprintln!(" max keyframe time > +0x08: {over}");
|
|
let mut r: Vec<_> = ratio.iter().collect();
|
|
r.sort();
|
|
eprintln!(" ratio max_time/+0x08 (tenths -> bundles): {r:?}");
|
|
let mut br: Vec<_> = by_rate.iter().collect();
|
|
br.sort();
|
|
eprintln!(" +0x04 rate word -> (bundles, largest +0x08 seen): {br:?}");
|
|
for w in &worst {
|
|
eprintln!(" over: {w}");
|
|
}
|
|
for o in &odd_rate {
|
|
eprintln!(" non-60 rate: {o}");
|
|
}
|
|
|
|
assert!(animated > 0, "no animated bundles — the sweep is broken");
|
|
|
|
// MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe
|
|
// record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859
|
|
// bundles that have both (2 313 before the correction, which could not time
|
|
// a group's final pose at all), and 444 of them reach it exactly. The
|
|
// spread-out ratio histogram is what rules out the boring explanation: a
|
|
// large unrelated constant would bound everything too, but then the ratios
|
|
// would pile up near zero instead of peaking at 1.0.
|
|
//
|
|
// ✅ The correction STRENGTHENS this result rather than weakening it: 546
|
|
// more bundles now carry a readable last-pose time, and `over` is still 0 —
|
|
// i.e. the newly-visible times, which are the LATEST in every group, still
|
|
// do not run past the header's.
|
|
assert_eq!(over, 0, "a keyframe time runs past the header's +0x08");
|
|
assert!(exact > 400, "the bound is never attained — it may be unrelated");
|
|
let near_one = ratio.get(&10).copied().unwrap_or(0);
|
|
let near_zero = ratio.get(&0).copied().unwrap_or(0);
|
|
assert!(
|
|
near_one > near_zero * 4,
|
|
"the max_time/+0x08 ratio does not peak at 1.0 — the bound may be vacuous"
|
|
);
|
|
}
|