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 c2b940c98b
commit afb737b8d0
2 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;

View File

@@ -216,3 +216,62 @@ fn scaled_elements_are_a_small_and_mostly_undiscriminating_minority() {
"expected thousands of placements, got {total}"
);
}
/// The measured paint order is applied, and the ghost instances are not drawn.
///
/// Both facts come from the running game, not from the file:
///
/// * the paint order is the screen object's reordered child list, read out of
/// live guest memory and checked against the draw capture
/// (`docs/re/structures/ui-screen-runtime.md`). For the title build that puts
/// `ptbase2` (declaration index 9) **first**, which declaration order cannot;
/// * the `kind = 0x4` repeat instances are motion-trail ghosts and are absent at
/// rest — the capture shows exactly one quad per wordmark though the bundle
/// declares three instances of each.
#[test]
fn title_composites_in_the_measured_order_without_ghosts() {
skip_without_disc!(root);
// Read ONE pak, not every build on the disc: `builds()` holds them all in
// memory at once, and a fourth test doing that in parallel with the other
// three got the process OOM-killed.
let mut found = false;
for bundle in pak_builds(&root, "GP_TITLE.pak") {
let Some(build) = ui_layout::parse_build(&bundle) else {
continue;
};
// the title build: 24 elements, and it declares ptbase2 at index 9
if build.elements.len() != 24
|| build.elements[9].name != "ptbase2.t32"
|| build.elements[0].name != "ptlogo1.t32"
{
continue;
}
found = true;
let out = ui_layout::compose(&build, &bundle, ComposeOptions::default(), None);
// the background is painted FIRST — the whole point of the measured order
assert_eq!(
out.drawn.first().copied(),
Some(9),
"expected ptbase2 (element 9) painted first, got {:?}",
out.drawn.first()
);
// ... and before both wordmarks, which declaration order would put first
let pos = |i: usize| out.drawn.iter().position(|&d| d == i);
assert!(pos(9) < pos(0), "background must precede ptlogo1");
assert!(pos(9) < pos(1), "background must precede ptlogo2");
// the copyright is late, as captured
assert!(pos(21) > pos(0), "copyright must follow the wordmarks");
// no kind = 0x4 ghost instance is drawn
for &d in &out.drawn {
assert_eq!(
build.elements[d].kind & 0x4,
0,
"element {d} ({}) is a kind=0x4 ghost and must not be drawn at rest",
build.elements[d].name
);
}
}
assert!(found, "the 24-element GP_TITLE build was not found");
}