The module said "a few entries disagree with the actual frame count, so we walk by the T8aD magic instead". They do not disagree. An LSTA is a display list of inline elements that are either T8aD sprites or PRMD primitives (the flat coloured quad the UI bundles use to dim a scene), and the count at 0x04 counts both: across all 64 lists on the disc, count == T8aD + PRMD, with no exceptions. The six lists that looked wrong (GP_DEBRIEFING_PILOTLOG, GP_MISSION_SELECT, two language builds each) each hold exactly one primitive, which is the whole of the off-by-one. Also measured after the T8aD rectangle-list fix: all 1281 sprite frames decode, 100%. parse() still returns sprites and skips primitives -- that is the useful behaviour -- but the docs now say so instead of blaming the header. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
3.6 KiB
Rust
98 lines
3.6 KiB
Rust
//! `LSTA` — a display list: a header followed by N inline elements concatenated
|
||
//! back-to-back, each either a [`T8aD`](crate::t8ad) sprite or a `PRMD`
|
||
//! primitive (a flat coloured quad — the same primitive the UI bundles use to
|
||
//! dim a scene).
|
||
//!
|
||
//! The `count` at `@0x04` is **exact, and counts both kinds**: across all 64
|
||
//! lists on the disc, `count == T8aD frames + PRMD primitives` with no
|
||
//! exceptions (measured 2026-08-11). An earlier note here said "a few entries
|
||
//! disagree with the actual frame count" — they do not; that comparison was
|
||
//! counting sprites against a total that includes primitives.
|
||
//!
|
||
//! [`parse`] walks by magic and returns the **sprites**, deliberately skipping
|
||
//! `PRMD` entries, so its result length is `count` only for lists that hold no
|
||
//! primitives. Six lists do (in `GP_DEBRIEFING_PILOTLOG`, `GP_MISSION_SELECT`),
|
||
//! each with exactly one.
|
||
|
||
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
|
||
|
||
/// Magic at the start of an LSTA sprite list.
|
||
pub const LSTA_MAGIC: [u8; 4] = *b"LSTA";
|
||
|
||
/// Whether `bytes` is an LSTA list.
|
||
pub fn is_lsta(bytes: &[u8]) -> bool {
|
||
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
|
||
}
|
||
|
||
/// Decode the inline T8aD sprites, skipping `PRMD` primitives. Returns `None`
|
||
/// only for non-LSTA input. Every one of the 1 281 sprite frames on the disc
|
||
/// decodes (measured 2026-08-11, after the T8aD rectangle-list fix).
|
||
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
|
||
if !is_lsta(bytes) {
|
||
return None;
|
||
}
|
||
|
||
// Offsets of each inline T8aD frame (search past the 4-byte magic).
|
||
let mut offs = Vec::new();
|
||
let mut i = 4;
|
||
while i + 4 <= bytes.len() {
|
||
if bytes[i..i + 4] == T8AD_MAGIC {
|
||
offs.push(i);
|
||
i += 4;
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
let mut frames = Vec::with_capacity(offs.len());
|
||
for (idx, &off) in offs.iter().enumerate() {
|
||
let next = offs.get(idx + 1).copied().unwrap_or(bytes.len());
|
||
if let Some(img) = t8ad::parse(&bytes[off..next]) {
|
||
frames.push(img);
|
||
}
|
||
}
|
||
Some(frames)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A faithful one-rectangle T8aD frame: base header, a 1-entry offset table,
|
||
/// then the rectangle header (dst 0,0, size w×h) and its pixels. (Before
|
||
/// 2026-08-11 this fixture wrote no offset-table entry at all and the decoder
|
||
/// read "pixels" from inside the header — the test only ever checked the
|
||
/// dimensions, so it passed anyway.)
|
||
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
|
||
let mut b = vec![0u8; 0x2c];
|
||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
|
||
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table → rect at 0x30
|
||
for v in [0u32, 0, w, h] {
|
||
b.extend_from_slice(&v.to_be_bytes()); // dst X, dst Y, width, height
|
||
}
|
||
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn walks_inline_frames() {
|
||
let mut b = LSTA_MAGIC.to_vec();
|
||
b.extend_from_slice(&2u32.to_be_bytes());
|
||
b.extend_from_slice(&[0u8; 15]); // header remainder
|
||
b.extend_from_slice(&t8ad_frame(4, 4));
|
||
b.extend_from_slice(&t8ad_frame(2, 3));
|
||
let frames = parse(&b).unwrap();
|
||
assert_eq!(frames.len(), 2);
|
||
assert_eq!((frames[0].width, frames[0].height), (4, 4));
|
||
assert_eq!((frames[1].width, frames[1].height), (2, 3));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_non_lsta() {
|
||
assert!(parse(b"T8aD....").is_none());
|
||
}
|
||
}
|