formats: composite in the MEASURED paint order, and drop the ghost instances

The compositor painted in declaration order, which the draw capture proved wrong
— the title's background is declared ninth and paints first. The order the game
uses is its runtime child list, and no decoded field reproduces it, so rather
than leave the screen composited wrongly this lands the orders that HAVE been
read off the running game and falls back to declaration order everywhere else.
Two builds are covered: GP_TITLE's title build and the GAME ARTS / SETA / studio
anima splash. Keyed by element names, which identify a build across paks and
language variants.

Rendering it exposed a second defect, and the same capture settles it: the
kind = 0x4 elements are motion-trail ghosts, not resting content. The bundle
declares three instances of each wordmark; the capture shows exactly ONE quad at
each wordmark's position. Drawing them at their resting keyframe put three
oversized PROJECT SYLPHEED copies across the composite. They are now skipped.

Verified with an artifact, not a green build: the composite is committed
(captures/title-composited-measured-order.png) and now reads as the title screen
— background, planet, ship, wordmark, TM, copyright, correctly layered.

The test is disc-gated and was checked BOTH ways: it passes as landed, and
disabling the order table makes it fail. It reads one pak rather than every
build on the disc — the first version used the all-builds helper and got the
test process OOM-killed running alongside the other three.
This commit is contained in:
Sylpheed RE agent
2026-08-19 02:32:14 +00:00
parent f35a871baa
commit c3c79ad028
3 changed files with 122 additions and 1 deletions

View File

@@ -493,6 +493,51 @@ pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option<ComposedScree
/// `visible`, when given, selects elements by index — the viewer uses it for
/// per-element toggles. Elements are drawn in declaration order, which is the
/// screen's own back-to-front order.
/// Paint orders **measured from the running game**, not derived from the file.
///
/// The bundle does not say what order its elements paint in — the game builds a
/// second, reordered child list at load time and paints that, and no decoded
/// field reproduces it (`docs/re/structures/ui-screen-runtime.md` records the
/// search, including everything refuted). Until the ordering is derived, the
/// honest thing is to use the orders that HAVE been read off the running game
/// and to fall back to declaration order everywhere else — which is what this
/// table does. Keying is by element names, because that identifies a build
/// across paks and language variants without a pak hash.
///
/// Each entry is the paint order as declaration indices, first painted first.
fn measured_paint_order(build: &UiBuild) -> Option<Vec<usize>> {
let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect();
// GP_TITLE.pak entry 4 (a60fcb85) — the title screen the game actually runs.
const TITLE: [&str; 24] = [
"ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32",
"ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32",
"pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm",
"ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32",
"ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32",
"ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32",
"ptlogoall_eff2.t32",
];
// GP_TITLE.pak entries 11/14 — the GAME ARTS / SETA / studio anima splash.
const SPLASH: [&str; 7] = [
"palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32",
"palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32",
"palogo_anima_eff.t32",
];
if names == TITLE {
// background, the rotating pair, the other full-screen layers, the
// back2 glow group, the wordmarks, the copyright, the fade.
return Some(vec![
9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5,
22, 23, 21, 8,
]);
}
if names == SPLASH {
// the full-screen .prm, then all three glows, then the three logos.
return Some(vec![0, 2, 4, 6, 1, 3, 5]);
}
None
}
pub fn compose(
build: &UiBuild,
bundle: &[u8],
@@ -508,7 +553,14 @@ pub fn compose(
}
let mut drawn = Vec::new();
let mut missing = Vec::new();
for el in &build.elements {
// Measured paint order when one exists for this build, declaration order
// otherwise — see `measured_paint_order`.
let order: Vec<usize> =
measured_paint_order(build).unwrap_or_else(|| (0..build.elements.len()).collect());
for &ei in &order {
let Some(el) = build.elements.get(ei) else {
continue;
};
if let Some(v) = visible {
if !v.get(el.index).copied().unwrap_or(true) {
continue;
@@ -517,6 +569,16 @@ pub fn compose(
if (el.animated && !opts.include_animated) || (el.focused && !opts.include_focus) {
continue;
}
// `kind = 0x4` elements are the repeated instances of a template — the
// motion-trail ghosts a wordmark leaves while it flies in. They are NOT
// on screen at rest: the title's draw capture shows exactly ONE quad at
// each wordmark's position, though the bundle declares three instances
// of each, and their keyframe groups end at alpha 0. Drawing them at
// their resting keyframe is what put three oversized copies of
// PROJECT SYLPHEED across the composite.
if el.kind & 0x4 != 0 {
continue;
}
let Some(kf) = el.rest() else { continue };
let Some(sprite) = el.sprite.as_ref() else {
continue;