104 Commits

Author SHA1 Message Date
Sylpheed RE agent
7a4e4333f4 docs: withdraw yesterday's "paint order is a sequence" -- wrong source
Last iteration I claimed the splash's measured_paint_order [0,2,4,6,1,3,5]
records, between its glow and logo halves, the temporal order they were
seen in rather than depth -- because the halves never share a frame.

The no-overlap measurement is right (glows f94-115, logos f116-211). The
inference is wrong, on two independent grounds:

  * Wrong source. That vector is not a read of the draw capture. It is a
    read of the live screen object's CHILD ARRAY -- ui-screen-runtime.md
    records it literally as "paint order (child slots)". A child list has
    a definite order whether or not its children are ever drawn together,
    so co-occurrence does not bear on it. The capture was the CHECK.
  * The order is in the file anyway. paint_order_audit on GP_TITLE entry
    11: derived == measured, 0 inverted pairs, 0 same-layer-key ties. The
    glows and logos carry distinct T8aD keys (0xa100 < 0xa110), so the
    file orders the halves statically, no capture involved.

I asked the question that started this iteration -- do the title and menu
orders have the same problem -- and the answer is that none of the three
does, for the same reason.

What survives is narrower and now recorded with numbers: how much of each
order its capture actually cross-checks. The title capture is stable (8
draws / 12 quads / 5 textures, identical in all five captured frames
across two logs) and confirms 7 of 24 positions; the menu capture is not
(texture 0x11C30000 present in frame 0, gone by frame 3); the splash
capture cannot cross-check its middle at all.

A counting trap worth the tool: count QUADS, not draws. The menu's draw 9
is indices=24 -- six quads batched from one texture. Counting draws reads
9 where 16 are on screen, and an earlier pass of this analysis briefly
"found" three quads for six declarations that way and concluded elements
were missing. They were batched.

METHOD: check what a "measured" value was measured FROM before reasoning
about its limits. The co-occurrence rule is real, and it is specific to
orders read from draw captures.
2026-08-29 05:19:44 +00:00
Sylpheed RE agent
2532c056be docs: the splash .prm measured -- and its paint order is a sequence, not depth
ui-prm-primitives recorded that where a .prm paints on a screen without a
measured order is unsolved. For the developer splash it is now measured.

Every frame opens with two full-screen draws before any sprite. The
second is untextured in all 212 frames with a constant vertex colour of
FF000000 -- opaque black -- matching palogo_eff0.prm's declaration
exactly: kind 0x10, pivot (640,360) -> 1280x720, one keyframe, a = 255.
So the splash backdrop is an opaque black full-screen quad from the
bundle itself, painted behind every sprite, which is why a splash render
needs --black rather than the default backdrop.

Not a general rule, and said so: the measured main-menu order puts
pteff02.prm at position 4 and pteff00.prm LAST, the latter being the
screen-transition fade.

And a correction to an existing row. measured_paint_order returns
[0, 2, 4, 6, 1, 3, 5] for the splash, described as "the .prm, then all
three glows, then the three logos". But the glows and the logos never
appear in the same frame -- 0 overlapping frames in 235 -- and two
elements that never co-occur have no observable relative depth. Between
those halves the vector records the order they were SEEN IN, not a
front-to-back relationship.

That does not make the render wrong, and element 0 is a real depth
observation since the .prm co-occurs with everything. But the type of the
claim matters: reading the vector as depth invites compositing all seven
elements at once, which is exactly what does not reproduce the screen.

METHOD: two things that never co-occur have no observable relative order;
when recording an order, note which pairs actually appeared together.
2026-08-29 05:06:37 +00:00
Sylpheed RE agent
7988f52ce7 re(ui): deliver the measured splash sequence the port has to author
The activation decision is code, and MISSION already says the port
authors the sequence -- so the useful move is to hand over the sequence
measured rather than chase the code. From the 235-frame draw capture, at
1 frame = 1/30 s (2 units/frame, 1 unit = 1/60 s, both settled in Q1):

  publisher: SQUARE ENIX logo    f1-90     90 frames   3.00 s+   at 0.00
  (gap, nothing drawn)           f91-93     3 frames   0.10 s    at 3.00
  developer: both glows          f94-115   22 frames   0.73 s    at 3.10
  developer: both logos          f116-211  96 frames   3.20 s    at 3.83

Three limits, stated with the numbers rather than after them. The capture
opens with palogo_sqex already at a=255, so the publisher phase began
before the window and 3.00 s is a FLOOR -- every "starts at" is relative
to the capture, not to boot. palogo_anima and palogo_anima_eff get 0
draws in all 214 frames, so a third pair's phase is not in this
measurement. And it is one capture, one run: the glow->logo switch being
a single frame boundary with no overlap is a strong shape, but each
duration is one sample.

What is solid is the part that matters: the 0.73 s and 3.20 s phases are
each within 2% of their element's declared span, so the durations are the
bundle's own and only the ordering is authored. That is the difference
between a port transcribing timing and inventing it.
2026-08-29 05:00:54 +00:00
Sylpheed RE agent
7156591654 re(ui): re-establish selective activation by killing the alternative statically
Last iteration I withdrew "a bundle is a palette" because the evidence
did not choose between selective activation within one bundle and two
compositions shown in sequence. The alternative can be killed from the
disc, which I had not tried.

Hypothesis 2 needs a bundle declaring the GLOWS WITHOUT THE LOGOS. There
is none. Every GP_TITLE entry carrying palogo elements:

  10, 13 (publisher twins)  palogo_eff0.prm, palogo_sqex, palogo_sqex_eff
  11, 14 (developer twins)  palogo_eff0.prm + all three logos + all three glows

Four entries, and each developer entry declares the complete set of six.
So whichever bundle was active across frames 94-211 -- entry 11, entry
14, or both in turn -- it declared the logos and the glows, while the
game drew two sprites at a time in disjoint phases.

Therefore only a subset of a bundle's elements is drawn at any moment,
whatever the bundle-loading story is. The conclusion no longer depends on
resolving how many bundles are involved, which is why the texture-base
test's failure stopped mattering.

So the claim is reinstated -- this time by eliminating the alternative
rather than by assuming it away. What worked was not a better capture but
asking what the competing hypothesis would REQUIRE on the disc and
finding it absent.

METHOD: a hypothesis that predicts an artefact can be killed by looking
for the artefact, which is often far cheaper than measuring the behaviour.
2026-08-29 04:56:13 +00:00
Sylpheed RE agent
2a086051b3 re(ui): withdraw the mechanism -- "palette" was an explanation, not a finding
Last iteration I wrote that a bundle is a palette whose elements are
selectively activated. The disjoint glow/logo phases have two
explanations and I asserted one:

  1. one bundle, some elements run then others;
  2. two bundle-loads shown in sequence (entries 11 and 14 are twins
     declaring identical sprites).

The draw log's tex[base=...] looked like it would separate them. It does
not, and the control is in the same table:

  publisher splash  f1-90     0x11C30000, 0x10000000
  glows             f94-115   0x11C30000, 0x10000000
  logos             f116-211  0x11C30000, 0x10000000

The publisher splash is certainly a DIFFERENT bundle from the developer
splash, and it uses the same base. So 0x11C30000 is a reused upload slot,
not a bundle identity, and the test cannot choose between the two
hypotheses.

Survives: a bundle's declared elements are not what gets drawn.
palogo_anima and palogo_gamearts carry byte-identical keyframe times and
in the same run one is drawn 95 frames and the other none -- and
whichever twin was active declares both. The phases are strictly disjoint
(0 overlapping frames in 235).

Withdrawn: the mechanism. The practical consequence is unchanged --
compositing every element of a bundle does not reproduce what the game
shows over time -- but the why is not established and I stated it as
though it were.

What would separate them: a per-draw capture recording the bundle each
draw came from, or a file-IO log showing whether a second RATC entry is
read between frames 115 and 116.

METHOD: a shared resource address does not identify the resource's owner;
and state the mechanism as a separate claim from the observation, or the
weaker one inherits the stronger one's evidence.
2026-08-29 04:51:34 +00:00
Sylpheed RE agent
baed44a9ea re(ui): the sequencing survives refutation -- and a bundle is a palette
Two checks on last iteration's "sequential, not simultaneous" reading.

First, the phases really are disjoint. If glows and logos ever shared a
frame the claim would be wrong. Across all 235 captured frames the count
of frames containing both is ZERO, and the switch is a single clean
boundary -- f110-115 draw 1280x720 + 262x108 + 525x90, f116 onward
1280x720 + 243x86 + 499x72. Two sprites either side, no transition frame.

Second, and larger: a third of the bundle is never drawn. Entry 11
declares three logo/glow pairs and only two appear.

  palogo_gamearts / _eff    95 / 22 frames
  palogo_seta     / _eff    95 / 22 frames
  palogo_anima    / _eff    never

palogo_anima declares the SAME keyframe times as palogo_gamearts. Two
elements with byte-identical data, 95 frames and 0 frames in one run.
Reach: the capture covers frames 1-214, so this is "never in the window".

So a bundle is a palette, not a script. Its elements say what to draw and
for how long; which of them run, and when each starts, is decided outside
the placement data. That is the same conclusion the boot-order work
reached from the other end -- the driver is code, not data -- now with a
per-element measurement behind it.

For the port, concretely: compositing every element of a bundle does not
reproduce what the game shows over time. It is the right thing for a
static screen that settles, and it is not a timeline.

METHOD: two elements with identical data and different outcomes is the
strongest possible evidence that the decision is elsewhere.
2026-08-29 04:47:05 +00:00
Sylpheed RE agent
7fb6bdfad8 re(ui): a group's duration is in the data, its start time is not
Tested whether the splash timeline, played, reproduces the capture --
the last gap in the animation model. Half of it does.

Durations match. At 2 units/frame under the shifted reading, from the
235-frame draw capture of the developer splash:

  glows  drawn f94-115  (22 frames = 44 units)  declared ~0..45  = 45   97.8%
  logos  drawn f116-211 (96 frames = 192 units) declared 15..210 = 195  98.5%

Each element is on screen for its declared span to within 2%.

Starts do not. Every glow declares the same times 15,30,45 and every
logo the same 15,30,190,194,206,210, so on one clock they would overlap
almost entirely -- and they do not overlap at all. The glows run 94-115
and the logos 116-211, strictly sequential, the logos starting the frame
after the glows end. Fitting one origin needs f0 ~ 93.5 for gamearts_eff
and ~103 for gamearts, about 19 units apart, and aligning one throws the
other off by ~9 frames at both ends.

The obvious candidate is refuted. parse_placements reads each group
header as (element index, keyframe count) plus one undecoded LEAD-IN
WORD -- exactly where a per-group start offset would live. It is
0x00000000 for all seven elements, glows and logos alike.

Reach: not the keyframe times (identical within each family), not that
word (zero), not declaration order (which interleaves logos and glows
where the observed sequencing groups them), not the RATC child order.
What remains is that the sequencing is code-driven, which agrees with
what the boot-order work concluded independently.

For the port: a group says how long an element animates and what it does,
not when it starts relative to its neighbours. The observed order on the
developer splash -- both glows, then both logos -- is measured for one
screen, not a decoded rule, so the sequencing has to be authored.

METHOD: when a model reproduces durations but not positions, the missing
piece is an origin, not a rate.
2026-08-29 04:42:55 +00:00
Sylpheed RE agent
1f2b469f7e re(ui): a static composite is only meaningful for a screen that settles
The model's sharpest prediction, tested with its control. The draw log
says that on the developer splash the _eff glows are drawn on frames
94-115 and the logos on 116-211, so at the moment the reference capture
was taken EVERY glow is already finished -- including the two that have
plateaus and which rest_plateau therefore renders visible. Suppressing
them should help on the splashes and hurt where a screen genuinely
settles.

  publisher splash   +0.9604 -> +0.9982   +0.0377
  developer splash   +0.9659 -> +0.9980   +0.0321
  title    (control) +0.9500 -> +0.9480   -0.0020
  main menu(control) +0.9460 -> +0.8544   -0.0916
  EXTRAS   (control) +0.9440 -> +0.8370   -0.1070

Both splashes jump to about 0.998; all three persistent screens get
worse. The control is what makes this a finding rather than a
coincidence: the same edit helps exactly where the model says it should
and hurts exactly where it says it should not.

So rest_plateau is not over-drawing in general -- it over-draws on
TRANSIENT screens. A plateau mid-animation means the element is held at
that point in the timeline, not that it is on screen once the screen has
settled. Where a screen settles, the held pose IS the settled pose and
the rule is measurably right.

And that answers the question left open several iterations ago -- what
"rest" means for a transient element. It does not mean anything: the
splashes never rest. A static composite of them can match a chosen frame,
and about 0.998 is what these captures' frame is worth, but the format
does not answer a question the screen never poses.

For the port: play the timeline for the two splashes, which the settled
keyframe timing now supports, and composite statically for title, main
menu and EXTRAS.

METHOD: an edit that improves one set of cases is only interesting once
you have shown it damages the cases where it should.
2026-08-29 04:37:59 +00:00
Sylpheed RE agent
458585d136 re(ui): the structural case for last -- 2 293 of 2 305, checked disc-wide
The weakness in the rest-rule finding was that `last` had been SCORED on
only two elements. It cannot be scored on more -- only two ambiguous
elements sit on a screen with a live capture -- but the entry -> hold ->
exit model makes a prediction that can be checked on all 2 305: what does
each element's FINAL keyframe look like?

  final keyframe invisible (a = 0)          1 618   transient: gone at rest
  final keyframe visible, at max alpha        675   faded in and stopped
  final keyframe visible, BELOW max alpha      12   genuinely unclear

Of the 687 that end visible, 472 have monotonically non-decreasing alpha
-- a plain fade-in that stops, [0, 255] over two keyframes in the
commonest case (pjex_eff.rat, pghud_speed_cut.t32) -- and another 203 end
at their maximum after dipping.

So `last` is structurally defensible for 2 293 of 2 305 (99.5 %), against
a dwell rule that returns a mid-movement frame by construction. Observed
correct for 2, structural for 675, model-consistent for 1 618, unclear
for 12.

The assumption carrying the 1 618 is stated rather than buried: that a
plateau-less element's animation has finished by the time the screen is
settled. The draw log establishes exactly this for the two splash glows
(drawn frames 94-115, logos 116-211) and establishes nothing for the
rest.

Default still unchanged. The case is now observational, structural and
model-based rather than two data points, but it would move 1 896 elements
and the decision belongs with whoever owns the renderer.
2026-08-29 04:34:21 +00:00
Sylpheed RE agent
4012d5b555 re(ui): why rest_plateau is right -- and last is right only for a transient
The shifted keyframe-time reading looked like it implied something
simple: the final pose is reached at a definite time and nothing follows,
so rest should just be the last keyframe and the plateau heuristic could
go. Tested by applying it to EVERY element:

  title              +0.9500 -> +0.6819   -0.2681
  main menu          +0.9460 -> +0.6416   -0.3044
  EXTRAS             +0.9440 -> +0.5745   -0.3695
  publisher splash   +0.9600 -> blank (zero variance, corr undefined)
  developer splash   +0.9643 -> blank

Refuted, and the failure supplies the model. A group is entry -> hold ->
exit, and the exit is the screen's DISMISSAL. While a screen is displayed
it has not reached its last keyframe; it is sitting at the hold. So
rest_plateau is the correct primary rule, and the last keyframe is the
post-exit state -- correct only once the screen is gone, which is why
applying it everywhere blanks the splashes.

This does not contradict the shifted reading. That reading says when each
pose is reached; it says nothing about the group being played to
completion while the screen is still up. The step between them was mine.

And it explains why last wins for the two plateau-less elements: an
element with no hold is a transient, it flashes and is over, and at any
settled moment it is gone -- which is its last keyframe. The draw capture
says the same independently: on the developer splash the _eff glows draw
on frames 94-115 and the logos on 116-211, so the glows are already
finished when the logos are up.

Three independent observables -- animation timing, static composites, and
the per-frame draw log -- now agree on one rule: plateau where there is
one, last keyframe where there is not.

METHOD: a blank render is a NaN correlation, not a low score, and that
NaN was the strongest form of the result; and when a model predicts
something the measurement refuses, suspect the step you supplied between
them.
2026-08-29 04:28:19 +00:00
Sylpheed RE agent
515456c59a re(ui): quantify what changing the rest rule would do disc-wide
The open question was whether "last keyframe" holds beyond the two
elements I could score against a capture. It cannot be scored disc-wide --
only two ambiguous elements sit on a screen with a live capture -- but the
blast radius can be measured, and it argues the same way.

  genuinely ambiguous elements                       2 305
    the two rules AGREE on                             409  (17.7 %)
    they DIFFER on                                   1 896  (82.3 %)

  dwell (current): invisible pose 1 711 (74.2 %), zero-scale 195 (8.5 %)
  last           : invisible pose 1 618 (70.2 %), zero-scale  43 (1.9 %)

Two things follow. It is not a marginal choice: the rules disagree on 82%
of the affected elements, so "either is fine" is not available. And the
current rule produces 4.5x more degenerate poses -- a zero-scale pose is
collapsed to nothing, i.e. an element's PRE-ROLL before it has grown in,
which is definitionally not a rest. 195 elements currently rest at a frame
they are only passing through, against 43 under last.

That is an argument from the data's own structure rather than from the two
captures, and it points the same direction.

Kept honest: it is indirect. Fewer degenerate results is not the same as
more correct results, and last still returns an invisible pose 70% of the
time -- right for a transient element, wrong for a persistent one. The
default stays put; the numbers are in HANDOFF for whoever decides.
2026-08-29 04:24:40 +00:00
Sylpheed RE agent
93e9b185ea re(ui): the rest fallback fires on 2 elements, and "last keyframe" wins there
Scored candidate rest-pose rules by rendering and correlating instead of
arguing, and both results correct something I had published.

First, the exposure. The guessing fallback is reached only by an element
that is plateau-less AND multi-keyframe -- a single-keyframe element
short-circuits at `match len { 1 => first }`. Per screen:

  title (4)               24 elements   2 plateau-less   0 reach the fallback
  main menu (5)           16            5                0
  EXTRAS (6)              18            5                0
  publisher splash (10)    3            2                1
  developer splash (11)    7            2                1

So on the three screens the port cares most about, rest() never guesses.
That is why three different rules render builds 4/5/6 to identical
correlations -- the code is unreachable there, which I nearly read as
"the choice does not matter".

Second, where it does fire, the last keyframe is markedly better:

  publisher splash   dwell +0.9600   last +0.9982   maxalpha +0.9600
  developer splash   dwell +0.9643   last +0.9758   maxalpha +0.9643

That refutes my own earlier refutation. I had killed the last-keyframe
rule by arguing it makes palogo_anima_eff invisible while its two
siblings stay lit, which looked like an artefact. The capture says
otherwise: making it invisible is what improves the match. The sibling
symmetry was my expectation, not evidence.

Caveat kept in front: both captures are single frames of a transient
animation, so this fixes which pose matches THOSE frames, not which is
canonically at rest. Default unchanged -- better on both screens where it
fires and identical on the other three, but it would move 2 305 elements
disc-wide on two measurements. Reachable via SYLPHEED_REST_RULE=last.

Also confirmed: all 195 zero-scale rest poses are inside the corrected
2 305 ambiguous population; none is a single-keyframe element.

METHOD: score a rule where it can differ, or you measure nothing; and an
argument from symmetry is a prediction, not a refutation.
2026-08-29 04:20:27 +00:00
Sylpheed RE agent
0265da31a1 re(ui): refute my own fix for rest(), and correct the defect rate by 65%
Two corrections from one experiment.

A keyframe group is entry -> hold -> exit, and the exit ends invisible:
on the five port screens the final keyframe is invisible for 21/24
(title), 8/16 (main menu), 12/18 (EXTRAS), 2/3 and 6/7 (splashes). So the
screen as seen is the HOLD, which is why rest_plateau is the right
primary rule and why "rest = last keyframe" would empty every screen.

That suggested a fix: an element with no hold has no representative pose,
so draw nothing rather than guess an endpoint. Tested through compose's
visible mask and correlated against the live captures:

  title       +0.9500 -> +0.6839   -0.2661
  main menu   +0.9460 -> +0.9037   -0.0423
  EXTRAS      +0.9440 -> +0.9094   -0.0346

Refuted on all three, and the reason invalidates a number I published. An
element with a SINGLE keyframe has no adjacent pair, so the plateau test
marks it plateau-less -- but its one pose is unambiguously its rest.
Suppressing those removes backgrounds and full-screen layers, which is
the title's -0.27.

  no plateau (as published)      3 807  (24.57 %)
    ... single-keyframe          1 502  trivially at rest, not a guess
    genuinely ambiguous          2 305  (14.88 %)

So rest() guesses for 2 305 elements, not 3 807 -- the figure I gave the
port overstated the defect by 65%. Corrected in HANDOFF and the page.

METHOD: a predicate over adjacent PAIRS silently misclassifies a
one-element list; and acting on a claim is a better test of it than
re-reading it -- this flaw survived a census, a write-up and a handoff
row, and died the moment the rule was used to change a rendering.
2026-08-29 04:15:58 +00:00
Sylpheed RE agent
60285a6ead re(ui): replicate the keyframe-time shift -- three elements, two screens
The case for reading +36 as "the time the NEXT pose is reached" rested on
one element's fade-out shape, then on one element's hold duration. Both
splash halves supply more, and they agree.

  element            screen              observed hold  as decoded  shifted
  palogo_gamearts    developer splash        83 f          8 f       80 f
  palogo_seta        developer splash        83 f          6 f       80 f
  palogo_sqex        publisher splash       >=77 f *       6 f      102 f

  * the capture opens mid-hold at frame 1, so 77 is a floor.

The readings predict opposite structures. For palogo_gamearts, as decoded:
hold 8f, in 80f, hold 2f, out 6f, out 2f -- an eighty-frame FADE-IN and a
two-frame hold. Shifted: in 8f, hold 80f, out 2f, out 6f, out 2f. The
capture shows an 83-frame hold and no fade-in at all.

The elements that cannot discriminate are not contradicted:
palogo_gamearts_eff observed in 7f / hold 7f / out 8f, and both readings
give 8f phases -- with four blocks the shift only relabels which phase is
which. So the glows, which is where Q1's linear law was measured, say
nothing either way rather than arguing against.

The decoder's default is still unchanged, and the reason is now
articulated rather than assumed. The single thing opposing the shift is
rest() on ptlogo_eff3, where the shifted reading makes the longest-dwell
fallback return the bloom's 200% peak. That fallback is unsound whenever
it runs -- it returns an endpoint of a movement, neither of which is held
-- and checked: the shift does not fix it either. So the objection was
never evidence about the times. Timing had three discriminating
measurements; pose selection had a heuristic guessing.

For the port: animation timing should use the shift; static composites are
unaffected and the five screens' correlations stand. Classified measured,
not decoded -- three elements in one screen family, not a disc-wide check.
2026-08-29 04:09:36 +00:00
Sylpheed RE agent
fb6376a548 docs: one page saying how good the five screens actually are
The answers for the port's five screens were spread across a dozen
documents and none of them said how good the result IS. Measured:

  screen              build  drawn   corr vs capture  alignment
  title                 4    15/24      +0.9500       dy=0 dx=0
  main menu             5    11/16      +0.9460       dy=0 dx=0
  EXTRAS                6    13/18      +0.9440       dy=0 dx=0
  publisher splash     10     2/3       +0.9600       dy=0 dx=0
  developer splash     11     6/7       +0.9643       dy=0 dx=0

Every one aligns at exactly zero offset over a +/-2 px search in both
axes, so placement and scale are right and the residual is tone and
detail rather than geometry.

The drawn/total counts are not slack. Each undrawn element has a reason
already documented: kind & 0x4 ghost instances (4 on the title), .prm
primitives off by default (2 per menu, 1 per splash), loop* animations
off by default and off-screen at rest (2 per menu screen), and the 8AX
name mismatch (1 per menu screen) whose art reaches the screen anyway via
ptbase. 4+2+2+1 = 9, 2+2+1 = 5, 1. Nothing unexplained.

The residual is ranked for a consumer: tone first (gamma 1.34-1.49, the
game's own display ramp), then 8AX resolution, then one drawable
paint-order tie on EXTRAS alone, then rotation-decoded-but-not-rendered
which does not affect these five at rest.

Reach stated: these are static composites at the resting pose against
single frames, so nothing here speaks to animation, and a whole-frame
correlation is a sanity figure rather than a per-element check.
2026-08-29 04:04:15 +00:00
Sylpheed RE agent
0efd692b4c docs: the suite is heavy, not hung -- correcting my own "cannot terminate"
Last iteration I wrote that build-reborn test cannot finish in a working
session, from having watched it run 3h26m. That was the stronger claim
and I made it without measuring the work.

Timing `mesh info` on each of the 166 .xpr containers with a 25 s cap:

  files scanned                166
  exceeding 25 s                19   Hangar, 17 Stage_*, ptc_pack
  Stage_S02 to completion      144 s, rc = 0

Nothing hangs. Nineteen heavy containers at roughly two minutes each is
about 45-60 minutes for one pass, before the 147 fast ones. The 3h26m
observed was that hour of work running at a load average of 9-14 --
inflated by the two duplicate runs I had left going, which did not merely
coexist with the slowness but multiplied it.

The practical conclusion is unchanged and only the wording softens: an
hour-scale suite is not an iteration-scale gate, and every "green" I
reported from it this session was partial. But an hour-scale gate can be
run deliberately, whereas a hung one cannot be run at all, so the
distinction is worth having right.

File list committed as reference data so the cost is attributable without
re-scanning.

METHOD: a slow thing observed under contention looks like a stuck thing;
measure the work before choosing between "cannot finish" and "takes an
hour".
2026-08-29 04:02:34 +00:00
Sylpheed RE agent
145046f88f docs: the verification gate cannot terminate, and I left two runs going 4h
Two findings from checking whether last iteration's partial green had
finished. It had not, and the reason matters for anyone using the gate.

build-reborn test contains twin_pairs_do_not_share_a_buffer, which
decodes every .xpr in hidden/resource3d -- 166 files, 1.4 GB -- through
the full Xbg7Model anchoring path, and is NOT #[ignore]d. Its sibling in
the same file walks the same 166 files and IS ignored as known-failing,
which makes the binary's cost easy to underestimate. Measured: one
instance accumulated 3h26m of CPU at 89% without finishing.

So every "green run" reported in this corpus from a workspace or
sylpheed-formats test is necessarily PARTIAL unless it says the suite
terminated -- including the ones I reported this session. The honest form
is the suite count and elapsed state, not the word "green". Not proposing
to #[ignore] or subsample it: that changes what the suite asserts and is
the project's call, not an audit side-effect.

And the mess is mine. Two cargo test -p sylpheed-formats runs launched
detached in earlier iterations never exited, because they were sitting in
that test:

  pid 103375  4h12m elapsed  child mesh_consistency_disc  3h26m CPU  89.3%
  pid  99965  4h39m elapsed  child pak_idxd_disc          1h16m CPU  93.8%

Load average 14.18 on 12 cores. Killed, after checking the legitimately
running workspace suite and leaving it alone; load fell to 9.68.

What this does NOT explain, because it is tempting: the session's
emulator troubles. screenshot cost 0.49 s with both runaways live and the
emulator stopped, against 10.8 s measured earlier with the emulator
running. The 92x figure really was emulator contention; the runaways were
a background tax on top. The black surface and the unreachable title
stand as measured, with their own controls.

METHOD: a detached job you never check can outlive many iterations --
setsid was added so a timeout could not kill them, which also means
nothing does; and know whether your verification gate can terminate.
2026-08-29 03:45:14 +00:00
Sylpheed RE agent
6d9214fd07 docs: check that the docs' headline figures match their committed data
Nothing had ever verified that a number written in prose matches the
reference data file committed beside it. The figure is written once from
a run; the prose is edited around it afterwards and the data file is
regenerated independently, so drift is silent.

All 19 headline figures across four censuses -- the eff-bit census, the
plateau census, the top-level rotation census and the eff-bit alpha test
-- currently agree with their data files.

The checker had to be numeric, and the first attempt is the reason it is
a script rather than a grep: comparing strings reported almost every
figure as a mismatch, because the data files write 14709 where the docs
write "14 709" with a thin space, and the docs round 33.66 to 33.7. A
consistency check that fails on formatting trains you to ignore it, so
the tolerance is explicit: exact against the data, within 0.05 against
the doc to allow rounding.

Also ran the full disc-gated workspace suite (build-reborn test, which
wires SYLPHEED_DISC -- without it the disc tests self-skip and green
means almost nothing), covering this session's three decoder changes:
rotation_deg on Keyframe, the scale-0 fix in blit/fill_quad, and the
flags field on T8adImage. 122 passed / 0 failed across the four suites
that had completed; the long disc-gated integration tests
(records_roundtrip_disc, first_header_word_is_record0_hash) were still
running and are not counted here.
2026-08-29 03:42:13 +00:00
Sylpheed RE agent
714a26769d re(ui): premultiplied alpha refuted for bit 0x02; parking the field
A per-sprite premultiplied-vs-straight-alpha flag would matter a lot to a
port and has a sharp static signature: premultiplied means RGB <= A
everywhere. Over the 170 decoded GP_TITLE textures that pair to a flag
word:

  bit SET    n= 61   mean %(RGB>A) 55.52   median 52.52
  bit clear  n=109   mean %(RGB>A) 33.66   median 30.17

Premultiplied requires ~0% for the flagged group. Both groups are far
from it and the flagged group violates MORE -- the opposite of the
hypothesis. Refuted.

What remains is a weak association: flagged sprites carry more
bright-RGB/low-alpha pixels, which is what glow art looks like. But the
best single threshold classifies 76.5% against a 64.1% base rate -- a
12-point lift with badly overlapping distributions. A tendency, not a
rule, and reported with its base rate so it cannot read as more.

Noted for whoever returns: "0x02 selects an additive blend" was refuted
by blending those sprites additively and finding every measure worse
against the capture -- but that ran through a title render since fixed
twice (rest_plateau, and the 8AX background the composer drops). The
refutation may well stand; it was measured through a renderer with known
other errors, so it is worth one re-run if blit ever gains additive
blending.

Parking the field. Four candidate meanings are dead -- additive blend,
eff name in both directions, transient element, premultiplied alpha --
none produced a positive account, and the bit blocks nothing: the port's
screens composite at 0.947 correlation against a capture without it. The
negative space and the sound attribution method (child order, not size)
are written down so a later attempt starts here.

METHOD: report a classifier's lift over its base rate; and park a field
after N failed hypotheses, saying what was eliminated.
2026-08-29 03:23:40 +00:00
Sylpheed RE agent
489ea12759 re(ui): the eff-name implication for bit 0x02 is refuted disc-wide
Last iteration I killed the biconditional and reported that the one-way
reading survived: all 10 bit-set sprites on GP_TITLE build 4 are eff
names, so "bit set => eff name". Checked over the disc, that is false.

  sprites with a resolvable preceding name   14 709
    bit SET   & name has 'eff'                2 338
    bit SET   & name lacks 'eff'              2 657   <-- counterexamples
    bit clear & name has 'eff'                1 399
    bit clear & name lacks 'eff'              8 315

  P(eff | set)   = 0.468
  P(eff | clear) = 0.144

The implication fails more often than it holds. What survives is an
association -- 3.3x enrichment -- and build 4's 10/10 was a local naming
habit in an 18-element bundle, not a format rule.

The counterexamples are the useful part: pv_loading_ring0,
pv_loading_light0-3, pv_loading_line, px_bunk_line, px_top_extra. Rings,
glows, lights, thin lines -- effect-like artwork that does not carry the
eff naming convention. Consistent with the bit marking effect sprites by
authoring intent rather than by name, which is a description and not a
decode, and is labelled as such.

Names here come from the string immediately preceding each T8aD,
validated 17/18 on build 4 against the RATC child order; the single
mismatch is the known pteff04.t32 -> registered as 8AX case, so this is
the element (opt) name rather than the sprite's registered name. That
mismatch is itself an independent confirmation of the 8AX finding,
reached from the opposite direction.

METHOD: a pattern perfect on one screen can be near-chance on the disc;
and when an association survives a refuted implication, the
counterexamples are the finding.
2026-08-29 03:20:24 +00:00
Sylpheed RE agent
5f701db588 re(ui): kill two candidate meanings for the T8aD 0x02 bit, and fix attribution
The bit at +0x04 was recorded as a real field with its meaning "not
diagnosed", noting ptlogo_back2eff is 0x8830 "despite its name". That note
rested on a size match -- and its size is ambiguous, which is the trap
this corpus already records.

First, a sound attribution. T8aD headers appear in the bundle in RATC
CHILD ORDER, verified on GP_TITLE build 4 against an independent property
-- each header's decoded dimensions versus the dimensions the named child
should have: 18 of 18 match, 0 mismatches. Two of those eighteen share a
size (ptlogo_back2eff and ptlogo_back2eff5, both 1133x280), so a size-keyed
lookup cannot separate them; ordering can. Index 12 is back2eff5 (0x8832,
bit set), index 14 is back2eff (0x8830, bit clear). The documented
counterexample is real and correctly attributed -- now on evidence.

Two candidate meanings tested and refuted:

  bit <=> name contains "eff"        REFUTED: ptlogo_back2eff is an eff
                                     name with the bit clear. All 10
                                     bit-set sprites are eff names, so
                                     the implication holds one way only.
  bit <=> the element is transient   REFUTED: pteff03/pteff03a carry the
                                     bit and run to t=250, ramping to
                                     a=255 and holding.

Up close, the exception pair differs in two header words: +0x04
0x8832/0x8830 and +0x08 0x8083/0x8081 -- layer keys 32899 and 32897. They
are NOT duplicates: their alpha summaries agree to one decimal (4.5%
opaque, 86.7% clear, mean 19.3) but a pixel compare gives max abs diff 21.
Two renditions of one image at one size, which is why the summaries were
not trusted.

Still not diagnosed, and said so -- but the search space is two smaller
and the attribution beneath it is now sound.

METHOD: T8aD headers sit in child order, use that not the size; and
identical summary statistics are not identical data.
2026-08-29 03:14:40 +00:00
Sylpheed RE agent
c463f98164 re: the GPU trace is compiled out of the release build -- both my guesses wrong
Last iteration left two candidates for why trace_gpu_stream produced no
file: the CLI flag not reaching the cvar, or BeginTracing failing
silently. Neither. Following the code instead of guessing:

BeginTracing only sets trace_state_ = kStreaming ("Streaming starts on
the next primary buffer execute"). The file is opened later, in
ExecutePrimaryBuffer, inside

  #if XE_ENABLE_TRACE_WRITER_INSTRUMENTATION == 1

and trace_writer.h defines that as 0 under NDEBUG, 1 otherwise -- the
trace writer exists only in debug builds.

Confirmed against the binaries, with a control. The format string
"{:08X}_stream.xtr" lives only inside that guard:

  build/bin/Linux/Release/xenia_canary          0 occurrences
  build/bin/Linux/Debug/xenia_canary            1 occurrence
  /sylph-home/re/canary-build/.../Release/...   0   <- what run-canary uses

The debug binary is the control: it proves the test finds the string when
it is present, so the release zero means something.

So trace_gpu_stream is a no-op in this container's emulator -- the cvar
parses, BeginTracing runs, and nothing can open a file. The kill -9 was
not the cause either, though it would have destroyed a trace had one
existed.

The route exists but is not cheap: a debug build with the writer compiled
in sits at build/bin/Linux/Debug/xenia_canary, 253 MB against Release's
18 MB, so a much slower boot plus a trace of every GPU packet on a disk
at 95%. Recorded as available rather than attempted -- what it would
confirm, the DC_LUT write, is already a well-supported inference, and the
cost is out of proportion to the gain.

METHOD: a cvar existing does not mean the feature is compiled in; and
test a compile-time gate against the binary, with a control.
2026-08-29 03:08:57 +00:00
Sylpheed RE agent
921a3cfc44 re: GPU trace attempt produced nothing -- and the config dump is not the flags
Tried to turn the gamma-ramp inference into a direct observation.
canary's trace_gpu_stream records gamma ramps as their own command type
(kGammaRamp, index 11 in TraceCommandType), so a boot trace should show
the write. Two bounded runs produced NO trace file at all -- nothing under
the prefix, no .xtr anywhere, no scratch/gpu/.

Bounded deliberately: the disk is at 95% (50 GiB free) and a trace of all
GPU packets during boot includes video decode, so the runner carried its
own watchdog that killed the emulator the moment output passed a 2 GiB
cap. It never fired -- there was nothing to cap -- and disk stayed at 95%
throughout. Bounding from inside cost nothing and removed any need to
gamble on how coarsely I could poll.

What the attempt did establish. BeginTracing() runs at GPU init when the
cvar is set (graphics_system.cc:237), but EndTracing() runs only from
GraphicsSystem::Shutdown() -- so the kill -9 this session has used
routinely can never finalise a trace. The second run was stopped with
SIGTERM and exited cleanly; still no file, so that is not the whole
story. Two candidates remain unseparated: the CLI flag not reaching the
cvar, or BeginTracing failing silently. The next run removes the
ambiguity by setting trace_gpu_stream in the config FILE instead.

And a trap I nearly fell into. The startup config dump showed
trace_gpu_stream = false after I passed --trace_gpu_stream=true, which
reads as "flag ignored". It is not evidence either way: the gamma run
passed --log_mask=12 --log_level=3, its dump printed log_mask = 0 and
log_level = 2, and Kernel Debug logging was demonstrably ON -- that run
is where VdGetCurrentDisplayGamma was captured. The dump reflects the
config file and can neither confirm nor refute a command-line override.
(It does not undo the earlier user_language conclusion: absence of a NAME
from the dump still shows a cvar is unregistered.)

The gamma-ramp write therefore remains an inference, unchanged.
2026-08-29 03:05:03 +00:00
Sylpheed RE agent
421c61a517 re(ui): close the gamma chain from canary's defaults -- the game writes a ramp
Continues the previous iteration, where the game was measured calling
VdGetCurrentDisplayGamma at video init. The remaining link -- does it then
WRITE the ramp -- is a GPU register operation (XE_GPU_REG_DC_LUT_RW_INDEX
in CommandProcessor::WriteRegister), unlogged and invisible to kernel
logging. Two facts from the source close it without instrumenting.

1. The swap-path gamma stage is a PURE LUT. apply_gamma_table.xesli is
   the whole transform: index by input*255, fetch from a 256-entry ramp
   buffer, output. No sRGB encode, no second transfer function.

2. The table DEFAULTS TO IDENTITY. CommandProcessor::Initialize fills it
   with value = i * 0x3FF / 0xFF, and its own comment says the linear
   default is "what games set when starting with the sRGB (return value 1)
   VdGetCurrentDisplayGamma". An unwritten ramp is a no-op.

So the only transform is a LUT, the LUT is identity unless written, the
game queries the display gamma at init, and the capture differs from our
composite by gamma 1.34-1.49 -- which identity cannot produce. The guest
wrote a non-identity ramp.

Labelled an inference, with its weak joint named: it assumes our
composite reproduces the PRE-RAMP framebuffer, which it does not exactly.
What carries it is the shape -- a systematic ~1.4 fitted on flat patches
across three screens is not a compositor bug. The obvious alternative, a
fixed sRGB stage in the presenter, fits neither direction: an encode
(^0.45) brightens and we measured darkening; a decode (^2.2) darkens far
more than 1.4.

Direct observation remains available and cheap, and needs the emulator
only to boot: a GPU trace records gamma ramps as their own command type,
or one log line at the DC_LUT register write would settle it outright.
Not done.

METHOD: a default value is evidence; and name the weak joint of an
inference in the same breath as the conclusion.
2026-08-29 02:53:45 +00:00
Sylpheed RE agent
c6ce000002 re(ui): the game does query the display gamma -- measured, with its control
Last iteration's corrected experiment, run. Boot with --log_mask=12
--log_level=3 (Kernel logging on, Cpu/Gpu off), which changes nothing
about the output and so cannot perturb the capture harness the way the
gamma-cvar experiment would have.

VdGetCurrentDisplayGamma is called once, at video init:

  d> VdGetSystemCommandBuffer(701CF830, 701CF804)
  d> VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))
  d> VdSetDisplayMode(40000000)
  d> VdGetCurrentDisplayInformation(701CF110)

The control is in the same log: 359 VdRetrainEDRAM and 358
VdGetSystemCommandBuffer lines, so an absent call would have been
visible. Per the export's own comment the returned type is "used in D3D
SetGammaRamp/SetPWLGamma" -- the game asks the question a ramp-builder
asks, at the moment one would ask it.

Still open, and stated: whether it then WRITES the ramp, and whether the
measured gamma 1.34-1.49 is that ramp. The write is a GPU register
operation (DC_LUT), invisible to kernel logging; a GPU trace records
gamma ramps as a command type (TraceWriter::WriteGammaRamp), which is
where to look next.

Worth its own METHOD line: this had been parked behind "needs the
emulator to reach a menu" for several iterations, and it needed the
emulator only to BOOT -- video init happens in the first seconds. A
blocker that stops one experiment does not stop every experiment in the
same area.
2026-08-29 02:51:32 +00:00
Sylpheed RE agent
93b0a6b20f re(ui): the gamma confound is refuted from source -- canary applies none of its own
I had parked the tone-curve finding behind "this may be the emulator, not
the game: canary applies kernel_display_gamma_type = 2 (BT.709) on
output", with a planned run setting it to 0 and re-fitting. Reading the
source kills both the confound and the experiment.

VdGetCurrentDisplayGamma_entry is a kStub GETTER the guest calls
(xboxkrnl_video.cc). Its own comment: "Used in D3D SetGammaRamp/
SetPWLGamma to adjust the ramp for the display." The cvar is a value
REPORTED TO THE GAME, which then builds its own ramp. Canary's role is
downstream: the guest writes DC_LUT, command_processor.cc reads it into
gamma_ramp_256_entry_table_, and the swap path applies it via
swap_apply_gamma_pipeline_layout with apply_gamma_table.ps /
apply_gamma_pwl.ps compiled in.

So there is no emulator-side BT.709 post-process to subtract, and any
gamma in a captured frame is a ramp the game installed.

What is NOT established, and the reach is stated: that this game installs
a ramp at all, or that the measured 1.34-1.49 is it. The run logs cannot
say -- kernel exports log at Debug and this harness masks Kernel logging
(log_mask = 13, per boot_menu.sh's own comment), so their silence is
guaranteed regardless of what the game did.

The planned experiment was wrong in design: changing the cvar changes
what the GUEST is told and therefore which ramp the GAME builds, so it
could never isolate a stage that does not exist -- and it perturbs the
capture harness, since skip_intro classifies movie-vs-static on an
absolute rmse threshold that a brighter frame biases. The right run
changes nothing about the output: LOG_MASK=12 LOG_LEVEL=3 and look for
the call and the DC_LUT writes.

Also for the port: the ramp depends on the display type the game is told,
and canary hard-codes TV/BT.709 where hardware uses a console setting. So
this is a display profile, not a fixed property of the game.

METHOD: read what a cvar does before building an experiment around it;
and an absence in a log is only evidence if the log would have shown it.
2026-08-29 02:44:30 +00:00
Sylpheed RE agent
3490fba9e3 re(ui): settle 8AX vs ptbase statically -- the game draws the full-res one
I had parked this as "needs a per-draw capture recording texture base
addresses". It did not. 8AX (1280x720) and ptbase (640x360 at 200%) are
the SAME artwork at two resolutions, which is exactly why comparing
either against a capture is inconclusive -- and why comparing their
DIFFERENCE is not. Compute 8AX - upscale(ptbase), the detail only 8AX
has, and ask whether the capture contains it.

Both candidates are first mapped into the capture's tone domain with the
measured gamma; without that the residual is dominated by the tone
difference and the test is blind.

  main menu  corr +0.0475   controls +0.0032 shift, -0.0075 flip   68% of ceiling
  title      corr +0.0634   controls +0.0095 shift, +0.0086 flip   68% of ceiling

Two independent screens, both at 68% of the theoretical ceiling
(sd of the 8AX-only detail over sd of the capture residual), 7-15x their
matched controls. The controls preserve spatial correlation and destroy
only alignment, so they are what "no signal" looks like.

So the recommendation changes: resolve the name and draw 8AX at 1:1.
Upscaling ptbase 2x is wrong, not merely softer. Still do not draw both --
an opaque layer over an identical one costs fill and hides later changes,
and ptbase's element is the one carrying the keyframes, so a consumer
needs its timing with 8AX's pixels.

Also recorded and withdrawn: a cruder pixel-pair test gave 0.00-0.72 for
upscales, 0.98 native and 1.01 for the capture -- apparently decisive.
Additive noise raises both terms of that ratio equally and drives any
value toward 1; fitting a noise term, both "native + noise" and "bilinear
+ noise" reproduce the observed numbers. The conclusion is right, that
test does not establish it, and it is in REFUTED because the number looks
conclusive and is not.

Not shown: whether ptbase is also drawn underneath. 8AX is ~86% opaque
and carries the same art, so it would hide it either way.
2026-08-29 02:40:53 +00:00
Sylpheed RE agent
aaaa08b164 docs: the UI decode's own evidence images were unreachable -- 11 links repaired
The brief's rule is to commit reference data beside the finding so the
port can be built without a disc. Nothing had ever checked that the docs'
cited artifacts actually exist. doc_link_check.py walks every markdown
file under docs/, resolves each relative link, and reports targets that
are missing -- and separately targets that resolve to a ZERO-BYTE file,
which looks fine in any listing.

  links resolving   1038 -> 1049
  missing targets     16 -> 5
  empty targets        0 -> 0

+11 resolving and -11 missing against 11 edits: the counts pair, which is
the confirmation the pass did what it claimed and touched nothing else.

Two of the sixteen were the evidence for the UI layout decode itself.
structures/ui-rat-layout.md is what the port is built on, and its two
figures -- backing "the tutorial PAUSE menu rebuilds pixel-accurately
from its sprites" and "the same method reproduces the main menu" -- were
written as captures/ui-layout/... from a file in structures/, one
directory too shallow. The headline evidence for the decode could not be
opened from its own document.

Eleven links had the wrong relative depth with the target present. Each
was rewritten only where exactly one candidate path resolved, so nothing
was guessed; the first pass left three alone because equivalent spellings
(captures/../captures/x) failed to collapse, and a second pass normalised
them.

Five remain genuinely absent and are left rather than invented: two point
at MEMORY.md outside the repo, one at a header in the separate
xenia-canary-native tree, and two name documents that were never written
(weapon-datasheet-runtime.md, canary-build-verified-env-confound.md).
None is port-relevant. A missing document is a different problem from a
bad path and is not something a link fix should paper over.
2026-08-29 02:34:16 +00:00
Sylpheed RE agent
7373035868 re: the port was still being told SE audio is undecodable -- it is not
A resolve-check on HANDOFF's own rows. Q8 read "SE audio is undecodable
from the disc -- no XACT container exists anywhere". menu-audio-cues.md
retracted exactly that ("### Retracting 'cannot be extracted'") and
locates three cues in Static.slb that decode to PCM: d-pad move 0x1ec0
(4 packets), (B) back 0x0ec0 (2), (A) confirm 0x5d6c0 (6), all mono
48 kHz. The retraction landed in docs/re/ and the page the port reads
kept the superseded text -- the fourth time in this corpus.

Writing the rule down has not worked, so there is a tool now.
handoff_lint.py flags every HANDOFF line making a strong negative claim
that links a doc containing retraction language. First run: found the Q8
row, plus one benign false positive (Q3 links a doc whose retraction is
about a sprite count, not about the tie-break -- checked, and HANDOFF
repeats none of the retracted figures). The lint also caught its own bug
first: it reported existing docs as missing because it joined a guessed
repo root, so it now resolves links relative to the file as markdown does.

Separately, EXTRAS's paint-order risk narrows twice more. Of its 15 tied
pairs only 2 overlap, and of those, ptloop01 x ptloop02 are loop*
animations compose skips by default -- so exactly ONE tie can be drawn:
ptframe3 x ptframe4, overlapping 102x132 px. Against live-extras.png that
contested region correlates +0.9622, better than the whole frame (+0.9440)
and inside the range of regions where order cannot matter (+0.8502 /
+0.9903). Consistent with our order, not proof: correlation cannot see a
swap between locally similar art.

15 -> 2 -> 1 -> consistent is now the whole paint-order risk on the five
screens, and HANDOFF says so.
2026-08-29 02:31:37 +00:00
Sylpheed RE agent
f7f9b555f6 re(ui): the paint-order tie-break is undecodable from the bundle
Q3 was delivered as "decoded: a u16 layer key at +0x0A". The audit last
iteration showed the key does not fully order a screen -- elements
sharing a key are tied, and on the title that tie-break decides two total
occlusions. This searches for what breaks the tie, and closes it as a
negative with reach.

The game paints the five tied ptlogo_back2eff glows in the order
eff1, eff2, eff5, eff3, eff4. Three static structures were searched:

1. The declaration table. Entries 14-18 are byte-identical apart from the
   pivot, which is only half the sprite's own size.

2. The T8aD headers. All five carry identical +0x04 (0x8832) and
   identical +0x08/+0x0A (32899, the key itself), differing only in
   position and tile count. Searched exhaustively -- every offset
   0x00-0x7f, u8/u16/u32, ascending and descending:

     fields sorting to the MEASURED order:              0
     fields sorting to the DECLARATION order (control): 64

   The control is the point: 64 fields can be found that reproduce a
   known ordering, so the scan finds ordering fields when they exist. It
   finds none for the order the game uses.

3. The RATC child order -- a genuinely different permutation on other
   screens -- gives eff1..eff5 here, declaration order again.

All three static orderings give eff1..eff5; the game gives eff1,2,5,3,4.
That agrees with ui-screen-runtime's conclusion from the other direction:
the game builds a reordered child list at load time and paints that.

Q3 now reads honestly: the layer key is decoded and orders 4 of the 5
measured bundles exactly; the tie-break within a key is undecodable, and
a consumer must use a measured order or accept declaration index as an
arbitrary stand-in. The port's exposure remains 2 overlapping tied pairs
on EXTRAS.

METHOD: an exhaustive field search needs a positive control, or "found
nothing" is worthless.
2026-08-29 02:26:19 +00:00
Sylpheed RE agent
ba47bdebe8 re(ui): measure the paint-order hedge -- exact on 4 of 5, and bound the rest
`compose` claimed the derived paint order "reproduces both measured
orders up to ties". That sentence was never measured and was stale by
one: there are three measured orders, not two. examples/paint_order_audit.rs
checks it.

  main menu (entries 5, 8)      derived == measured   0 inverted pairs
  developer splash (11, 14)     derived == measured   0 inverted pairs
  title (entry 4)               DIFFERS               8, all same-key ties

So the claim holds and the exception is entirely ties -- but two of those
ties are total occlusions, not near-misses. The tied family is the five
ptlogo_back2eff glows (key 32899); back2eff5 is 1133x280 and FULLY
CONTAINS back2eff3 (82,824 px^2 = 100% of the smaller) and back2eff4
(152,047 px^2 = 100%). Derived paints it on top of two glows it entirely
covers; the game paints it underneath. A tie-break by declaration index
can therefore be wrong by a whole layer. The title itself is unaffected --
it has a measured order.

The port's actual exposure, per screen: title, main menu and developer
splash all use MEASURED orders; the publisher splash is derived but has
ZERO ties, so it is fully determined; EXTRAS is derived with 15 tied
pairs of which only 2 OVERLAP. Two element pairs on one screen is the
whole risk, and that is what HANDOFF now says -- not the raw 15, which
would have overstated it 7x.

Reach stated: this compares the derived order against orders measured
from the game, not an independent derivation, so where no measured order
exists only the tie exposure can be checked. Overlap uses pivot*2 as the
element size at its resting placement.

Stale comment in compose corrected. METHOD: a hedge in a code comment is
an unmeasured claim; and count the cases that can bite, not the ones that
match the pattern.
2026-08-29 02:22:01 +00:00
Sylpheed RE agent
c3cf3c2e81 re: a title negative that survives its own cross-check
Three earlier "the title never appears" claims came from instruments
later found broken -- a stale pixel oracle, a 41 s sampling interval, a
freezing stream. This one carries its own evidence.

title_probe_xchecked.py restarts its capture stream every 30 s AND prints
its reading beside an independent `import` grab every 60 s:

  1851 frames in 560.2 s = 3.30 fps
  cross-checks 9, disagreements 1
  max glyph 0

  t= 62s stream   6.05 | import   0.07  disagree (a fade, logos mid-transition)
  t=123s stream   7.40 | import   7.49  agree
  t=183s stream   8.18 | import   8.29  agree
  t=243s stream   0.23 | import   0.10  agree
  t=311s stream  89.68 | import  89.51  agree
  t=371s stream  80.97 | import  81.58  agree
  t=426s stream 117.43 | import 117.72  agree
  t=487s stream  77.71 | import  76.25  agree
  t=546s stream  70.43 | import  70.55  agree

Eight of nine agree within 2%, fps held at 3.30 with no collapse to 1.60,
and the surface moved through dark and bright phases. So the frames were
live: over 560 continuous seconds from launch, sampled 3.3 times a
second, the interactive title's green (A) plate never appears while the
game renders throughout. The final frame correlates 0.0145 / -0.0047 /
0.0102 with our title / main menu / EXTRAS renders -- attract-movie
content, not a UI screen.

Why remains unknown. live-title-press-a.png with its 753 glyph pixels
proves the title was reachable from this container on 2026-08-28, and
clearing the shader cache fixed the black surface but not this.

The two emulator-side questions (gamma control, 8AX vs ptbase) are
therefore blocked on a characterised failure rather than a suspicion.
Neither blocks the five menu screens, so I am returning to static work;
the probe is committed for whoever picks it up.

METHOD: a probe that cross-checks itself turns "no result" into a result.
2026-08-29 02:14:21 +00:00
Sylpheed RE agent
46006406a4 re: the fast probe stalls -- its own dense negatives are withdrawn
Cross-checked the instrument built last iteration against an independent
grabber while both watched the same screen, and it fails.

A single long-lived ffmpeg x11grab stream degrades and then freezes:

  862 frames in 540.1 s = 1.60 fps        (it starts at 3.98)
  t=450/480/510/540 s: surface mean 5.21, identical every time

At that same moment `import` read surface mean 125.65, and a freshly
started ffmpeg stream read 122.43 -- agreeing with import to 3%. So the
acquisition was broken, not the analysis: the stream replayed a stale
frame while the screen was 24x brighter.

That withdraws last iteration's headline. "2391 frames over 600 s from
t=0, max glyph 0" cannot distinguish "the title never appeared" from "the
stream froze early and repeated one frame 2391 times". Its 3.98 fps was
measured over the first 20 s, before the degradation. Sample count is not
coverage unless the samples are known independent.

Fixed: the stream is now torn down and restarted every 30 s. Startup is
~0.3 s, cheap against the title's window, and it guarantees live frames.

Separately, the cache hypothesis was tested and is SUPPORTED. cache,
cache0, cache1, cache_host moved aside (to /tmp/xenia-cache-aside, not
deleted) and the surface renders again: import reads mean 54.8 and 68.6
with 100% non-black warm content, against 0.07 and 0.08% non-black in the
black run; 773 of 862 probe frames had >2% non-black. One run each side
and many kill -9s before the black one, so it is supported, not proven --
the old caches are kept for reproduction.

Still no title, but that number now comes from a stalling probe and
establishes nothing either way.

METHOD: validating a probe on static images tests its analysis, not its
acquisition -- cross-check against an independent grabber during a run.
2026-08-29 02:01:47 +00:00
Sylpheed RE agent
a3946e5005 re: the game surface is rendering BLACK -- check that before explaining absences
The named experiment was to attach the fast probe at t=0 so the boot
title could not be missed. Done, default config, English:

  2391 frames in 600.4 s = 3.98 fps; max glyph 0; hits 0

Ten minutes sampled four times a second FROM LAUNCH, no green-(A) glyph.
So "the plate only shows in an early boot window I keep missing" is mine
and refuted -- the third explanation refuted in three iterations.

Then the check that should have come first. Splitting the raw root grab
into bands:

  y   0- 44 (GTK menu bar)   100.00% non-black   mean 210.50
  y  45-719 (game surface)     0.08% non-black   mean   0.07

The game is rendering black, reproducibly across back-to-back samples,
while the guest is alive and polling input (XamInputGetKeystrokeEx past
1201 calls) and MEM-WATCH keeps reporting. The crop and every pixel
oracle were correct; there was nothing on the surface to detect.

What this does NOT do is retroactively explain the earlier failures, and
claiming so would be the fourth over-reach in a row. Those runs had
content: run 2 sampled mean 33.1, run 3's classifier measured real
frame-to-frame rmse, the gamma_type=0 run measured mean 122.8. The
failure mode CHANGED over the session; black is the newest and worst.

Hypothesis for the regression, untested: canary's shader/pipeline cache
is 47 MB and was last written 23:49 on Aug 28, during the failed runs,
and this session has kill -9'd the emulator repeatedly. The test is to
move cache* aside and boot again -- one line and one run, not done.

Two METHOD lines: ask whether the screen is drawing anything before
explaining why a feature of it is missing; and a newly found fault does
not retroactively explain older failures.
2026-08-29 01:49:42 +00:00
Sylpheed RE agent
e9924ff9e8 re: build the fast probe -- and it refutes the diagnosis that motivated it
Last iteration I blamed four failed runs on the probe sampling every
~41 s, slower than the title screen lasts, and withdrew three earlier
conclusions on that basis. Building the fix tested the claim and killed
it.

The speedup is real and control-verified. One long-lived ffmpeg x11grab
stream, raw RGB, glyph counted in numpy -- no per-sample process startup,
no PNG encode, no convert -crop:

  wrapper `screenshot`            3.98 s per sample (emulator running)
  import -window root -> PPM      1.20 s
  long-lived x11grab stream       0.29 s          13.7x

The counter is byte-identical to is_title.py: 753 on the committed title
capture, 327 on the main menu.

Pointed at a running game it says the opposite of what I expected:

  332 frames in  85.3 s = 3.89 fps; max glyph 0
  1674 frames in 420.0 s = 3.99 fps; max glyph 0

1674 consecutive samples over seven unbroken minutes, four per second,
zero green-(A) pixels. Sampling rate was a real defect that happened not
to be the cause.

So "neither locale reaches the interactive title without a pad press" --
withdrawn last iteration for want of evidence -- is reinstated, now as a
dense measurement, with its reach stated: a MID-RUN window only, silent
about the boot title.

Leading hypothesis, unconfirmed: the PRESS (A) plate appears only in the
boot title window and the attract loop's title carries none, which is
exactly what title_states_capture.sh was written to test. The experiment
is to start the fast probe from t=0 rather than attach to a run already
in progress.

METHOD: fixing the instrument is how you test the explanation that blamed
it -- a plausible mechanism is a hypothesis, and the fix is its
experiment, not its proof.
2026-08-29 01:34:40 +00:00
Sylpheed RE agent
cc4e5e04a7 re: the boot harness was blinking slower than the title -- diagnosed
Four consecutive runs failed to reach the interactive title, across two
locales, two launch paths and two display-gamma settings. I attributed it
in turn to a stale oracle, to the locale, and to the attract loop. It was
none of those.

  one `screenshot` call, emulator running:  10.8 s
  one `screenshot` call, emulator killed:    0.117 s

92x, measured at a 1-minute load average of 1.80 -- so it is contention
with the emulator through the X server, not background load.
skip_intro.sh takes two grabs per iteration plus a numpy import, giving a
median sampling interval of 41 s in the last run (38/82/41/20/30/30/35/
47/46/45/44/43/21/19). The title lasts "a few seconds" before the attract
loop reclaims it -- wait_title.sh's own header says so. The harness was
sampling slower than the event it was waiting for. That also explains why
runs at 16:43-18:05 the same day succeeded.

Withdrawn as CAUSES, though the observations stand: "the JP run never
reaches the interactive title", "neither locale reaches it without a pad
press", and "the game sat in the attract loop for 604 s". The English
control did control for locale -- it just shared the same defect.

Also recorded: I set kernel_display_gamma_type = 0 for the gamma control
run, which brightens the frame (mid-attract mean 122.8 vs 52.5/82.8 at
type 2) -- and skip_intro classifies movie-vs-static on an ABSOLUTE rmse
threshold, so the gamma change biased the very classifier the run
depended on. Changing a display setting and a capture behaviour in one
run confounds both. Config restored to type 2.

The fix is not applied: make the probe cheap enough to outpace the title
window (small region, no convert round trip, one long-lived process).
Every remaining emulator-side question is waiting on that.
2026-08-29 01:18:05 +00:00
Sylpheed RE agent
ee73de50be re(ui): put a number on the render-vs-capture tone difference
Closes an observation I left dangling last iteration ("the capture is ~4x
darker than the render") and puts a figure on the  INDEX's texture row
already carried: exact gamma/sRGB fidelity untested because a hue
comparison cannot see it.

Geometry first: cross-correlating the main-menu capture against our
render over +/-6 px puts the best alignment at exactly dy=0 dx=0,
correlation 0.9466. Only the tone differs.

Two methods failed before one worked, and both failures are recorded.
Three dark patches gave "4x darker" -- the whole-frame best linear scale
is 0.914, so three patches from one region are not a transfer curve. A
pixel-wise fit over 854,685 pixels then produced a NON-MONOTONIC transfer
(render 96-127 mapping brighter than render 128-159) with mean abs error
10-14 for every candidate model. That is edge misalignment, not a tone
curve: at correlation 0.947 a bright pixel routinely lands on a dark one.

Flat patches fix it -- 16x16 blocks with std < 8 in BOTH images, a
threshold chosen from the counts (0/83/404/1055/1788 at std<3/5/8/12/20):

  main menu  404 patches  gamma 1.491  err 0.28   (best linear 0.276, 0.34)
  EXTRAS     382 patches  gamma 1.493  err 0.22   (best linear 0.273, 0.28)
  title      506 patches  gamma 1.338  err 1.08   (best linear 0.842, 9.02)

Reach, stated because it is narrow: those patches span only render values
~0-60, where gamma and a plain scale are nearly indistinguishable -- the
two menus decide nothing (0.28 vs 0.34, 0.22 vs 0.28) and only the title
separates them. Nothing constrains midtones or highlights.

The held-out control FAILED TO DISCRIMINATE and is reported as such: the
splash's 2918 flat patches are pure black (render 0-4), so every model
scores ~0.00. That is a test with no power, not corroboration.

Confound left open: this compares our composite to what canary DISPLAYS,
and canary applies kernel_display_gamma_type = 2 (BT.709). The exponent
may be its output stage. The discriminating run -- set it to 0, recapture,
refit -- needs one emulator session reaching the main menu and was not
done.

Classified measured, not decoded; HANDOFF says plainly that a port
applying it is authoring.
2026-08-29 01:05:25 +00:00
Sylpheed RE agent
2b4ec20f00 re(ui): a full-screen element is dropped on three port screens -- do not "fix" it
Audited what `screen render` silently omits on the port's five screens,
since an element the game draws but we skip is the one defect class the
port agent has actually hit. Everything is accounted for -- kind & 0x4
ghost instances, .prm primitives, loop* animations -- except pteff04.t32
on the title and pteff05.t32 on both menus. Those are kind 0x0, one
keyframe, rest a=255, pivot (640,360): full-screen and opaque.

Cause: the element declares pteff05.t32, but the T8aD behind its `opt `
link is registered under the name 8AX, so build.sprites.get() misses and
compose hits a silent continue. Bytes at 0x0e2035 of GP_TITLE entry 5:

  opt  ... 70 74 65 66 66 30 35 2e 74 33 32 00  38 41 58  54 38 61 44
           p  t  e  f  f  0  5  .  t  3  2  \0  8  A  X   T  8  a  D

8AX is 1280x720 and present in all six title-family bundles; it is a
sprite in builds 4/5/6 and never an element.

It does not currently show, and that is the useful half. ptbase.t32 is
640x360 drawn at 200% and carries THE SAME ARTWORK: its 2x upscale
differs from 8AX by mean abs diff 2.05 (max 80), and our rendered
background is pixel-identical to 8AX in every patch sampled. So resolving
the name and drawing it in addition would double-draw an opaque
full-screen layer -- invisible as a doubling, which is worse than a
visible bug. Written into HANDOFF as a do-not-do.

The free win, offered and not taken: use 8AX at 1:1 and drop ptbase
instead of upscaling a half-res copy. That is a rendering choice and
ptbase's element carries the keyframes, so it is the port's call.

Not established: which of the two the game actually draws. Both carry the
same art, so pixels cannot separate them; it needs a per-draw capture
recording texture base addresses, since the two differ in size.
2026-08-29 01:00:36 +00:00
Sylpheed RE agent
7d07eb945f re: the title capture is not reachable here either; stopping this line
Run 3 used the proven path rather than my own probe: launched exactly as
boot_menu.sh does (DISPLAY=:98, --apu=sdl, /dev/shm/xenia_* cleared, the
existing profile signed in) and drove skip_intro.sh, the detector that is
documented to work and that is stricter than mine (glyph >= 800 AND a
static frame).

It classified every one of 20 samples over 604 s as "movie", waited them
all out, and timed out. A direct check at 604 s says it was right: zero
green-glyph pixels, screen_id = other, warm mean (69,53,40), correlation
0.09 with build 7. The game really was playing attract movies for ten
minutes.

Three runs, two locales, two launch paths, ~35 minutes of emulator time,
no interactive title. Either the attract loop is far longer than the
600-780 s windows tried, or something regressed since
live-title-press-a.png was captured -- that one came from a pad-driven
boot (88b3ce9, "booting to the main menu and walking it").

Also settled a discrepancy I had flagged without checking: there is no
contradiction with title_states_capture.sh. It waits on screen_id.py, a
LOOSER oracle than is_title.py, and was written precisely to test whether
the interactive title draws ptbtn00 and the attract one does not. It
never claimed to reach the interactive title.

Stopping this line. The capture blocks none of the five menu screens and
has now cost five iterations. The locale mechanism, the fixed oracle and
the launch recipe are all committed, so what remains is patience with the
attract loop, not tooling. METHOD line on knowing when to stop paying for
a non-blocking answer.

Emulator stopped, lock cleared, locale English.
2026-08-29 00:49:49 +00:00
Sylpheed RE agent
8783adda14 re: the English control removes the locale from the title-capture problem
Two iterations framed the JP title capture as possibly locale-specific.
It is not. Same flags, same oracle, English locale: 75 samples over 734 s,
every one glyph = 0. The English boot does not present the interactive
title either. Canary was alive throughout, polling
XamInputGetKeystrokeEx (1801 calls); the frame at 734 s has content but
correlates 0.15 with build 4's render and 0.05 with the main menu -- an
attract-movie frame, not title art.

So neither locale reaches the interactive title in ~12 minutes without a
pad press. What is actually untested is the pad: pad.py and
nav_to_flight.sh exist, and title_states_capture.sh reaches title states
with a flag set this probe did not replicate (--log_ui_draws
--ui_draw_capture_frames, plus xdotool window focus). It is a capture
problem, not a locale one. MISSION.md updated.

Also resolved a caveat I had given the port agent without checking it:
"rotation is decoded but not rendered" does NOT affect their five screens
at rest. Title, main menu, EXTRAS and both splash halves have ZERO
top-level elements with a non-zero rotation. The only rotations on any of
them are the title's two nested ptloop records (r = 30 and -45), and at
rest those sit at x = 1521 and x = -839 -- a 399-wide sprite entirely off
both edges of a 1280 screen. The caveat now applies only to animating the
title build-in, where the sweeps cross the screen rotated.

Two METHOD lines: run the control before theorising about the difference,
and log every sample so a failure is a measurement rather than a silence.

Emulator stopped, lock cleared, locale English.
2026-08-29 00:35:49 +00:00
Sylpheed RE agent
f10edf1e79 re: fix wait_title.sh's stale oracle; the JP title still is not reached
Last iteration's "never reached the title in 787 s" was a broken tool
reporting on the world. wait_title.sh was still sampling the single pixel
(625,618) that is_title.py had already been written to replace -- its
docstring says why: a 1280x720 coordinate sampled against the 1279x675
game surface, so it always reads the copyright line. The replacement sat
in the same directory. wait_title.sh now delegates to it.

is_title.py passes its own controls before being trusted here: 753
green-glyph pixels on the committed English title capture, 327 on the
main menu, threshold 400.

Re-ran with the working oracle and the profile flag the English captures
use. The game STILL did not present the interactive title -- but that is
now a measurement rather than an artefact: not one frame showed a single
green-(A) glyph pixel, and content correlation against either build-7
render never exceeded 0.22. Canary was alive and polling
XamInputGetKeystrokeEx (601 calls), sitting in the attract movie.

So the open question narrowed again, and is written into MISSION.md:
whether the attract loop returns to the INTERACTIVE title without a pad
press. title_states_capture.sh claims it does on the English boot with no
pad input; if that holds, the difference is the locale.

Nothing decided about the keyframe-time association or the rest() rule.
Emulator stopped, lock cleared, locale restored to English.
2026-08-29 00:21:44 +00:00
Sylpheed RE agent
0f1c0f4e14 re: the JP-locale capture is not blocked -- I stopped one grep too early
Last iteration I wrote into MISSION.md that a Japanese-locale capture is
impossible here, because user_language is DECLARE_int32 at four call
sites with no DEFINE and no entry in xenia-canary.config.toml. That is
true, and it was not the question. The language is PERSISTED:
kernel_state.cc builds XConfig over <storage_root>/xconfig.settings,
SetDefaults() only supplies a value when the file has none, and the file
is writable. Checking where a setting is stored rather than where it is
configured turned "blocked, needs a human decision" into a two-line edit.
Withdrawn from MISSION.md; METHOD and REFUTED lines added.

The field is located from struct landmarks rather than a hard-coded
offset, and the check re-runs on every invocation so it fails loudly if
the layout moves:

  music_volume 0.7f at User+449  -> BE float at 2727 -> User base 0x8e6
  language          at User+44   -> reads 1 (kEnglish) at 0x912
  country           at User+64   -> reads 103 (US) at 0x926

XLanguage::kJapanese = 2 (xbox.h:307). set_console_language.py wraps it
with a backup and a --restore.

The capture itself is still NOT taken, for a smaller reason than I
claimed. A run with the locale set to Japanese booted fine but never
reached the title in 787 s: wait_title.sh's green-(A) oracle never fired
and burst-sampling found no frame correlating above 0.18 with either
build-7 render -- the run sat in the attract loop. So it needs a longer
or pad-driven run, not a rebuilt emulator. Emulator stopped, lock
cleared, locale restored to English.

Nothing is decided about the keyframe-time association or the rest()
rule; this only changes what standing between us and deciding them.
2026-08-29 00:05:58 +00:00
Sylpheed RE agent
6d246c7a97 re(ui): finish the top-level rotation census; record the JP-capture blocker
Two threads had converged on needing one capture this container cannot
take, so this iteration records that and finishes something reachable.

BLOCKED, written into MISSION.md rather than worked around: Q1's keyframe
time association and the rest() rule for plateau-less elements both now
hinge on a running capture of GP_TITLE build 7, the Japanese title. The
console language is not settable here -- user_language appears only as
DECLARE_int32 at four call sites with no DEFINE anywhere in the tree, and
it is absent from the registered cvars in xenia-canary.config.toml. There
is no flag to pass, and guessing one is specifically unsafe: run-canary's
own header records that xenia calls ShowSimpleMessageBox from
ParseLaunchArguments before logging starts, so a bad flag blocks forever
with an empty log. Rebuilding canary to add the cvar would be improvising
around the blocker; it needs a human decision. Neither question blocks
the five menu screens.

FINISHED: the disc-wide top-level rotation count, left running four
iterations ago as a shell loop over `screen info --geometry` that never
completed (it decodes every texture per build). Walking the placement
region directly takes seconds.

  top-level elements with a keyframe group   15 493
  carrying a non-zero rotation                2 152  (13.89 %)

Both controls pass: GP_TITLE build 4 reports 0 (its rotations are the
nested ptloop records) and GP_DIALOG build 0 reports the expected two.

The control earned its place -- the first version indexed the pak with a
`screen list` BUILD number and got 0 for a screen that has two, because
GP_DIALOG build 0 is entry 2. GP_TITLE maps 1:1, which is how the
assumption survived. METHOD line added.

Two free corroborations of the rotation decode. The rotated population is
dominated by tactical-map ship icons -- pbb_destroyer 444, pbr_destroyer
402, pbr_fighter 276 -- i.e. markers rotated to heading, the single
largest use of the field on the disc. And GP_TITLE entry 7's Japanese
wordmark pieces settle from ALTERNATING tilts:

  ptlogo3a  r = 0, -14, -4, -1, 0, ...
  ptlogo3b  r = 0, +14, +4, +1, 0, ...
  ptlogo3c  r = 0, -14, -4, -1, 0, ...

Same magnitudes, opposite signs, all decaying to upright. A misread field
does not produce that.
2026-08-28 23:46:54 +00:00
Sylpheed RE agent
b973d8cde3 re(ui): scale 0 means collapsed, not "unset" -- stop drawing it full size
blit() and fill_quad() both opened with

    let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };

so an element whose pose is collapsed to nothing rendered at FULL SIZE.
I first described this as "a 1-pixel sliver", reading the .max(1) in the
size arithmetic two lines below -- the guard above it meant .max(1) never
saw a zero. Read the whole function, not the lines you went looking for.

Control run before the change: if 0 meant "unset", something on the disc
would use it throughout. Nothing does.

  elements with a keyframe group                    15 493
  at least one keyframe at scale 0                   2 166
  EVERY keyframe at scale 0                              0
  zero is a transient the element grows out of       1 762

ptlogo_eff3.t32 runs 0% -> 200%. An "unset" marker no element ever uses
throughout is not a marker.

Both functions now return without drawing when either scale is 0.

Reach: 24 of 24 renders byte-identical across GP_TITLE (all 16 builds),
GP_PAUSE_MENU and GP_OPTIONS -- additive on every screen the port needs.
195 elements have a guessed rest pose at scale 0 and 126 of those have a
non-zero alpha, so the old code painted them; all 126 are in
GP_READY_ROOM.pak, which S1 already declared a no-go. They are
tactical-map sprites at sx=0/sy=100, mid-horizontal-wipe, that the
coercion drew full-width.

So: a real correctness fix with no visible effect on the menu port.
Suite green, 122 passed / 0 failed across 3 suites at commit time.
2026-08-28 23:38:37 +00:00
Sylpheed RE agent
c2c69b02be re(ui): size the rest() guess disc-wide, and refute my own proposed fix
Follows up the defect found last iteration: rest()'s dwell fallback is
guessing whenever it runs. Two things were open -- how big it is, and
whether "rest = the last keyframe" is the fix. Both are now answered, and
the second is answered no.

plateau_census.py walks the placement regions directly instead of going
through `screen info --geometry`, which decodes every texture and cannot
do a disc-wide pass in reasonable time. Its control reproduces GP_TITLE
build 7's three fallback elements and names ptlogo_eff3.t32 among them
before counting anything.

  elements with a keyframe group      15 493
  no plateau -> rest pose is guessed   3 807  (24.57 %)
    ... current rule returns invisible 1 711  (44.9 %)
    ... current rule returns scale=0     195  ( 5.1 %)
  the two candidate rules agree        1 911  (50.2 %)

195 elements get a rest pose with scale 0%, which is not a pose. And
disc-wide the choice of rule is not cosmetic: the candidates agree half
the time.

But the port's exposure is one element. Across main menu, EXTRAS, title
and the developer splash, 14 elements are plateau-less and the two rules
agree on 13. The single disagreement is palogo_anima_eff.t32.

And "last keyframe" loses there, on a control that needed no new capture:
the splash carries three sibling glows with identical structure and
identical times --

  palogo_gamearts_eff  15:a=0 30:a=255 45:a=255 -:a=0  plateau -> visible
  palogo_seta_eff      15:a=0 30:a=255 45:a=255 -:a=0  plateau -> visible
  palogo_anima_eff     15:a=0 30:a=255 45:a=212 -:a=0  no plateau

-- differing in one byte. "Last keyframe" makes anima alone invisible
while its two siblings stay lit. The capture agrees weakly: box-mean
ratios capture/render are gamearts 0.717, seta 0.723, anima 0.772, and a
glow we drew that the game does not would put anima below its siblings,
not above.

So the defect is measured and the fix is still undecided. Nothing in the
decoder changed.
2026-08-28 23:23:55 +00:00
Sylpheed RE agent
b947fa8e8e re(ui): withdraw the render evidence -- rest()'s dwell fallback is unsound
Last iteration I reported a 13.1% render change in GP_TITLE build 7 as
evidence AGAINST the keyframe-time shift, arguing that language twins
should match in brightness. Withdrawn. Localising the diff to an element
shows it is not about the time association at all.

The element is ptlogo_eff3.t32, a transient bloom:

   46: (98,42)  100%,100% a=0
   61: (108,72)   0%,0%   a=0
  103: (108,72) 200%,200% a=255 r=80
    -: (108,72)   0%,0%   a=0   r=150

No two adjacent poses are equal, so there is no plateau, and rest() falls
through to its longest-dwell fallback. The longest gap is 61->103, during
which the sprite grows from nothing to 200% at full alpha and then
collapses. The rule returns whichever end of that movement the indexing
lands on: the invisible frame as decoded, the 200% peak shifted. An
896x389 sprite at 200% is larger than the screen, which accounts for the
entire 13.1% and the entire 4.9-unit luminance gap. I was comparing a
heuristic against itself.

The defect generalises, and structurally. A dwell gap is time spent
interpolating FROM pose k TO pose k+1; neither pose is held during it
unless the two are equal -- which is a plateau, and the plateau path has
already returned by then. So the fallback is guaranteed to be guessing
every time it is reached, under any reading of the times. Every element
with no two adjacent identical poses has a guessed rest pose, in our
renderer and in anything built from it.

Consequences: the case FOR the shift (26x on the hold:fade-out ratio) is
no longer opposed by render evidence -- 10 of 11 builds byte-identical,
the 11th differing only through this heuristic. It is still not adopted,
now because it flips this element to the visibly wrong answer, so the
shift and a decision about plateau-less elements must land together, and
neither half has a capture to verify against.

Default-mode suite green (122 passed, 0 failed across 3 suites so far).
2026-08-28 23:10:41 +00:00
Sylpheed RE agent
6f23f4d113 re(ui): the keyframe-time shift is favoured 26x by timing, rejected by a render
Follow-up on last iteration's unadopted candidate (+36 holds the NEXT
pose's time, not its own). Two new results, pointing opposite ways, and
both are reported.

FOR, and calibration-free: the observed full-alpha hold : fade-out ratio
on palogo_gamearts is 83 : 13 frames = 6.38. The shifted reading predicts
8.00. The current reading predicts 0.25 -- off by 26x. With the glow's 2
units/frame fixed and nothing else free, the current reading says the
logo holds full alpha for 2.0 frames; the capture holds it for 83. This
is no longer the shape argument the candidate rested on.

Also for: rest()'s plain max-dwell fallback picks a=0 -- a transparent
pose, for a publisher logo -- under the current reading, and the visible
a=255 hold under the shift. Only the rest_plateau special case rescues
the render today, and that is the case the port agent reported a bug in.

AGAINST: rendering every build of six UI paks under both readings, 10 of
11 compared are byte-identical and one changes -- GP_TITLE build 7, the
Japanese twin of build 4, by 13.1% of pixels. Build 4, the one verified
against a live capture, is unchanged either way, so the single build the
shift moves is the one with no capture to adjudicate it. The proxy goes
against the shift: language twins are the same artwork, and build 7 reads
70.94 mean luminance as decoded against build 4's 71.41, but 76.32
shifted. Correlation does not separate them (0.6206 vs 0.6201).

These constrain different things -- timing versus pose selection -- and
rest() is a heuristic layered on the times, so moving the times moves its
tie-breaks. Adopting the shift means revisiting that heuristic in the
same change, with no build-7 capture to verify against.

Default UNCHANGED. Experiment reachable via SYLPHEED_KF_TIME_SHIFT=1.
Shifted-mode suite: 122 passed, 0 failed across 3 suites at commit time.
2026-08-28 23:03:55 +00:00
Sylpheed RE agent
531840b417 re(ui): Q1 -- the interpolation law holds, the group timeline does not
Q1's gate asks whether the ramp is linear. It is, and that result stands:
it rests on the splash's _eff glows, which reproduce exactly. This adds
the part that does not.

The test is a calibration, not a fit. Fix the clock on
palogo_gamearts_eff -- declared 15-unit fade-in 0@15 -> 255@30 against
captured alphas 34,68,102,136,170,204,238, a constant step of 34, giving
t = 2f - 171 -- then check that against the glow's own next landmark: its
declared hold ends t=45, predicted frame 108.0, observed last full-alpha
frame 107. Then apply it to palogo_gamearts in the same bundle and the
same frames, with no free parameter left:

  declared a=232 at t=206 -> frame 188.5, observed alpha 255
  declared a= 32 at t=210 -> frame 190.5, observed alpha 255

The logo is still at full alpha nine frames after it should read 32; its
fade-out runs ~17 frames late; its declared 80-frame fade-in is never
drawn. Not culling -- the same element is submitted down to a=7 on the
way out. Calibration-free version: the declared fade-out spends 12 of 16
units dropping 23/255 of the alpha, and the capture has no such plateau.

Candidate, offered and NOT adopted: if +36 held the NEXT keyframe's time,
the fade-out shape fits (RMS 4.05 vs 12.13, two elements) and the
decoder's "last block's time is unreadable" special case disappears --
the last block would simply have no successor. Rejected for now because
it explains neither the missing fade-in nor the lateness, and because the
_eff elements cannot discriminate between the readings at all (with four
blocks the shift only relabels the phases). Decoder unchanged.

Also withdrawn, mine, within the iteration: "the _eff glows hold a
constant alpha 33". They ramp 34 -> 255 in steps of 34. I printed the
series minimum and read it as its range, with a "14 distinct colours"
column sitting next to it saying otherwise.
2026-08-28 22:56:17 +00:00
Sylpheed RE agent
f11fde51e5 re(ui): rotation is not nested-only, and the pivot-anchored scale is measured
Two corrections and one new confirmation, all from finishing the sweep I
left running last iteration.

Refuted, by my own sweep, within the hour: "rotation appears to live only
in nested .rat leaf records". That held for GP_TITLE, GP_BUNK and
GP_CHALLENGE -- the three archives the sweep had reached when I wrote it
-- and fails on GP_DIALOG and GP_DEBRIEFING_PILOTLOG, which rotate
top-level elements. Scoping the claim made it cheap to withdraw, but the
sentence should have waited for the sweep.

Those top-level cases are the best evidence on the disc, and they show up
in `screen info --geometry` without a hex dump. GP_DIALOG build 0:
pceff03/pceff04 ramp r = 90 -> 30 -> 10 -> 3 -> 0 while alpha ramps
0 -> 255 and they slide into place -- a swing-in settling upright. Build
6: pzeff02 ramps 43 -> 61 -> 75 -> 90 while scaling 112% -> 200% and
fading to 0 -- a spin-out. Rows committed as reference data.

New: the pivot-anchored scale term in blit, kf.x - pivot*(scale-100)/100,
was implemented and reasoned about but never measured, because every
element previously examined sits at 100% scale where the term is exactly
zero. The ptloop pair scale 600% and 800%, where it is worth 450 and 630
px. Formula predicts centre y = 360.0 for both; capture measures 359.1
and 360.0. Top-left anchoring predicts 810 and 990. Horizontally it makes
t-from-position agree with t-from-alpha to 0.33 / 0.65 units against ~8
without it.

That agreement does NOT prove linear interpolation -- both fields were
inverted through the same linear map, so a shared easing curve cancels.
It shows position and alpha ride one shared parameter. Recorded as such.

The disc-wide sweep is still running; the count is incomplete, the
existence is settled.
2026-08-28 22:46:44 +00:00
Sylpheed RE agent
67fa1a1b0b re(ui): decode keyframe +12 as screen-plane rotation in degrees
The rotated quads on the title screen come from the keyframe block after
all. The earlier negative -- "every GP_TITLE build 4 element has all three
angle words at zero" -- read the right bytes over too small a region: it
walked the top-level declaration table, and the rotated elements are the
nested leaf records ptloop01.rat / ptloop02.rat.

Confirmed against the framebuffer rather than against our own renderer.
The two records declare +12 = 30 and -45; the GPU capture submits their
quads at +30.26 and -45.28 degrees -- magnitude and sign, two different
values. Corroborated by shape in GP_BUNK 117ca14f, where +12 ramps
0 -> 360 with position, scale and alpha constant: a spin in place.

Identifying which draw it was needed edge lengths, not bounding boxes:
400x1076 and 400x1444 against pteff03/pteff03a 399x180 at the elements'
two different declared scales, 600% (1080) and 800% (1440). The same test
names three known-positives in the capture (ptlogo1, ptcopyright,
ptbtn00), so it passes its own control.

Keyframe gains rotation_deg plus unknown_4/unknown_8, carried rather than
dropped. NOT rendered -- ui_layout::blit is axis-aligned only, so the
reference renderer and the port will both draw these upright until a
rotating blit exists.

The census tool ships with the trap that broke its first version: nested
RATC blobs are not 4-byte aligned, so an aligned scan found 0/3 of its
own control blocks and missed 16 341 blocks. Disc-wide +12 is non-zero in
14.50 % of 83 862 blocks.

sylpheed-formats tests, SYLPHEED_DISC set: 131 passed, 0 failed across the
6 suites finished at commit time; the run had not yet completed.
2026-08-28 22:42:00 +00:00
Sylpheed RE agent
130ad8d1b3 re: the skewed draw is not the swoosh -- refuting the identification five
iterations of work were built on

I flagged last iteration that "draw 2 is the swoosh" was an inference I
had never checked. Checking it took one line, and it is wrong.

Converting the quads from NDC to screen space: quad A spans y -209 to
925, quad B spans y -292 to 1012. Both cover the full screen height and
run well off it. The swoosh, ptlogo_back2, rests at (71,126) and is a
234-pixel band at y 126 to 360. Draw 2 is not it.

A candidate offered as one: the two ptloop sweeps. pteff03a is 399x180
with element scale (100,800), so 399x1440, and pteff03 carries (100,600)
-- two long thin sprites, two quads, and the drawn vertex alphas 0xC3 and
0xB6 sit inside the 0x80 to 0xff ramp those records declare. Not
confirmed; no texture or position match was made.

If that is right it matters, and I have written it conditionally rather
than acting on it: our renderer parks those sweeps at their final
keyframe, both off screen, and draws nothing, while the game draws them
across the screen. That would mean the capture caught them mid-sweep
inside their t=150 to 600 window, and that the "groups hold" reading
needs re-examining for those two elements.

What this cost is worth stating plainly. Five iterations -- the additive
blend test, the pivot analysis, the vertex-colour capture -- were built
on an identification made by elimination on one screen and never checked
against the draw's own coordinates. The eliminations themselves stand,
because each was measured against the capture rather than against the
identification. The chain that pointed them at ptlogo_back2 did not.
METHOD takes the general form.
2026-08-28 22:21:42 +00:00
Sylpheed RE agent
5115b4894a re: three angle fields found in the keyframe -- and they are not the
title's rotation

Looking for where the rotated quads come from, the obvious candidates
were the three keyframe words at +4, +8 and +12 that ui_layout.rs
documents as zero.

They are not zero. Across 72287 keyframe blocks disc-wide they are
non-zero in 4.81, 4.56 and 15.82 percent of blocks, and read as signed
values clustering on 180, -180, 90, -90, 120 and 22 -- degrees. Three of
them, so plausibly rotation about three axes. I have marked that amber
because it is the shape of the numbers and nothing more; no observed
rotation has been tied to a value. The doc comment is corrected
regardless: "0 on every frame seen" was an artefact of the sample.

And they do not explain the screen I was chasing. Every element of
GP_TITLE build 4 has all three at zero, element by element, while the
game demonstrably submits rotated parallelograms there. So the title's
rotation comes from outside the keyframe data and is still unidentified.

One correction to my own last write-up, flagged rather than left: I
stated that the skewed draw IS the swoosh. It is the only skewed geometry
in the capture and the swoosh is the only diagonal element on the screen,
so the inference is reasonable -- but I never confirmed it by matching
the draw's texture or screen position to that element, and I should have
said so the first time.
2026-08-28 22:17:14 +00:00
Sylpheed RE agent
0ecb20bdbc re: the swoosh is solved -- the game draws it as rotated quads and our
blit cannot

Ran the route I corrected last iteration, and it answered the question I
had classified as undecodable.

First the ninth candidate died: every vertex colour in the whole capture
is <alpha>FFFFFF, white RGB with only alpha varying. The game passes no
colour, so that hypothesis is refuted from the running game rather than
from the file.

Then the geometry gave it away. Draw 2 submits TWO PARALLELOGRAMS,
neither axis-aligned -- edges (0.54,-0.56) and (0.44,0.79), roughly 45
and 61 degrees, both extending to y=+/-1.81 in NDC, well off screen.
That is the diagonal Z stroke. And ui_layout::blit walks rows and columns
of an axis-aligned rectangle; it has no rotation at all. So we blit the
sprite upright where the game draws it skewed, which is exactly the
signature I measured: right on average at +1.83, right in position with
the correlation peaking at zero shift, wrong in structure at 0.70.

Nine candidates, and the answer was never going to be a field, because
the difference is not in the sprite -- it is in how the quad is built.

Two things left open and written as such. The decoded keyframe carries
fade, scale, tint, x, y and time and NO rotation, so where the rotation
comes from is not decoded. And the pink-versus-white reading I have been
carrying was a visual comparison of two differently-shaped renderings; it
should be re-checked after geometry rather than treated as a separate
defect.
2026-08-28 22:13:50 +00:00
Sylpheed RE agent
6930d301c1 re: full suite green, and correcting the route I recommended last
iteration

Two loose ends, both on my own work.

The disc-gated suite finally completed: all six binaries, 131 passed,
0 failed, one pre-existing ignored. So the rest() fix and the T8adImage
flags field are fully verified rather than verified-on-three-binaries.
The reason earlier attempts produced empty logs was that the tool
timeout's SIGTERM reached the whole process group; setsid nohup survives
it, which is now a METHOD line.

And a correction that matters more. Last iteration I closed the swoosh as
undecodable from the disc and named a per-draw GPU capture as the next
route, "because it reads the actual blend state". It does not. Reading
command_processor.cc, each captured draw records primitive type, index
count, index-buffer address, VS and PS ucode hashes, the pixel shader's
texture bindings, and vertex attribute 0 of binding 0. There is no
RB_BLENDCONTROL dump.

So the route splits, and I have said so rather than leaving the wrong
version standing: the capture can test a per-draw VERTEX COLOUR today
with no code change, which would explain white-versus-pink directly, and
getting the blend mode itself needs a Canary change to dump the blend
registers. Either way it is instrumentation rather than another field.

The general lesson goes in METHOD too: validate a recommendation before
leaving it as advice. A named next step is a claim like any other, and I
made it without checking.
2026-08-28 22:09:07 +00:00
Sylpheed RE agent
e10a6d6f2c re: the capture is settled after all, and the swoosh is undecodable from
the disc

Testing my own caveat instead of leaving it hanging. I had worried the
plate-free title capture at t=4.0s was too early, since elements carry
keyframes out to t=600 and I had judged "settled" from mean luminance,
which cannot see a thin sprite still moving.

The plate sits at y 550-600 and the swoosh band at y 112-225, disjoint,
so a LATE capture works even with the plate present. The band correlates
0.7342 at t=4.0s and 0.7353 at t=21.5s -- identical to a thousandth over
17.5 seconds, with the band mean flat at 127. So the band is settled by
4 seconds, the capture I handed the port agent is sound, and my caveat is
withdrawn. It also corroborates the earlier finding that groups hold:
nothing crosses that band in 22 seconds.

That was the seventh candidate for the swoosh and the last one that was
not a format field. Pivot is inert at scale 100 and there is no
displacement anyway; fade is white-with-alpha; tint is white; the texture
is blue-leaning; additive via +0x04 bit 0x02 makes everything worse; the
capture is settled. The residual is stable and modest -- band mean +1.83,
edge-correlation 0.70 against 0.92 frame-wide.

So I have classified it rather than generating an eighth candidate:
undecodable from the disc, with the reach written out. And I have named
where a next attempt should start, which is not another field -- a
per-draw GPU capture of the running guest reads the actual blend state,
vertex colours and draw order for that band instead of inferring them
from the file. That is where static RE stops here.
2026-08-28 22:02:32 +00:00
Sylpheed RE agent
5d02d5cd60 re: additive blending refuted, the swoosh is not displaced, and the
residual is smaller than I said

Testing the candidate I raised last iteration rather than carrying it.
Blending bit-0x02 sprites additively moves every measure the wrong way --
whole-frame mean diff +0.55 to +1.04, swoosh-band mean +1.83 to +3.98,
band edge-correlation 0.6971 down to 0.5578. So the bit is real and
independent but does not select an additive blend. I reverted the
experiment and kept the word as T8adImage::flags, documented and not
acted on; the render is byte-identical to before.

Second refutation: the swoosh is not displaced. Shifting the band over
plus or minus 80 by 8 pixels peaks sharply at zero, 0.7342, falling to
0.22 at 24 px. So the pivot story is dead twice over -- inert at scale
100, and no displacement to explain anyway.

And I have restated the residual, because earlier sections overstated it.
The +16 to +34 band tiles I quoted were measured WITHOUT --primitives.
With the dim drawn the band's average is nearly right at +1.83; what is
wrong is its structure, tiles running -38.6 then +33.8 and cancelling.
Six candidates eliminated now and none confirmed.

One caveat I owe the port agent about the capture I gave them: it is at
t=4.0s, roughly 174 keyframe units into a screen whose elements have
keyframes out to t=600. I judged "settled" from mean luminance, which
cannot see a thin sprite still moving. It is settled for the bulk of the
screen and not proven settled for every element -- which is a live
alternative explanation for a structural difference in exactly the band
the sweeps cross.

METHOD: cargo build passing does not mean cargo test compiles. Adding the
field built the library in 1.48s and broke two test-only struct literals;
cargo test failed with exit 101.
2026-08-28 22:00:55 +00:00
Sylpheed RE agent
e0d02dce71 re: a candidate blend flag -- T8aD header +0x04, bit 0x02
Continuing the swoosh. Last iteration ended with "the next step is
finding where a blend mode would be encoded, and I do not know the format
carries one". It does carry a candidate.

The kind field is not it -- the swoosh sprites are kind 0x0, the same as
ordinary ones. But the T8aD header word at +0x04 splits the title's
sprites exactly along effect versus normal: pteff01, pteff03a,
ptlogo_back2eff1..5 and both ptlogoall_eff are 0x8832, while ptlogo1,
ptlogo2, ptlogo_tm, ptbase2, ptlogo_back2 and ptcopyright are 0x8830.
One bit, 0x02.

Disc-wide it behaves like a real flag rather than an artefact: 19216
sprites, 18 distinct values, bit 0x02 set in 27.1 percent, and it toggles
against otherwise identical words -- 0x8830 against 0x8832, 0x0830
against 0x0832, 0x0810 against 0x0812, 0x0030 against 0x0032.

Marked as correlation and not decode, because nothing here shows the bit
MEANS additive. The one thing that makes it more than a guess is
ptlogo_back2eff, which carries 0x8830 despite having eff in its name --
so the split is the field's and not my pattern-matching on names. The
test is to blend bit-0x02 sprites additively and re-correlate the title
against the plate-free capture.

METHOD gets the trap that cost the first attempt: searching for a
sprite's name and taking the next T8aD returns the SAME header for every
sprite, because the names all live together in the declaration table. It
failed its own control at once -- different sprites reporting identical
dimensions -- and the fix was to match on width and height instead of on
proximity.
2026-08-28 21:46:18 +00:00
Sylpheed RE agent
c10802536f re: the pivot mismatch cannot be the swoosh's cause -- refuting my own
candidate with the check I named

Last iteration I offered the pivot mismatch as a quantified candidate for
the swoosh rendering too thick, and said explicitly that whether it bites
depends on which pivot the compositor uses and that I had not checked. I
checked.

blit takes the drawn size from the TEXTURE and uses the pivot only as the
scale anchor, kf.x minus pivot times scale-minus-100 over 100. At scale
100 that term is zero. And every one of the seven swoosh elements --
ptlogo_back2, its glow and the five eff segments -- reports a single
scale of (100,100) across all its keyframes. So the mismatch, real as it
is in the data, cannot move or resize the swoosh in our render. Refuted,
under my own name.

It is not harmless everywhere, and I have said where it does bite:
ptlogo1 and ptlogo2 run 100, 101, 103, 112, 150 during the build-in, so
there the wrong pivot really does displace them -- during the animation
rather than at rest.

What that leaves is the blend, by elimination rather than by evidence.
Position and size are the texture's own and right, fade is
white-with-alpha, tint is white, the texture is blue-leaning rather than
pink. Seven overlapping sprites at 5 to 23 percent opacity stacked with
plain alpha-over saturate toward opaque white, which is what we draw and
would read as thicker beside the game's thin coloured stroke. Recorded as
the remaining candidate and explicitly not as a diagnosis -- no blend
mode has been identified in the data.
2026-08-28 21:40:46 +00:00
Sylpheed RE agent
2a0afc1405 port: land the pivot finding on the handoff (third assertion miss)
The scripted HANDOFF edit asserted on text an earlier edit had rewrapped,
so the pivot and colour findings landed in docs/re and not on the page
the port agent reads. Third time this session; METHOD now says the rule
was insufficient and names the mechanical fix.
2026-08-28 21:37:51 +00:00
Sylpheed RE agent
e249b2e2ff re: the swoosh's pivot belongs to the Japanese sprite, and the colour is
not in any field I can find

Chasing the swoosh defect. The colour half is a clean negative and the
geometry half now has a number.

Ruled out for the colour: every keyframe of every swoosh element carries
fade 0x??ffffff -- white RGB with only the alpha varying -- and no tint
is anything but white. The texture is not pink either; ptlogo_back2
decodes blue-leaning at (175,174,198) and its glow warm at (255,253,234).
So the magenta edge the game draws comes out of blending, and I have said
that rather than leaving "wrong colour" as an undifferentiated mystery.

The geometry half: the declared pivot does not match the element's own
texture. Build 4's ptlogo_back2 pivot is (500,117), which is exactly half
the JAPANESE sprite at 1000x234, not half its own English one at
1118x262 -- off by 59 px, the right order to make the swoosh too thick
and extend too far right. The layout record is authored once and shared
while the sprites are swapped per language, which ui-rat-layout.md warns
about in general; this is that warning measured on the screen where our
render disagrees with the capture. It cuts both ways -- the Japanese
build's ptlogo1 is off by 40.

Marked as a quantified candidate rather than a diagnosis, because whether
it bites depends on which pivot our compositor uses and I did not check.

One METHOD line, because the first version of that check was misleading:
a pivot-equality test conflates rounding with real breakage. Equality
flagged 74 percent of title elements. Split by magnitude it is 17 exact,
54 off by a pixel from odd dimensions, and 24 genuinely off -- and those
24 are the finding.
2026-08-28 21:37:24 +00:00
Sylpheed RE agent
cdb5a99def re: the title "slab" is the logo swoosh drawn white and thick, and it
lands on the known tie-break group

Chasing the residual I left open last iteration, and it turns out I had
attributed the port agent's slab to the wrong thing.

First the refutation. My hypothesis was that our dim is applied over the
whole frame rather than beneath the UI where its layer key puts it. If
that were true the logo would render too dark. It reads +2.36 against a
background of -0.74, so the compositor honours the paint order and the
hypothesis is dead.

What the residual actually is: with the dim in place the error is not
uniform but concentrated in one band, running -38.6 in one tile and +33.8
three tiles to its right. Cropping that band from capture and render
shows it immediately -- the game draws the logo's Z swoosh thin with a
pink edge, and we draw it thick and solid white. That, not the missing
dim, is the washed-out slab over the title logo the port agent reported.
The dim explains a uniform +13; this explains the slab.

The elements are ptlogo_back2, its glow, and the five ptlogo_back2eff1..5
segments -- which are also the group carrying this corpus's known
unsolved paint-order tie-break at key 0x8083, on the same screen. I have
recorded that as a lead and explicitly not as a diagnosis, because a
blend-order swap is a poor explanation for white instead of pink and I
would expect a second cause.
2026-08-28 21:31:52 +00:00
Sylpheed RE agent
d217c07db2 re: the title's arrived pose is rest, and the washed-out slab is a
MISSING dim quad

Both halves of what the port agent could not settle, answered against the
plate-free capture rather than against another renderer.

Rendering build 4 and edge-correlating to the capture of the real screen
gives 0.9163 at zero shift, 0.92/0.78/0.93 per band. The geometry of rest
is the title's arrived pose, so the timeline is not needed there.

The slab is the more useful half, and it is inverted from how it looked.
rest alone renders uniformly +13.14 too bright, R +12.35, G +13.58,
B +13.48. Drawing the .prm primitives collapses that to +0.55. The
element is pteff02.prm, the 25 percent dim at rest t=46 with fade 0x40,
and --primitives is OFF BY DEFAULT. So the washed-out cyan slab is not
something being drawn that should not be; it is a dim that should be
drawn and is not, and because the title art is blue-dominant the
shortfall reads as a cyan wash. Any consumer of screen render that omits
--primitives on this screen gets it.

Two residuals recorded rather than tidied. With the dim drawn the mean is
essentially exact but per-pixel agreement gets slightly WORSE --
edge-correlation 0.9163 down to 0.9066 and pixels off by more than 20
rising from 108051 to 162636 -- so the dim's average contribution is
right while its application is not exactly the game's. And separately the
per-tile map shows a +25 to +38 hot spot on the upper-right Z swoosh,
which a capture from the first iteration of this work already showed pink
in the game and white in ours. Same region, still undiagnosed.
2026-08-28 21:27:04 +00:00
Sylpheed RE agent
5414db34bf re: the rest() fix gets its disc-wide check, and one question back to the
port agent

Closing the two things I flagged as unconfirmed when I committed the fix.

The unit gate is green: cargo test -p sylpheed-formats with SYLPHEED_DISC
set gives 131 passed, 0 failed across six binaries including the
disc-gated ones. The earlier background run that produced an empty log
had died with SIGTERM, which is why it looked like nothing happened.

And the disc-wide check the classification requires. Reimplementing both
rules over every RATC bundle on the disc -- 2859 bundles, 13991 elements
with at least two keyframes -- the fix moves rest for 30 elements, which
is 0.21 percent. Four go invisible to visible. Zero go visible to
invisible, which is the safety property I wanted and did not have when I
committed. The four are ptframe1 and ptframe2 in GP_TITLE entries 5 and
8, the same pair once per language.

That surfaces a discrepancy worth handing back rather than smoothing
over. On the English main menu exactly TWO elements satisfy the port
agent's pose-equality condition, not six, so their six must span the
whole twelve-screen export. That fits their own observation that the
timeline and rest differ in exactly one region, the bounding box of
ptframe1 and ptframe2 and nothing else. But if any of their other four
have a TRANSPARENT trailing run, my alpha rule leaves them alone
deliberately -- that exclusion is what protects PAUSE -- so I have asked
which screens they are on and whether a capture shows any of them drawn.
If one is, the alpha rule is incomplete and needs a third discriminator.
2026-08-28 21:24:42 +00:00
Sylpheed RE agent
4bc970688f re: rest() fixed -- a trailing keyframe run is the hold when it is
VISIBLE, not when its pose repeats

The port agent's report was right about the defect and about which
elements it hits, and its proposed condition does not survive the case
the exclusion was written for.

Their test -- the final untimed keyframe has the same pose as the last
timed one -- is also true of pgptitle.rat, whose trailing run is two
identical transparent frames. Adopting it would erase the word PAUSE
again, which a committed capture disproves. What separates the two is
alpha: an exit fades the element out so its last keyframe is transparent,
while an element with no exit ends on the pose you can see. So
rest_plateau now accepts a trailing run exactly when it is visible.

Verified against a capture rather than against another renderer, which is
the point their own report made: ptframe1's rest moves from (620,108)
t=16 to (440,108) t=62; the changed pixels are 10082 in a bounding box of
x 440-839, y 108-577, which is exactly the 400x470 at (440,108) they
predicted; and correlation against main-menu-oracle.png over that region
improves from 0.9596 to 0.9748. The PAUSE wordmark is unchanged across
all three pause builds.

This also closes a question ui-paint-order-key.md has carried for a
while, that ptframe1 and ptframe2 rest at alpha 0 while the capture shows
the menu frame plainly.

One trap cost most of this iteration and goes in METHOD: CARGO_TARGET_DIR
is redirected in this container, so ./target/debug/sylpheed-cli is hours
stale and every render I made against it was old code. Byte-identical
before and after reads as "no effect" when it actually means "you ran the
old binary".

The full disc-gated test suite was still executing when this was
committed; the verification above is artifact-based.
2026-08-28 21:19:00 +00:00
Sylpheed RE agent
ed4e5c7b37 re: build 4 captured without the plate, and keyframe groups hold rather
than loop

The port agent ranked a plate-free capture of build 4 above any further
static RE, so that came first.

B from the main menu returns to the title and the plate fades in a beat
later, which opens a clean window. Recorded at 20 fps from the press: the
art appears at 1.10 s, builds in to 3.70 s, sits settled and unobstructed
until 5.00 s, and the plate arrives at 5.10 s -- the band jumps from 282
to 3755 bright pixels. Committed the frame at 4.0 s as the reference for
the cyan glow slab they report drawing and the game not having.

Their other sub-question -- whether a keyframe group loops or holds --
falls out of the decoded sweeps plus a measurement I already had, and the
two agree. ptloop01's final keyframe parks pteff03.t32 at x=1521 and
ptloop02's parks pteff03a.t32 at x=-839, both off-screen on a 1280-wide
design; and over 18 s of settled title the centre tiles sit at sd <= 0.01
when a looping group would recross the screen every 7.5 s. So groups HOLD
at the last keyframe. The loop*.rat name is misleading.

Also recorded, in the corpus rather than only in their report: the
rest_plateau bug, with their exact identifying condition -- the final
untimed keyframe has the same pose as the last timed one -- the six
elements it misses on main_menu, and the bracket it drops. That closes an
open question ui-paint-order-key.md has carried for a while about
ptframe1 and ptframe2 resting at alpha 0 while the capture shows the
frame plainly. Same two elements, same cause. Not fixed yet; the change
is in ui_layout's rest().

And a METHOD line I would not have written myself: two renderers agreeing
is not evidence the field is right. Their composite and screen render
matched to 3/255 on main_menu and both omitted two elements the game
draws, because both read one field through one decoder.
2026-08-28 21:05:35 +00:00
Sylpheed RE agent
1b95001feb re: the last keyframe time is the chunk terminator -- observed, not
assumed

Continuing the pulse's cycle length, which I had called a structural
limit of the format. It is, and now I can show it rather than assert it.

Dumping past what I thought was the final keyframe found an EIGHTH block:
fade 0x00ffffff at the same position, so the glow returns to fully
transparent and the pulse is a closed cycle rather than a one-shot ramp.
That is worth having on its own -- the port knows the animation ends
where it starts.

And the eighth block's time slot contains the four bytes "end ", the
record's ASCII terminator. So the corpus's rule that a group's last block
has no time of its own holds here in a second form: not the next group's
element index, but the chunk terminator. The value does not exist. That
goes into ui-rat-layout.md, because it generalises beyond this record.

The measured 2.3 s would need a final step of about 33 units. I have
written that number down explicitly labelled as fitted to the measurement
rather than read from the file, so nobody later re-derives it as a
decode.

One thing I had half-assumed and can now rule out: the word at +0x004 is
not a keyframe count. It reads 60 here with 8 keyframes and 30 in the
loop records with 3. Unknown, and marked so.
2026-08-28 21:02:14 +00:00
Sylpheed RE agent
c176fa53bc re: the 2.3 s pulse is the PRESS (A) plate, not the title
Closing the residual I left explicitly unidentified last iteration, and
correcting my own claim twice over in the process.

The cheap test first: is the oscillation global? No -- the bottom-right
corner is flat at sd 0.003 and uncorrelated with the whole frame. So a
per-tile amplitude map over an 8x6 grid, which localises it hard: sd 7.65
in the band x 318..954, y 560..672 at lag 2.3 s, against 0.06 on the
wordmark. That band is the PRESS (A) BUTTON plate's rest position.

Decoding ptbtn00f.rat, the plate's highlight variant, gives the pulse
itself: alpha 0x00 -> 0x06 -> 0x4a -> 0x50, held, then back down through
0x4a to 0x06 across t=6..105. A glow that fades in and out, which is
exactly what a press-start prompt does.

So "the title screen loops at 2.2 s" was wrong in both halves. The title
ART is near-static apart from the two decoded sweeps at 7.5 s and 9.5 s;
what pulses is the PLATE, and the plate is build 2, not build 4. REFUTED
carries it under my own name.

Still amber, and said so rather than rounded off: the cycle LENGTH is not
readable. The keyframe group's last block has no time -- that slot
belongs to the next group -- so the declared span is at least 105 units,
1.75 s, against a measured 2.3 s. Consistent with a final block
extending the tail. Not confirmed.
2026-08-28 20:58:49 +00:00
Sylpheed RE agent
83293c3f81 port: land the loop decode on the handoff, and note the edit trap
Second iteration running where a scripted HANDOFF edit failed its
assertion on rewrapped text, leaving the port's page stale while docs/re
had the finding. Patched by reading the file first, and METHOD now says
to check the commit's file count for a silently dropped edit.
2026-08-28 20:53:33 +00:00
Sylpheed RE agent
3717293fa9 re: the loop records decode -- and they are not what I measured
Two of my own claims from last iteration die here, and the decode is the
thing that kills them.

Searching for the SHAPE instead of an assumed count found it immediately:
fade words matching 0x??ffffff at alternating strides of 24 and 16, which
is a 40-byte block carrying fade at +0 and tint at +24 -- exactly the
build layout, starting at +0x68. So "a leaf record's keyframes are not in
the build's 40-byte layout" is withdrawn. They are. My scan failed
because I demanded 29 strictly-increasing times, having read the word at
+0x004 as a keyframe count; the records hold THREE.

Decoded, both are horizontal light sweeps. ptloop01 runs pteff03.t32
from x=-639 to x=1521 over t=150..600, which is 450 units or 7.5 s;
ptloop02 runs pteff03a.t32 the other way over 570 units or 9.5 s.

Which refutes the second thing. Last iteration I attributed the measured
2.2 s oscillation to these elements because build 4 declares them --
association, not evidence. A 7.5 s cycle would give about three peaks in
a 22 s capture and I counted eight. So the loops are not what I measured,
the 2.2 s stands as a measurement, and its source is now explicitly
unidentified rather than wrongly assigned.

METHOD gets the general form, because it is what cost the previous
iteration: do not hard-code an expected element count into a structure
scan. Scan for the shape and let the count fall out.
2026-08-28 20:53:08 +00:00
Sylpheed RE agent
00ce0f33d0 port: carry the loop-cycle negative into the handoff
The previous commit's handoff edit failed its own assertion -- the text
had been rewrapped since I wrote the pattern -- so the finding landed in
docs/re and REFUTED but not on the page the port agent actually reads.
An answer not reachable from HANDOFF.md has not been delivered.
2026-08-28 20:49:39 +00:00
Sylpheed RE agent
a048910497 re: the loop record is located, its cycle is still not readable
Continuing the one gap from last iteration -- turning the title's
measured 2.2 s loop into a decoded cycle. It did not turn.

The record itself is found and partly confirmed. ptloop01.rat is an
opt-linked leaf RATC at 0xbb5966 in build 4, placing pteff03.t32, with
0x1e -- thirty -- in the high half of the word at +0x004, which is the
~30 keyframes ui-rat-layout.md predicts for a loop record. Its pivot
fields read 200 and 90, matching exactly what screen info prints for
ptloop01, so this is the right blob and the header offsets hold.

The times are not there. A build's keyframes are 40-byte blocks with the
time at +36; scanning every 4-byte-aligned start across a 0xC0 window for
29 strictly increasing values at that stride finds nothing, in either
loop record. So a leaf record's keyframe layout is NOT the build
placement layout, which is now a line in REFUTED because it is the
obvious first assumption and it is wrong.

The 2.2 s stays measured and the port hardcodes it. What the next attempt
inherits is the record's address, a confirmed pivot, and one layout ruled
out -- which is the useful part of a negative.
2026-08-28 20:49:11 +00:00
Sylpheed RE agent
5f3d8f86a4 re: the title loops at about 2.2 s -- and the build-in test I intended
did not work

I set out to do for the title what worked for the splash: measure the
build-in and check it against the declared keyframes. That part failed,
and I am recording the failure rather than the number.

It failed for two reasons, both mine. The title was reached by skipping
the movie with A, which cuts to black and brings the title up on a path
that may not be the normal one. And the visible rise, about 2.7 s, is a
luminance curve -- which screen-transitions.md already establishes is not
the fade quad's ramp. So 2.7 s neither confirms nor contradicts build 4's
declared 16 -> 261, four point oh eight seconds. They are not measuring
the same thing, and I have said so instead of reporting a discrepancy.

What the recording did establish is worth having anyway: the title never
settles. Over 22 s the mean luminance oscillates continuously, peaks at
5.6, 7.8, 10.0, 12.7, 15.2, 17.1, 19.2 and 21.3 s, mean interval 2.24 s.
Marked as about 2.2 s plus or minus 0.4 rather than a precise period,
because peak-picking a low-amplitude signal is coarse.

The mechanism was already decoded and I did not re-derive it: build 4
declares ptloop01.rat and ptloop02.rat, and loop*.rat is a looping sprite
animation rather than a composition. What is new is the period, and that
it runs forever. For the port that matters -- drawing the resting pose
and stopping will look wrong.

Not confirmed, and flagged: that 2.2 s is the loop's OWN declared cycle.
screen info prints the element's placement keyframes but not the frame
count inside the .rat, and I did not open it.
2026-08-28 20:45:53 +00:00
Sylpheed RE agent
113c25aac1 re: the splash's timing is decoded, and the fade-out matches to 0.03 s
Closing the caveat I left last iteration -- the splash timing was +/-0.5s
because I sampled at 2 fps, and at that rate I could not see ramps at
all, only plateaus.

Re-recorded at 10 fps: the splash FADES, both in and out, rather than
cutting. And the bundles declare it. palogo_sqex.t32 carries keyframes
[15 30 235 239 251 255] and palogo_gamearts.t32 [15 30 190 194 206 210],
each with an _eff glow child on [15 30 45]. Under Q1's 1 unit = 1/60 s
that is a 0.25 s ramp in, a 3.42 s or 2.67 s hold, and a 0.33 s fade out
-- against measured holds of about 3.5 s and 2.4 s and fade-outs of about
0.3 s. The constant 0.35 s offset between declared and measured start is
just that my recording's t=0 is when the WINDOW appears, not when the
guest starts drawing.

So the first screen's animation moves from measured to decoded: the port
reads it off the disc instead of trusting my stopwatch.

It also explains something the capture showed and I had no account for.
The brightness overshoots on the way in -- peaks at 0.8 s, settles by
1.1 s -- which reads as a bloom. It is the _eff glow child, whose
keyframes run 15 -> 30 -> 45, ramping in after the logo and back down
while the logo holds. Mechanical, not a rendering artifact.
2026-08-28 20:39:17 +00:00
Sylpheed RE agent
9f39e8ee72 re: the splash confirmed against the running game, and timed
Closing the amber I raised last iteration: the splash renders had no
framebuffer capture to diff against. Recording the boot from the moment
the window appears, at 2 fps, catches it before the movie.

Both halves match their own render at zero shift, each with the OTHER
half as a negative control: publisher frame 0.9146 against entry 10 and
0.027 against entry 11; developer frame 0.9792 against entry 11 and
-0.033 against entry 10. The EN/JP twins score 0.876 and 0.966, so the
test tells a screen from a different screen but cannot tell a pair apart,
and I have said so rather than implying it discriminates.

The recording also times the front of the boot, which Q6 and Q7 only had
from the title onward: SQUARE ENIX from about 0.5 to 4.0 s, black,
developer logos from about 5.0 to 7.5 s, black, then ADV.wmv from about
9.5 s.

One trap avoided and written into METHOD. The frames around 9.5 to 12.5 s
show SQUARE ENIX again in cyan, and read from the capture alone that is a
third logo screen. It is not -- it is the intro movie's own opening,
which the milestone-2 notes describe as white SQUARE ENIX plus cyan glow
plus red diamonds. A logo appearing twice may be two different things.
2026-08-28 20:35:10 +00:00
Sylpheed RE agent
b302558d26 re: the developer splash renders -- screen 1 of 5 finally has a composite
Two pages of this corpus disagreed. MISSION says the splash "is the RATC
screen, which already renders"; ui-paint-order-key.md says it "cannot be
rendered by screen render at all". Running the tool both ways settles it.

screen list --all shows all 16 GP_TITLE entries instead of 12, and the
four the default listing drops are the splash, each half shipped twice:
entries 10 and 13 are the white SQUARE ENIX publisher logo, entries 11
and 14 the GAME ARTS / SETA / studio anima developer logos. Entry 11's
seven elements are the three logos, their three _eff glows and the
palogo_eff0.prm backdrop -- exactly the composition ui-paint-order-key.md
had measured for the splash without being able to draw it.

So the "cannot be rendered" line is wrong and is corrected in place. What
is true is narrower and worth keeping: the splash is invisible to the
DEFAULT listing because is_build wants a .rat child, so anyone who does
not pass --all concludes it is missing. That goes in METHOD -- a default
filter can hide a whole screen and the corpus will record it as absent.

The payoff is for the port rather than for the RE: the first of the five
screens now has a reference composite, which it did not have. Marked
amber on one point -- there is no framebuffer capture of the splash in
this repo, so the match to the running game is by description against the
milestone-1 notes, not by pixels.
2026-08-28 20:29:33 +00:00
Sylpheed RE agent
cc72f8b5a8 re: the phase transitions -- and withdrawing yesterday's refutation
because the test probed the wrong state

Nine stores to the phase field this+132, control-gated on the 136 query
returning at least the 18 known ones. Attributed to the handler each
lives in, they give the phase graph: entry -> 2, then 2 -> 0 (splash),
2 -> 3, 3 -> 4, and 4 -> 2 on event 0.

That last edge is the problem with what I did last iteration. The event-0
block sets TWO fields one instruction apart -- stw r28,136(r30) for state
0 and stw r11,132(r30) with r11 = 2 for phase 2. So B from the menu lands
in PHASE 2, the same phase as the boot title, and the test I ran
believing it probed phase 4 state 0 probed nothing of the kind.

So the refutation is withdrawn. Worse for me and better for the idea: A
working on the B-returned title is exactly what the hypothesis predicts,
since phase 2 is the phase that references BUTTON. The hypothesis is back
to untested, now consistent with two observations rather than one, and
what it still needs is the attract-returned title's phase -- which no
test so far has read.

The measurement from that iteration stands, because it does not depend on
the phase: the B-returned title accepts A, so only the ATTRACT-returned
title is inert. I have said so explicitly in the page rather than letting
the withdrawal take the good half down with it.

METHOD gets the lesson: before testing "state X behaves like this", check
your route actually reaches state X. When a transition writes more than
one field, read the whole block and not just the store you were looking
for.
2026-08-28 20:25:06 +00:00
Sylpheed RE agent
4830c63402 re: my phase hypothesis is dead, and the input puzzle is narrower than
this corpus thought

Last iteration I proposed that the boot title is phase 2 and the
attract-returned title is phase 4 state 0, and that only phase 2 wires up
A -- a candidate answer to the oldest open question in
canary-scripted-input-traps.md. I marked it untested and named a test
that needed reading this+132 at runtime.

There was a cheaper one. The decoded graph says B from the main menu is
the edge 4 -> 0, so the B-returned title IS phase 4 state 0. If that
state were the inert one, A would not work there.

A works. Control first -- A on the boot title opened the menu -- then A
on the B-returned title opened it too. So phase 4 state 0 handles A and
the hypothesis is refuted, one iteration after I raised it. Recorded in
REFUTED under my own name, and the conditional stated: the edge is
decoded, but which title it produces is my inference.

The test earned something better than it cost, though. That page frames
the problem as "the boot title accepts A, a later title does not", and
that is too broad -- the B-returned title accepts A perfectly well, a
case nobody had tried. Only the ATTRACT-returned title is inert. Whatever
causes it is tied to the attract cycle specifically rather than to the
title having been shown before, which is a much smaller haystack.

Also reproduced in passing: 4 s after B the plate is absent and fades in
after, the same near-miss this corpus has already been caught by once.
2026-08-28 20:21:23 +00:00
Sylpheed RE agent
4380305921 re: phase 0 is the splash, phase 2 is the title -- and a candidate answer
to this corpus's oldest input puzzle

Continuing Q6 on the phases left unread. Strings each phase handler
references, plus whether it carries its own jump table:

  phase 0  sub_821C5690   LOGO                                    no switch
  phase 2  sub_821C5818   BASE_INFO, BUTTON, TITLE_SCREEN         no switch
  phase 3  sub_821C5EC0   (none)                                  one switch
  phase 4  sub_821C6458   BASE_INFO, LOADING, TITLE_MENU, TITLE_SCREEN

Phase 0 referencing LOGO is a second independent confirmation that it is
the developer splash -- the iterate3E notes reached the same function
from the guest side and named the splash's LOGO items. Phase 2 draws the
title WITH the PRESS A plate, which the archive side had already
established is a build of its own.

Which produces something worth more than either: the title is installed
from TWO places, phase 2 and phase 4 state 0. Same screen, different
code. canary-scripted-input-traps.md has recorded for months, and never
explained, that the boot title accepts A while the attract-returned title
accepts nothing, with the giveaway that a draw capture in each is
identical. Two code paths installing one screen is exactly that shape. I
have written it into that page as a candidate with the cheap test named
-- read this+132 on each title -- and marked it untested, because it is.

One query in this iteration failed its own control and I threw its half
away: counting stw rX,136(r30) per phase returned 0 for phase 4, which
has 18, because the operand text has a space the pattern did not allow.
The bctr half passes its control and is reported. METHOD gets the
underlying trap: instructions.function is unpopulated for most rows, so a
query scoped on it silently returns nothing.
2026-08-28 20:15:02 +00:00
Sylpheed RE agent
86e52b01d0 re: the menu's music is BGM_103 -- found by accident while chasing Q6,
confirmed three ways

Chasing where the event code comes from, sub_821C5580 turned out to do
two things worth having.

For Q6 it arms the outer gate: li r11,3 ; stw r11,16(r28) is exactly the
value sub_821C7850 tests before dispatching, which answers the "what does
this+16 == 3 gate on" question I left open last iteration. It then passes
r5 = [r27+4], an event read out of a structure, so the vocabulary is
still not enumerable. Of the four callers of sub_821C7850, three pass
constants -- 0, 0 and 5 -- and this one does not.

The accident is a few instructions earlier: li r5, 1103 into a sound-play
call. 1103 is a BGM cue id, BGM_103. That closes a residual I had written
off as undecodable: which bank the menu plays. The cue table genuinely
cannot say, since its BGM entries are numeric -- but the code can, and it
checks out three independent ways. The census says BGM_103.slb is two
waves of 3876864 and 3930112 bytes; the XMA probe at the main menu saw
two stereo streams of 3876864 and 3930112 bytes. Byte for byte on both.

That also corrects me. bgm-two-stems.md said those observed sizes matched
no bank's declared waves and inferred the decoder gets a window rather
than a whole wave. Wrong on both counts -- I had checked only the BGM_0xx
rows of the census because that was the block on screen. METHOD gets it:
check a measurement against the whole reference set, not the part you
happened to be reading.
2026-08-28 20:10:41 +00:00
Sylpheed RE agent
a98e96b0f0 re: the ten states are ONE PHASE of five -- and phase 0 is the splash
Chasing where the event code comes from turned up the level above and
corrected my own framing three sections running.

sub_821C6458 has exactly one caller, sub_821C7850, and that caller is
itself a dispatcher -- on a SECOND, outer state field at this+132, five
phases, gated on this+16 == 3. Phase 4 is the title/menu machine I have
been decoding. Phase 0 is sub_821C5690, which the iterate3E notes had
already fingered as the splash mechanics from a completely different
direction. That agreement is the useful check here.

So GamePart_Title has two nested state fields: a phase at this+132
choosing which sub-machine runs, and the state at this+136 inside phase
4. Everything I decoded over the last three iterations -- ten states,
eighteen edges, the event dispatch -- is phase 4 ONLY. Phases 1, 2 and 3
are untouched. I have called that out in REFUTED against my own earlier
wording rather than quietly restating it.

And the event code is forwarded, not created: all five phase handlers are
called with the same (this, r29, r28), where r28 is sub_821C7850's own
third argument passed through untouched. So the event vocabulary is
defined at least one level further up, and finding what 3, 5, 8, 10 and
25 mean means going up again. Not done.
2026-08-28 20:05:27 +00:00
Sylpheed RE agent
52275ddd53 re: state 4's edges are selected by an event code -- 6 of 26 handled
Continuing Q6's last item: the condition on each edge.

sub_821C6458's third argument is an EVENT CODE, and state 4 -- reached
straight after the main menu is installed, so the input-waiting state --
dispatches on it through a second jump table at 0x821c6b7c, 26 cases.
Six are handled and twenty fall through changing nothing:

  event 0 -> state 0 (TITLE_SCREEN, back to the title)
  events 3, 5, 8, 25 -> state 8 (LOADING)
  event 10 -> state 5

So the edges I listed last iteration as "4 -> 0, 5, 8x4" are each now
attributed to a specific event, and the input state's graph is complete.

One thing I deliberately did NOT promote. The main menu has five items, B
returns to the title, and this table has one event to the title, four to
LOADING and one elsewhere. That is a tempting fit -- B plus four items
that load an external archive plus EXTRAS staying inside GP_TITLE -- and
it matches everything measured. It is also a count-match and nothing
more: the events are unnamed, nothing shows event 3 is a particular menu
row, and state 5 installs no named screen so the EXTRAS half has no
support at all. Written down as an observation with that said plainly,
because the same shape of reasoning already cost me DIFFICULTY and
EXTRA_MENU two iterations ago.

Still open: what the event numbers mean, the conditions on edges out of
the other states, and what states 1/3/5/6/7/9 do.
2026-08-28 20:00:49 +00:00
Sylpheed RE agent
6e92ae4597 re: the title's state machine is decoded -- ten states, eighteen edges
Q6's last open piece was which state leads to which, and it turned out to
be a plain switch.

state = this+136, compared against 9, dispatched through a jump table at
0x821C6498. The ten words at that address disassemble as lwz r16,N(r28)
instructions and are nothing of the kind -- they are the table. That is
the same "self-reference" I flagged two iterations ago as looking like a
jump table; it is one, and the disassembler was decoding its data.

Three states install a named screen: 0 is TITLE_SCREEN, 2 is TITLE_MENU,
8 is LOADING. Eighteen transitions, every one a literal li/stw pair into
the state field, giving the full graph.

It lines up with the behaviour measured weeks of iterations ago without
either side knowing about the other: boot reaches state 0 and A opens
state 2; B at the main menu returns to the title, and 4 -> 0 is the ONLY
edge back to state 0, reached from 2 -> 4; entering a submenu goes
through LOADING and comes back, which is 4 -> 8 at four separate sites
then 8 -> 2. I have marked that as corroboration and said plainly it is
me matching a graph to observations -- the conditions on the edges are
not decoded, so nothing here proves which input picks which branch.

Still open and written down as such: the condition on each edge, what
states 1/3/5/6/7/9 do, and the fact that state 3 is never a destination
in this function, so something outside sets it.
2026-08-28 19:56:22 +00:00
Sylpheed RE agent
c8c7677f6d re: the lookup argument is decoded at 46 of 48 sites -- and it refutes my
own corroboration from last iteration

Last iteration I listed a candidate name vocabulary from "upper-case
strings the callers reference", marked it amber because those are not
proven arguments, and named the check: confirm per call site which string
actually lands in r5. Ran the check. It fired.

The first extractor FAILED ITS OWN CONTROL -- a backward scan for
addi r5 recovered 7 of 48 sites and missed both title sites I had already
read by eye, because there the name is built in r27 and reaches r5 via a
later mr. Rewritten as a forward register simulation it reproduces all
three known sites and recovers 46 of 48, giving 28 distinct names.

Two of my own claims die. DIFFICULTY and EXTRA_MENU are NOT arguments at
any site -- they are strings that merely live in the same functions, so
the "independently corroborated by measurement" line I wrote was an
artifact of the loose filter. Only TUTORIAL_MENU survives. And
sub_821CC860 is not a screen factory at all: its real arguments include
BG, BLACK, FADE, FILE, KEY, PAD, SOUND and GAMMA_RGB. It is a generic
name-keyed lookup, mostly config, which the title part happens to call
with its three state names.

The title finding itself is untouched -- sub_821C6458 really does
transition by calling this with TITLE_SCREEN, TITLE_MENU and LOADING.
What is gone is the inference that the 28 names are a screen vocabulary.

Two METHOD lines, both earned here: strings a function references are not
arguments it passes, and a backward scan for a destination register
misses arguments staged through a scratch register.
2026-08-28 19:52:02 +00:00
Sylpheed RE agent
8b4965f0a5 re: a title-side transition is a screen lookup BY NAME, not by id
Continuing Q6's last residual -- the control flow I left unread.

All three state-name sites in sub_821C6458 compile to the same shape:
load the name, then bl 0x821CC860 with it in r5 and 0 in r6, then hand
the result to sub_82187B78. So a transition is lookup-by-string then
install: sub_821CC860(this+88, this+24, "TITLE_SCREEN", 0). Not a numeric
id and not a table index. That also explains something that had been
sitting unexplained -- GP_ADVERTISE_DEMO having zero xrefs -- because at
this level the screen graph is keyed by name rather than by GamePart id.

sub_821CC860 has 28 callers, and the upper-case identifiers in that
neighbourhood split cleanly into screen names and config keys. Three of
the screen names are corroborated by measurements I took before ever
opening this function: DIFFICULTY is what NEW GAME opens, EXTRA_MENU is
the EXTRAS submenu, TUTORIAL_MENU the lesson list. That is the static
side agreeing with the dynamic side on names neither knew about the
other.

Held at amber deliberately. The 35 strings are what those callers
REFERENCE, not proven arguments, and the list plainly mixes screen names
with things like TEXT_FONT and GAMMA_RGB. Confirming it means checking
per call site which string actually lands in r5, and I did not do that.

Still unread: which state leads to which. The three lookups sit in
different branches and TITLE_MENU's is guarded by a cmplwi/bne, but I did
not trace the branch structure, so the ORDER still comes from measurement
rather than from the code.
2026-08-28 19:46:43 +00:00
Sylpheed RE agent
2be5ba7934 re: the title part's states are named in the executable -- TITLE_SCREEN,
TITLE_MENU, LOADING

Taking the one residual I had ranked as worth doing: sub_821C6458, which
the previous note left explicitly unread.

It is the title part's screen-state function -- 4460 bytes, has EH,
called from exactly one place, sub_821C7850, which sits beside the
creator the registration site points at. Its string references are
TITLE_SCREEN, TITLE_MENU, LOADING and BASE_INFO. Those are precisely the
states measured off the running game, in the game's own words, and
BASE_INFO is this corpus's own marker for a screen-config lookup rather
than a table read.

That sharpens Q6's negative rather than overturning it. The title part
DOES ask for configuration keyed by TITLE_SCREEN and TITLE_MENU, and
config.ini -- the disc's only config -- has no such sections, only an
empty [SYSTEM] and [LANGUAGE]. So the game asks the question, the shipped
disc does not answer it, and the defaults live in code. The port can
transcribe the state names even though it must author their contents.

Marked amber, not green: this is characterisation from string references
and call counts, not a read of the control flow. Two self-references
inside the function look like jump tables, which is what a state machine
compiles to, but I did not confirm that and nothing here shows which
state leads to which.

One refutation recorded because it looked like a find for several
minutes: the {func, func, ptr} triples at 0x820a3b48 read convincingly as
a state/handler table and are nothing of the kind -- the bytes before
them are the tail of a RegisterToFactory diagnostic string and the data
column is zero-filled descriptors. Static-init records, not dispatch.
2026-08-28 19:42:39 +00:00
Sylpheed RE agent
2b0e66b6ca re: confirm, move and back -- every cue the five screens need is now
located, and two of them reproduce

Continuing Q8 rather than opening anything new. The gap that mattered was
the confirm cue: a menu needs a sound on A, and I had only cursor and
cancel.

The fix was where I was counting from. The first run started counting
streams at the main menu, so the confirm cue had already fired during
boot and showed up as "played before any input". Counting from the TITLE
instead attributes it cleanly: the A that advances title -> main menu
fires the 12288-byte wave at 0x5d6c0, together with the two stereo BGM
stems, which is the menu's music starting.

So move (0x1ec0, 0.533 s), confirm (0x5d6c0, 1.016 s) and back (0x0ec0,
0.344 s) are all located and decodable. Move and back came back with
IDENTICAL head bytes and sizes on a second independent boot, so the
dedup-keyed method is stable and those two are now n=2 rather than n=1.

Two honest limits recorded rather than smoothed over. The A press both
confirms and opens a screen, so its wave could be the cue the vocabulary
calls DECIDE or the one it calls SUB_WIN_OPN -- the port gets the sound
the game plays, not a name. And left/right fired no new stream, which
excludes a DISTINCT invalid cue but cannot exclude them quietly replaying
one of the three already heard, because the probe dedups on head bytes.
2026-08-28 19:38:09 +00:00
Sylpheed RE agent
b1b2576769 re: the UI cues decode -- 0.53 s, 0.34 s and 1.02 s of real audio
Finishing the step I left open last iteration rather than starting
something new. Offsets and packet counts were in hand; what was missing
was proof they are actually waves.

slb_extract_wave.py wraps a (bank, offset, packets, channels, rate) slice
in a synthesized XMA1 RIFF, following the layout the Rust decoder already
uses. The three located cues decode to 0.533 s, 0.344 s and 1.016 s of
mono 48 kHz audio, audible from sample 0, each with the percussive
attack-and-decay envelope of a UI blip. Bitrates come out at 12-15 kB/s,
about half the stereo BGM rate, which is what mono should be.

The control matters more than the results. The SAME wrapper applied to
BGM_001's first wave decodes to 173.808875 s -- identical to the duration
that bank's own on-disc RIFF header produced back when Q10 was answered.
So the header I synthesized is not approximately right, it reproduces a
known-good decode exactly, and the cue durations are trustworthy for the
same reason.

I did not commit the decoded audio. Three commands regenerate it from the
disc, and the corpus's job is measurements and tooling rather than
extracted game assets. The offsets, the packet counts and the tool are
the deliverable.
2026-08-28 19:32:16 +00:00
Sylpheed RE agent
0d7199bc22 re: the UI sound effects ARE extractable -- retracting "cannot be
extracted", and the tool was in the build all along

Two iterations ago I closed Q8 by declaring the SE audio undecodable:
Static.slb has no wave boundaries, there is no XACT container anywhere,
and I said the index "exists only at runtime" as though that put it out
of reach. The disc half of that stands. The conclusion did not.

This build of Canary carries a cvar called xma_param_probe, added by this
project, whose own comment says it logs each XMA stream's parameters and
head bytes so raw sound.pak entries can be matched to real decode params.
It has been sitting in the startup CONFIG DUMP of every log I have read
this session.

Run with it, driving the main menu: a d-pad move spawns a new mono 48 kHz
stream of 4 packets / 8192 bytes, and B spawns a different one of 2
packets / 4096 bytes. Searching their logged head bytes in Static.slb
finds each at exactly one offset -- 0x1ec0 and 0x0ec0 -- and the two are
contiguous, 0x0ec0 + 4096 = 0x1ec0. So the bank is a packed run of whole
2048-byte packets with no delimiters, which is precisely why the seek
scan found nothing: there is nothing to find. A wave is (offset, packet
count) and nothing else.

That splits Q8's binding cleanly. Event -> WAVE is now measured: the port
can have the audio. Event -> cue NAME is still a name match on the
authors' identifiers, and the page says so.

The same run settled something for Q10 too. Sitting on the main menu, TWO
stereo 48 kHz streams were decoding simultaneously. bgm-two-stems.md said
that observation was what it needed and that this container could not
make it; it can, and a music bank's two waves are now measured as
simultaneous rather than only inferred.

METHOD gets the general lesson, because it cost two iterations: check
what instrumentation the local build already has before declaring a
question blocked on tooling.
2026-08-28 19:30:03 +00:00
Sylpheed RE agent
aea529e7dc re: S00A.wmv confirmed off the running game -- the movie was never
ambiguous, the sampling was

Continuing last iteration's amber rather than starting something new. The
fix was already named there: stop using 5-second screenshots and record
the display.

Recorded with ffmpeg x11grab at 10 fps and matched every 0.5 s. Across
the 25 consecutive samples from capture 5.0 s to 17.5 s the S00A playhead
is strictly non-decreasing -- 1.0 through 11.0 s, advancing at
essentially real time -- with scores at or above 0.96 and four of them at
0.999 or 1.000 against a runner-up in the 0.78-0.83 range. S00A is the
top match on 23 of the 27 frames carrying signal. So MS00A -> S00A.wmv is
decoded from the manifest AND measured off the game, and the intro begins
about 4.5 s after A on the save slot.

The previous attempt's failure is worth its own METHOD line, because it
did not look like a sampling problem: it looked like weak evidence for
the wrong film. Sparse sampling produced contrast-23 frames, a playhead
that would not join up, and one frame preferring ADV. Sampling does not
weaken a signal gracefully; it turns it into noise shaped like a
different answer.

One aside recorded and not chased: in the S00A 5.5-10 s window, ADV also
scores 0.97-0.99 at its OWN monotone playhead of 33.5-37.5 s. Two films
matching strongly with two consistent playheads is not noise -- it reads
as the boot movie being a trailer cut from the story cutscenes, which
also explains why the sparse run kept flipping between them.
2026-08-28 19:23:57 +00:00
Sylpheed RE agent
dfcaac221e re: the new-game path completes, the intro is only weakly identified, and
this container kills long runs

Three results, and the middle one is deliberately not upgraded.

First: NEW GAME -> DIFFICULTY -> NORMAL -> SELECT DATA -> pick a slot ->
a movie plays, with no crash. The previous run's throw at 0x82307128 is
therefore not inherent to the menu path -- the same six presses got
through it -- which fits the incomplete-cache trigger the corpus already
names. n=1 either way, and the note says so. Worth recording because one
observation had already hardened once into "A on NEW GAME hangs", and it
would have hardened again into "the new-game path crashes".

Second: with the path open, the manifest's MS00A -> S00A.wmv finally got
a runtime test, and it did NOT reproduce the quality of the attract-movie
identification. The first four samples give a clean monotonic S00A
playhead of 2, 6, 9, 13 s and the strongest frame matches at 0.916, but
the run as a whole is not one advancing timeline, most frames sit at
contrast 23-37 where this page's own rule says discrimination is poor,
and one high-contrast frame prefers ADV. So it stays amber: consistent
with S00A, corroborating the manifest decode, not confirming it. A clean
answer needs the 30fps x11grab route rather than 5-second screenshots.

Third, and it bounds the rest: the emulator vanished at ~145 s with no
crash line in its own log, last entries routine MEM-WATCH at 1.15 GB RSS.
Second time this session -- the other was ~50 s into a boot. That is an
external kill on a box with ~1 GB free and swap exhausted, not a guest
fault. METHOD and the handoff's residue table both now say dynamic
experiments here have to fit in about two minutes of guest time, which is
why the DELTASABER plates went unseen again: they need a mission load and
the run never got there.
2026-08-28 19:19:14 +00:00
Sylpheed RE agent
bc6354dce0 re: the title presents at 28.5 fps idle -- the keyframe unit is settled at
1/60 s, and the 60 Hz reading is excluded

ui-keyframe-time-unit.md named its own re-test and nobody had run it: time
300 submitted frames on the IDLE title, where nothing is streaming from
the ISO. Reaching the title is cheap now that one A skips the intro, so
this cost one boot.

Two clean trials: 300 frames in 10.40 s and 10.60 s, i.e. 28.8 and 28.3
fps. That is the same rate as the 27.6 fps measured during the loading
splash, which is exactly the page's own criterion for "the game is 30 Hz
and the unit is 1/60 s". The 60 Hz alternative is now excluded rather
than disfavoured: it needs the emulator at 47% of real time while sitting
idle on a screen the capture says costs 1526 draws over 300 frames, about
five per frame. Nothing there halves an emulator.

So the durations stand as written -- title 4.2 s, main menu 1.1 s, EXTRAS
0.87 s -- and Q1 leaves the handoff's open-residue table. It stays
MEASURED, not decoded: no field on the disc says sixtieths of a second.

One trap, and it nearly cost the conclusion. My first attempt reported
39.5 fps, which sits between the two hypotheses and would have left the
question open. It was a harness bug: I polled the log for a "done" line
rather than for the COUNT of them increasing, so it matched a capture
that had already completed and timed 0.1 s of nothing. METHOD gets the
general form -- a log-polling probe must compare a count, not test for
presence.
2026-08-28 19:11:11 +00:00
Sylpheed RE agent
2c4005795b port: audit the handoff -- four stale statements, a broken link, and the
residue in one place

Every MISSION row is answered, so this iteration checked the deliverable
itself rather than opening new scope: an answer that is not reachable
from HANDOFF.md has not been delivered, and I have been editing that page
by surgical replacement for a dozen iterations.

Four things were wrong or stale:

- the Q5 row still said initial focus was 2x TUTORIAL, 1x NEW GAME while
  its own bullet said four boots -- I updated the body last time and not
  the row;
- the Q2 bullet still described the other four main-menu destinations as
  "likely by name -- an inference, not a measurement", which Q4 has since
  MEASURED, and it listed GP_MISSION_SELECT as a main-menu destination
  when MISSION SELECT lives under EXTRAS;
- the Q1 bullet claimed that if the game presents at 60 Hz "nothing else
  on this page changes". That is now false: Q7's declared fade-in lengths
  are keyframe-derived and would halve. The same bullet also missed that
  the transition capture is a SECOND independent measurement supporting
  1 unit = 1/60 s -- the quad is declared black for 12 units, 0.20 s
  under the conversion, and the capture measured the plateau at
  0.17-0.23 s. Recorded as corroboration, not proof, because the plateau
  spans two screens' fades and cannot separate them;
- bgm-two-stems.md linked ../port/MISSION.md from inside structures/,
  which resolves nowhere.

Also named the two videos actually in scope, since "3.3 GB of video, two
files are in scope" is not much use without the filenames.

And added a What Is Still Open section. With every row green the residue
was scattered across eight pages; it is now one table with why each item
is stuck. Two entries share a single unblocker -- an emulator whose audio
path can be observed -- and they are the only ones that would change a
port decision.
2026-08-28 19:02:49 +00:00
Sylpheed RE agent
f5e7426cfa re: there is no XACT container on this disc -- retracting my own lead and
closing Q8

Last iteration I named Pj_Silph.xgs as the route to the SE cue index, on
the reasoning that XACT keeps cue->wave there. That route is dead, and
the negative is properly bounded: its 533-byte entry is high entropy with
no XGSF magic, +/-8 KB around it has none either, all 1.08 GB of
sound.pak contains zero XGSF, SDBK and WBND, and the executable has no
XACT or .xgs string anywhere. Control run first -- the same scan finds
RIFF in BGM_001 exactly where the bank structure says it should be, so
the scan works and the magic genuinely is not there.

So the .xgs and .slb extensions came from the authoring tool and survived
into shipped data that is not XACT at all. That goes in METHOD as a
general trap: check for the magic before committing to a parser for the
format an extension advertises. Writing an XACT parser would have been
wasted work.

What that leaves for the SE audio is undecodable-with-reach rather than
pending: Static.slb has no wave boundaries, there is no project file, and
none of the sound table's five records carries an offset. The bank is
certainly the right one -- 8 353 472 bytes over 322 cues is 0.84 to 1.01
seconds each at the bitrates the disc uses elsewhere, exactly the shape
of a bank of short effects. Only the index into it is missing, and it
exists only at runtime.

Q8 closes: vocabulary and bank decoded, the event binding a name match on
the authors' own event names, and the audio explicitly something the port
must source or author rather than extract. With this every MISSION row is
answered.
2026-08-28 18:58:06 +00:00
Sylpheed RE agent
c760cdc509 re: the boot sequence is not data-driven -- closing Q6 with the negative
rather than leaving it amber

Q6's second half asked what the game READS to decide the boot order. The
answer is nothing, and the value here is the reach rather than a find.

Four places checked, the order in none of them: config.ini's [SYSTEM] is
empty and it is the disc's only config; the movie manifest carries the
boot-side assets but no transitions; the requested GamePart id was
already shown to exist only as a stack argument in flight, with no
persistent field and no literal store; and the string GP_ADVERTISE_DEMO
has zero xrefs of any kind, so nothing in the code reads the attract
entry of the id table. A transition is a call with an id argument.

Traced as far as it goes cheaply: the RegisterToFactory<0,
GamePart_Title> string is referenced from exactly one site,
sub_8280E148, which also takes the address of sub_821C7D98 -- where a
factory template puts its creator. Marked amber, because that is position
and convention rather than proof, and sub_821C7D98 has zero .rdata
references, which fits a new+ctor thunk and not a state machine. The
substantial function in that neighbourhood is sub_821C6458 and I did not
read it.

So Q6 closes as answered with the driver classified as code rather than
data, which means the port AUTHORS the sequence -- and that is fine,
because the sequence itself is measured end to end and the handoff now
carries it in one line.
2026-08-28 18:53:14 +00:00
Sylpheed RE agent
c8e8dc0427 re: what the game actually reads at boot -- config.ini, and which
GameParts exist at all

Q6's second half, advanced but not closed, and the negative is the point.

config.ini is the disc's ONLY config file -- one find over the whole
extract -- and its own Shift-JIS header calls it the "Application /
GamePart initial settings table". Its [SYSTEM] section, which that same
comment says holds what the game and every game part share, is EMPTY. So
the boot order is not in disc-side configuration at all, and that search
space is now closed rather than merely unexplored.

What the file DOES carry is the language: XC_LANGUAGE_* -> eng/jpn/deu/
fra/esp/ita, defaulting to eng. That is the mechanism behind the EN/JP
build pairs in GP_TITLE and the <lang>.pak families -- a question the
corpus had described but never traced to its input.

Then the registry. Pulling every RegisterToFactory diagnostic string
binds 24 of the 29 GamePart ids to a C++ class, and five ids have no
registration site: 1, 2, 16, 18, 28. Id 1 is GP_ADVERTISE_DEMO, which
agrees with what I measured two iterations ago -- the attract loop is the
TITLE replaying ADV.wmv, not a transition into an advertise part. Marked
amber, not green: it is an argument from an error message, not from code.

Two things fall out for Q4: ids 3 and 4 are the same class
(GamePart_SaveLoad, one part with two ids), and the menu buttons' ids now
match the executable's own class names rather than a list of table names.
Still a name match, one level closer to the code.

What is still missing is the transitions themselves, and I say so: the
manifest gives the boot-side assets, config.ini the language, the
registry which parts exist. What decides to advance is in
GamePart_Title's code and that dig has not been started.
2026-08-28 18:49:19 +00:00
Sylpheed RE agent
935f7ecab8 re: one A skips the intro, and NEW GAME was never a hang
Two corpus claims died in one boot, and both were costing us.

Q9's last row: a movie IS skippable with a single A. One tap ~45s into
the boot put the title up at ~57s, against a ~193s no-input baseline
measured over three boots, and Canary's own keystroke counter went 3->4
so exactly one press was delivered. The skipped-to title is fully
functional -- it draws the PRESS A plate and a second A opens the main
menu. What actually breaks the boot is hammering: the 88-press run in the
traps doc. The scripts' "tapping breaks the title" comment is too broad
and costs every scripted boot two and a half minutes.

Q4's last row: A on NEW GAME does not hang. It opens DIFFICULTY
(EASY/NORMAL/HARD/BACK, focus on NORMAL), then SELECT DATA, and only then
does the guest throw -- at PC 0x82307128, which is inside sub_823070B0,
the cache-manager STL erase this corpus already documents and which has
nothing to do with the menu path. The screen sat unchanged for 90s
because it was a menu waiting for input from a loop that never pressed
anything. That is now a METHOD line: a screen that never changes is not
necessarily hung, and the fix is to look at it and press something.

Also METHOD: never run ps -ef in this container -- all three long-lived
processes carry the entire loop prompt as argv.
2026-08-28 18:45:59 +00:00
Sylpheed RE agent
1f822f8b30 re: the menu's sound events are named on the disc; the binding to them is
not

Q8. The cheapest thing nobody had tried was to look at the SE cue names,
and they are semantic: SE_UI_CURSOR (2), SE_UI_DECIDE (3), SE_UI_CANSEL
(4), SE_UI_IMPOSI (5, the error buzz), SE_UI_SUB_WIN_OPN/CLS, and
SE_UI_SPLASH_IN/OUT. That is exactly the move/confirm/back/error
vocabulary the question asks for, named by the authors after the EVENT
rather than the sound. 322 SE cues committed as reference data.

They all live in one bank: BANK_SE is a single field reading Static.slb,
and the disc-wide check agrees -- 0 of the 322 has an entry in FILES, the
5135-path list that names every voice, briefing and BGM bank.

I am calling the binding a NAME MATCH, not a measurement, and the page
says so in the same breath as it gives the table. It is a stronger name
match than Q4's GamePart ids -- these are the authors' own event names --
but nobody has watched the game emit cue 2 on a d-pad press, and this
container is muted against a dummy device so there is no audio path to
watch either.

One new negative, with its reach. Static.slb's 8 353 472 readable bytes
contain zero RIFF, zero seek and zero WAVE, scanned over the whole
buffer. The boundary marker that works for all 7 620 other banks is
absent, so the cue is named, the bank is named, and the wave inside it is
not locatable. The UI sound effects cannot be exported yet, and that is a
gap in the assets rather than in the naming. The named next step is
Pj_Silph.xgs -- the XACT project SETTINGS points at, which IS in
sound.pak at TOC 9454, though its 533 bytes carry no XGSF magic and its
region's phase says they are probably the previous bank's tail.
2026-08-28 18:33:58 +00:00
Sylpheed RE agent
e81dcad632 re: the boot intro and the attract loop are the same video, and the
new-game intro has a name

Q9, answered twice over -- once from the disc and once from frames I had
already captured and not used.

The movie manifest's first eight slots are the whole boot-side flow, and
the slot key is the role: LOGO1-4 -> logo1-4.wmv (not on the disc, which
is why the splash is a screen), ADVERTISE_MOVIE -> ADV.wmv, STAFF_ROLL ->
the credits reel, MS00A -> S00A.wmv, MS01A -> S01A.wmv. So the new-game
intro is S00A.wmv, decoded -- which is the half I could not test at
runtime, because A on NEW GAME hangs the emulator.

And there is no separate boot-intro slot: ADV.wmv IS the advertise movie,
the boot just plays it first. Confirmed independently by matching 19
attract frames against five candidates -- 15 hit ADV with a playhead that
advances monotonically at the sampling rate and ends at 137 s, its full
length, with the title back on the next sample. So the attract movie
plays to its end; nothing cuts it short.

That corrects me. Two iterations ago I recorded the attract movie as
"~85 s, so probably not ADV.wmv" -- arithmetic on a start I never
observed, since sampling began 39 s in. REFUTED and METHOD both take it,
along with the matcher's real failure mode: a near-black frame has no
signature, and its runner-up is not evidence.

Skippability I did NOT settle, and the corpus contradicts itself: one
page says A skips a movie every time, while the boot harness deliberately
never taps during one because it breaks the title. Named the one-boot
test rather than picking a side.
2026-08-28 18:29:39 +00:00
Sylpheed RE agent
e64965b3f9 re: S1 -- the Ready Room probe is a no-go, and not for the reason the
question expected

One iteration, as the mission gates it, and no Ready Room work started.

The two criteria it named both pass. It is NOT 3D: the corpus already
holds a capture of the running screen and it is a flat six-item menu in
the same visual language as the title. And unrecoverable paths do not
mean unrenderable screens -- content enumeration finds 60 builds in the
1106 entries.

What kills it is something the question did not ask. GP_READY_ROOM.pak
does not appear to hold the Ready Room's own menu at all. Two signals
agree: the four largest builds composite to briefing panels -- a planet
readout, unit markers, direction arrows -- and, much harder, the 902
bundles declare 317 distinct element names of which NONE matches any of
the six labels on the screen (takeoff, brief, hangar, pilot, option,
mission, ready all return nothing). The pak is the briefing /
tactical-map content behind the BRIEFINGS item. If this is ever picked
up, the first job is finding where the menu lives -- GP_BUNK.pak is the
obvious place -- not naming this pak's keys.

The probe also reaches back into something already delivered: kind ==
0x3002 finds ZERO buttons across all 902 bundles here, while GP_TITLE has
16. 0x3002 is one member of a 0x3000 family with sub-bits, and this pak
uses 0x3000/0x3004/0x300c/0x3008. Nothing in the milestone changes --
every screen in scope is GP_TITLE -- but the handoff now says not to ship
that equality as a general button test.
2026-08-28 18:24:48 +00:00
Sylpheed RE agent
b3c8632006 re: a music bank is two stems that play together -- not intro+loop, and
not something to concatenate

Q10, and it starts by withdrawing the question's own premise. BGM_001 is
not three sub-waves of 10 KB / 4.47 MB / 4.67 MB: the 10 KB is the bank
header. A bank is exactly TWO waves, and across all 32 BGM banks on the
disc the two always have the SAME duration -- equal to 0.01 s over
lengths from 37 s to 277 s. That alone kills intro+loop and kills two
halves of one piece, both of which require unequal lengths.

Four banks appear to break the rule and do not: BGM_106-109 are the known
leading-region straddle, and the giveaway is that the entry named
BGM_107.slb contains BANK id=1108. The seek packet counts pin each join
exactly, so they realign to the same two-equal-waves shape rather than
being dropped as noise. That trap goes in METHOD.

Then the roles, by decoding both waves to PCM. They are
sample-synchronous: transient-envelope correlation searched over +/-5 s
peaks at lag +0.00 s, and both waves stop at the same millisecond,
167.663 s. Two stems of one performance, meant to sound at once. Wave 1
is quieter, has almost no bass and is far more L/R-decorrelated, which
reads as a surround-rear pair or a second intensity layer -- I cannot
separate those two from the file, and say so: ChannelMask is 0x0002 on
both, and this game's channel metadata is already documented as
meaningless.

Two things the port needs that are NOT on the disc, both marked as
authored: the track is not a seamless loop (BGM_001 fades out and is
followed by 6.15 s of silence, no loop-point field found), and nothing
names which bank the menu plays -- all 32 BGM cues are numeric.
2026-08-28 18:13:10 +00:00
Sylpheed RE agent
7fef19b3a0 re: the transition between screens is a fade through black, and most of
its timing is on the disc

Q7. Every title-side screen carries a full-screen black .prm quad that
paints last, and its keyframe group IS the transition: black at T0, clear
by T1, clear until T2, then back to black on exit. Read with the corpus's
start-of-a-ramp rule and Q1's time unit that gives 0.87s for EXTRAS,
0.97s for the main menu, 4.08s for the title -- from the file, not from a
stopwatch.

The disc-wide check is per-pak all-or-nothing rather than the 41% the
headline count suggests, and GP_TITLE's 6 of 12 is the useful row: the
six builds carrying a fade quad are exactly the six SCREENS, and the six
without are exactly the six overlays. GP_DIALOG is 0 of 133. That is
independent corroboration of the overlay finding from two iterations ago.

One piece is NOT on the disc and says so: the fade-OUT length. The fourth
keyframe has no time slot, because a group's last block stops four bytes
short. Measured instead, at 30fps, ~0.4s and the same both directions.

And a warning I earned: the luminance rise after a transition is NOT the
quad's ramp. The incoming screen's own elements animate in after the quad
has cleared -- 1.47s observed against a declared 0.97s. Time the fade
from where the frame is pure black.

Rig: screenshot samples at 0.5 Hz and cannot see a 0.4s fade at all,
which is why an earlier burst called this an instant cut. ffmpeg x11grab
at 30fps instead; both go in METHOD.
2026-08-28 18:05:19 +00:00
Sylpheed RE agent
4e745c8177 re: the title menu wraps -- Q5 measured, Q4 driven, and one of my own
method lines withdrawn

Q5, measured off two boots: up/down move one item and WRAP at both ends
on the 5-item main menu and the 3-item EXTRAS alike; left/right do
nothing; B goes up one level and restores focus to the item you came
from (4/4); B on the main menu returns to the title; B on the title does
nothing. The menu opens on TUTORIAL -- the middle item -- 2/2, though a
third recorded run implies NEW GAME, so that one is reproducible rather
than invariant and says so.

Q4 by driving: LOAD GAME opens the save-slot list, TUTORIAL the lesson
list, OPTIONS the settings menu, EXTRAS build 6, MISSION SELECT the stage
list. NEW GAME is not tested -- A on it hangs the emulator and this
iteration needed the session. The GamePart ID behind each is NOT
measured: it is the entry of the decoded id table whose name matches the
screen I saw, and the page says so rather than wearing the badge.

And the withdrawal. Last iteration I wrote that these menus drop d-pad
presses shorter than ~0.3s. They do not. Once wrap is measured, every
press count I had is exactly right -- four presses moved four steps
THROUGH the bottom, which lands one above where a non-wrapping menu
would. I invented hardware flakiness instead of testing the ends of the
list. METHOD keeps the withdrawal rather than deleting the line.

Also: label brightness is not a cursor oracle here -- the background art
outshines the highlight on some rows. menu_focus.py reads the focus ring
instead, 254 vs <82, no tuning.
2026-08-28 17:53:46 +00:00
Sylpheed RE agent
bebdb18a02 re: Q3 was already answered in docs/re -- deliver it, and test it on EXTRAS
The handoff had Q3 as "runtime-solved only". It is not: the layer key at
+0x0A of the T8aD sprite header, stable-sorted, is a file-derivable paint
order, already checked against five measured orders and already driving
the compositor for every build on the disc. That answer had simply never
reached the page the port agent reads.

The new evidence is EXTRAS -- the first screen composited from the rule
alone and scored against a framebuffer capture the rule had never seen.
0.9620 at zero shift, against a same-tooling control of 0.9657 on the
screen the rule was fitted to.

Written down with its limit rather than its headline: align_to_capture
correlates edges, and a paint-order change moves blends, not edges, by
=<45/255 on a few per cent of pixels. So the score is evidence for
placement and only CONSISTENT with the order. The sharp A/B needs a
rebuild and is named in the doc instead of being claimed.
2026-08-28 17:39:29 +00:00
Sylpheed RE agent
88b3ce9af5 re: which GP_TITLE build is which screen, measured against the game
Q2. The archive is eight screens shipped twice, English and Japanese --
not the "build 4 title, 5 main menu, 6/8/9 submenus" the handoff claimed.
Build 8 is the JAPANESE main menu; 6 and 9 are the EN and JP EXTRAS, and
EXTRAS is the only submenu GP_TITLE holds. The PRESS (A) BUTTON plate is
its own build (2/3), composited over the title art and faded in a beat
later, not a state of build 4.

Confirmed by booting to the main menu and walking it: title, PRESS (A),
main menu and EXTRAS each match their render element for element. Builds
0/1 and 10/11 -- a DELTASABER / SYLPHEED A.I. plate -- were looked for in
the whole boot filmstrip, every title-side screen and the attract loop,
and appear in none of them; the reach of that negative is written down
rather than filled in with a guess.

Two rig traps went into METHOD: the menus drop d-pad presses shorter than
~0.3 s, and a grab 2.5 s after a transition can catch a screen mid-fade
-- which nearly wrote "the returned title has no plate" into the corpus.
2026-08-28 16:55:33 +00:00
111 changed files with 10064 additions and 77 deletions

View File

@@ -523,7 +523,7 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
println!();
println!("geometry — decoded sprite size vs the declared pivot, and every keyframe");
println!(
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=fade-alpha",
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=alpha r=rot°",
"#", "sprite", "decoded", "pivot*2", "same"
);
for el in &b.elements {
@@ -543,8 +543,13 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
.keyframes
.iter()
.map(|f| {
let rot = if f.rotation_deg != 0 {
format!(" r={}", f.rotation_deg)
} else {
String::new()
};
format!(
"{}: {},{} {}%,{}% a={}",
"{}: {},{} {}%,{}% a={}{rot}",
f.time.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
f.x,
f.y,

View File

@@ -0,0 +1,136 @@
//! Does the DERIVED paint order reproduce the ones measured from the game?
//!
//! `compose` uses a measured order for the three builds that have one and falls
//! back to `derived_paint_order` (a sort on each sprite's layer key) everywhere
//! else. The doc comment claims the derived order "reproduces both measured
//! orders up to ties" — this checks that claim against all three, and says what
//! the ties actually cost.
//!
//! cargo run -p sylpheed-formats --example paint_order_audit -- <GP_TITLE.pak>
use sylpheed_formats::{pak, ui_layout};
fn measured(names: &[&str]) -> Option<(&'static str, Vec<usize>)> {
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",
];
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",
];
const MENU: [&str; 16] = [
"pteff00.prm", "ptbase.t32", "pteff05.t32", "ptloop01.rat",
"ptloop02.rat", "pteff02.prm", "ptframe1.t32", "ptframe2.t32",
"pteff10.t32", "pteff12.t32", "ptbtn01.rat", "ptbtn02.rat",
"ptbtn03.rat", "ptbtn04.rat", "ptbtn05.rat", "ptmsg.t32",
];
if names == TITLE {
return Some(("title", 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 { return Some(("splash", vec![0,2,4,6,1,3,5])); }
if names == MENU {
return Some(("main menu", vec![1,3,4,2,5,8,9,6,7,15,10,11,12,13,14,0]));
}
None
}
fn main() {
let path = std::env::args().nth(1).expect("usage: paint_order_audit <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
let mut checked = 0;
let entries: Vec<_> = ar.entries().to_vec();
for (i, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect();
// Every build: how exposed is it to tie-breaking? A tie between
// OVERLAPPING elements is where a derived order can go visibly wrong.
let keys_all: Vec<u32> = build.elements.iter()
.map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX))
.collect();
let mut tie_pairs = 0;
for a in 0..keys_all.len() {
for b in (a + 1)..keys_all.len() {
if keys_all[a] == keys_all[b] && keys_all[a] != u32::MAX { tie_pairs += 1; }
}
}
// Of the tied pairs, how many OVERLAP? Only those can paint visibly
// differently under an arbitrary tie-break. Rect from the declared
// pivot (= half the sprite for a .t32) at the resting placement.
let rect = |e: &ui_layout::Element| -> Option<(i32,i32,i32,i32)> {
let kf = e.rest()?;
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 { return None; }
Some((kf.x, kf.y, w, h))
};
let mut tie_overlap = 0;
for a in 0..keys_all.len() {
for b in (a + 1)..keys_all.len() {
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; }
let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b]))
else { continue };
let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0);
let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1);
if ox > 0 && oy > 0 { tie_overlap += 1; }
}
}
let Some((label, want)) = measured(&names) else {
println!("entry {i:2} (no measured order) {} elements, {tie_pairs} tied pairs, \
{tie_overlap} of them OVERLAPPING", build.elements.len());
// Name them: these are the only pairs whose order can show.
for a in 0..keys_all.len() {
for b in (a + 1)..keys_all.len() {
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; }
let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b]))
else { continue };
let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0);
let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1);
if ox > 0 && oy > 0 {
println!(" overlapping tie: [{a}] {} x [{b}] {} key {} rect {:?} / {:?} overlap {}x{}",
build.elements[a].name, build.elements[b].name, keys_all[a], ra, rb, ox, oy);
}
}
}
continue;
};
checked += 1;
let got = ui_layout::derived_paint_order(&build, &bytes);
let keys: Vec<u32> = build.elements.iter()
.map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX))
.collect();
let exact = got == want;
// How many adjacent pairs in the MEASURED order does derived get wrong,
// and of those, how many are between elements sharing a layer key (a
// tie the sort cannot resolve) versus a genuine key-order conflict?
let pos_got: Vec<usize> = {
let mut p = vec![0; got.len()];
for (r, &e) in got.iter().enumerate() { p[e] = r; }
p
};
let (mut inv, mut tied) = (0, 0);
for a in 0..want.len() {
for b in (a + 1)..want.len() {
let (x, y) = (want[a], want[b]);
if pos_got[x] > pos_got[y] {
inv += 1;
if keys[x] == keys[y] { tied += 1; }
}
}
}
println!("entry {i:2} {label:10} {} elements", want.len());
println!(" derived == measured : {}", if exact { "YES" } else { "NO" });
println!(" inverted pairs : {inv} (of which same-layer-key ties: {tied})");
if !exact {
println!(" measured: {want:?}");
println!(" derived : {got:?}");
println!(" keys : {keys:?}");
}
}
println!("\n{checked} build(s) with a measured order were checked");
}

View File

@@ -0,0 +1,52 @@
//! Does DRAWING NOTHING beat guessing, for an element with no held pose?
//!
//! A keyframe group is entry → hold → exit, and the exit ends invisible (on the
//! five port screens the final keyframe is invisible for 21/24, 8/16, 12/18, 2/3
//! and 6/7 elements). So the screen "as seen" is the HOLD — which is why
//! `rest_plateau` is the primary rule. An element with **no** plateau has no
//! hold, and `rest()` currently falls back to guessing an endpoint of a movement.
//!
//! This renders each screen twice — as-is, and with every plateau-less element
//! suppressed via `compose`'s `visible` mask — and correlates both against the
//! live capture. If suppression wins, the fallback should draw nothing.
//! Writes both composites as raw RGBA (`<out>/entryNN_{asis,suppressed}.raw`,
//! 1280x720) so the correlation is done outside — this crate has no image
//! decoder and the comparison is not worth a dependency.
use sylpheed_formats::{pak, ui_layout};
fn main() {
let pak_path = std::env::args().nth(1).expect("usage: <GP_TITLE.pak> <outdir> <entry>...");
let ar = pak::PakArchive::open(&pak_path).expect("open");
let entries: Vec<_> = ar.entries().to_vec();
let outdir = std::env::args().nth(2).expect("outdir");
std::fs::create_dir_all(&outdir).ok();
for spec in std::env::args().skip(3) {
let idx: usize = spec.parse().unwrap();
let bytes = ar.read(&entries[idx]).expect("read");
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
// plateau-less = rest() had to guess: no two adjacent keyframes share a pose
// Default: suppress plateau-less elements. With SUPPRESS_SUBSTR set,
// suppress every element whose NAME contains it instead — used to test
// the entry→hold→exit model's prediction that the splash glows are all
// finished by the moment the logos are up.
let by_name = std::env::var("SUPPRESS_SUBSTR").ok();
let mask: Vec<bool> = build.elements.iter().map(|e| {
if let Some(sub) = &by_name {
return !e.name.to_lowercase().contains(sub.as_str());
}
let k = &e.keyframes;
(0..k.len().saturating_sub(1)).any(|i| {
k[i].fade == k[i+1].fade && k[i].scale_x == k[i+1].scale_x
&& k[i].scale_y == k[i+1].scale_y && k[i].x == k[i+1].x && k[i].y == k[i+1].y
})
}).collect();
let suppressed = mask.iter().filter(|m| !**m).count();
let opts = ui_layout::ComposeOptions::default();
let a = ui_layout::compose(&build, &bytes, opts, None);
let b = ui_layout::compose(&build, &bytes, opts, Some(&mask));
std::fs::write(format!("{outdir}/entry{idx:02}_asis.raw"), &a.rgba).unwrap();
std::fs::write(format!("{outdir}/entry{idx:02}_suppressed.raw"), &b.rgba).unwrap();
println!("entry {idx:2} {}x{} elements {:2} plateau-less suppressed {suppressed}",
a.width, a.height, build.elements.len());
}
}

View File

@@ -38,6 +38,18 @@ pub struct T8adImage {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
/// The header word at `+0x04`. A flag word; **bit `0x02`** is a candidate
/// blend selector — it separates the title's effect sprites from its ordinary
/// ones exactly, and disc-wide it toggles independently of the rest of the
/// word in 27.1 % of 19 216 sprites. See
/// `docs/re/structures/ui-paint-order-key.md`.
///
/// ⚠️ **Additive was tested and REFUTED.** Blending bit-`0x02` sprites
/// additively moved every measure against the title capture the wrong way:
/// whole-frame mean diff +0.55 → +1.04, swoosh-band mean +1.83 → +3.98, band
/// edge-correlation 0.6971 → 0.5578. The bit is real and independent, but it
/// does not select an additive blend. Carried, not acted on.
pub flags: u32,
}
/// Whether `bytes` starts with the T8aD magic.
@@ -108,6 +120,7 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
}
}
Some(T8adImage {
flags: be32(bytes, 4),
width: width as u32,
height: height as u32,
rgba,

View File

@@ -59,9 +59,9 @@ const DESIGN_H: u32 = 720;
///
/// ```text
/// +0 u32 ARGB fade colour — alpha ramps 0x00 → 0x80 → 0xd5 … over the group
/// +4 u32 0
/// +8 u32 0
/// +12 u32 0
/// +4 i32 ⚠️ NOT always 0 — see below
/// +8 i32 ⚠️ NOT always 0 — see below
/// +12 i32 ⚠️ NOT always 0 — see below
/// +16 u32 scale X, percent
/// +20 u32 scale Y, percent
/// +24 u32 tint (0xffff_ffff on every frame seen)
@@ -69,10 +69,39 @@ const DESIGN_H: u32 = 720;
/// +32 i32 Y ← signed
/// +36 u32 time
/// ```
///
/// ## `+12` is the screen-plane ROTATION, in degrees (2026-08-28)
///
/// ✅ **Measured against the framebuffer, not against our own renderer.** The
/// title's two light sweeps are the nested leaf records `ptloop01.rat` /
/// `ptloop02.rat`, and their keyframe blocks read `+12` = `30` and `-45`. A
/// `log_ui_draws` capture of the live title submits those two quads rotated by
/// **+30.26°** and **-45.28°** — magnitude *and* sign, on two different values.
/// Positive is clockwise in screen space (Y down).
///
/// `+4` and `+8` are 🟡 still unexplained: signed, non-zero in ~4.7 % / 4.6 %
/// of blocks disc-wide, dominated by `±180` and `±90`. Plausibly rotation about
/// the other two axes, but nothing observed turns on them.
///
/// ⚠️ **`rotation_deg` is decoded but NOT rendered.** [`crate::ui_layout`]'s
/// blitter draws axis-aligned quads only, so `screen render` still paints a
/// rotated element upright. See `docs/re/ui-title-build-map.md`.
///
/// ⚠️ The earlier note here — *"every element of `GP_TITLE` build 4 has all
/// three at zero"* — was **wrong about reach, not about the bytes**: build 4's
/// top-level elements do read zero, but the rotated quads come from its two
/// **nested** `.rat` leaf records, which the census never opened.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Keyframe {
/// The fade colour, ARGB. Its alpha is what ramps an element in.
pub fade: u32,
/// Screen-plane rotation in **degrees**, clockwise-positive (`+12`).
/// Confirmed against a GPU capture; see the type's docs. Not rendered.
pub rotation_deg: i32,
/// `+4` / `+8` — signed, meaning unexplained. Carried rather than dropped
/// so a consumer can see them instead of assuming they are zero.
pub unknown_4: i32,
pub unknown_8: i32,
/// Scale in percent (100 = 1:1).
pub scale_x: u32,
pub scale_y: u32,
@@ -154,6 +183,15 @@ impl Element {
/// Falls back to the longest-dwell rule when no two adjacent keyframes
/// agree — a group that ramps through every frame and never holds.
pub fn rest(&self) -> Option<&Keyframe> {
// `lastall`: the LAST keyframe for every element, bypassing the plateau
// rule entirely. This is what the shifted time reading predicts — under
// it the final pose is reached at a definite time and nothing follows,
// so "rest" needs no heuristic. Testing it against the captures is an
// independent check on that reading, from static composites rather than
// from animation timing.
if std::env::var("SYLPHEED_REST_RULE").as_deref() == Ok("lastall") {
return self.keyframes.last();
}
if let Some(k) = self.rest_plateau() {
return Some(k);
}
@@ -161,6 +199,26 @@ impl Element {
0 => None,
1 => self.keyframes.first(),
n => {
// ⚠️ EXPERIMENT GATE, default off. Both published alternatives to
// the longest-dwell fallback died by argument rather than by
// measurement, and `compose` can score a rule against the live
// captures — so they are reachable here to be tested.
// SYLPHEED_REST_RULE=last -> the final keyframe
// SYLPHEED_REST_RULE=maxalpha -> the most opaque keyframe
match std::env::var("SYLPHEED_REST_RULE").as_deref() {
Ok("last") => return self.keyframes.last(),
Ok("maxalpha") => {
let mut best = (0usize, 0u32);
for (k, f) in self.keyframes.iter().enumerate() {
let a = (f.fade >> 24) & 0xff;
if a >= best.1 {
best = (k, a);
}
}
return self.keyframes.get(best.0);
}
_ => {}
}
let mut best = (0usize, 0u32);
for k in 0..n - 1 {
let (Some(t0), Some(t1)) =
@@ -227,7 +285,24 @@ impl Element {
any = Some(i);
any_len = len;
}
if j != n - 1 && len >= best_len {
// A trailing run is normally the EXIT and is excluded (see the
// doc comment) — but not always, and the tell is its ALPHA.
//
// An exit fades the element out, so its final keyframe is
// transparent: `pgptitle.rat`'s trailing run is `0x00ffffff`, and
// taking it erases the word PAUSE. An element with **no exit**
// ends on its hold, which is whatever it looks like on screen —
// opaque: `ptframe1`/`ptframe2` on the title main menu end on a
// run of three at `0xffffffff`, and excluding it dropped the
// bright circuit bracket the capture plainly shows.
//
// So a trailing run is the hold exactly when it is **visible**.
// (The port agent's report proposed "the final untimed keyframe
// has the same pose as the last timed one"; that is true of
// `pgptitle` too and would erase PAUSE, so it is the alpha and
// not the pose-equality that separates the two.)
let trailing_is_the_hold = j == n - 1 && (self.keyframes[i].fade >> 24) != 0;
if (j != n - 1 || trailing_is_the_hold) && len >= best_len {
best = Some(i);
best_len = len;
}
@@ -404,6 +479,8 @@ fn mark_focused_states(elements: &mut [Element]) {
/// Read the placement region that follows the declaration table, filling in each
/// element's keyframe group.
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
// Experiment gate, default off; see the `time` field below.
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
let count = elements.len();
let mut order = Vec::with_capacity(count);
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
@@ -430,13 +507,25 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
}
group.push(Keyframe {
fade: be32(bundle, blk),
rotation_deg: be32(bundle, blk + 12) as i32,
unknown_4: be32(bundle, blk + 4) as i32,
unknown_8: be32(bundle, blk + 8) as i32,
scale_x: be32(bundle, blk + 16),
scale_y: be32(bundle, blk + 20),
tint: be32(bundle, blk + 24),
x: be32(bundle, blk + 28) as i32,
y: be32(bundle, blk + 32) as i32,
// Only a block wholly inside the group carries a time.
time: (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)),
//
// ⚠️ Which block a time word BELONGS TO is under test — see
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
// to read `W[k-1]` as block `k`'s time ("the word is the time the
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
time: if shift_times {
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
} else {
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
},
});
}
elements[idx].keyframes = group;
@@ -539,6 +628,9 @@ fn fallback_elements(bundle: &[u8], records: &HashMap<String, (usize, usize)>) -
pivot_y: be32(rec, 0x54),
keyframes: vec![Keyframe {
fade: 0xffff_ffff,
rotation_deg: 0,
unknown_4: 0,
unknown_8: 0,
scale_x: 100,
scale_y: 100,
tint,
@@ -811,8 +903,16 @@ pub fn compose(
// Measured paint order when one exists for this build, declaration order
// otherwise — see `measured_paint_order`.
// Measured order when this build is one of the two read off the running
// game; otherwise the order DERIVED from the sprites' layer keys, which
// reproduces both measured orders up to ties.
// game; otherwise the order DERIVED from the sprites' layer keys.
//
// ✅ Checked 2026-08-29 (`examples/paint_order_audit.rs`), because the
// previous wording here — "reproduces both measured orders up to ties" —
// was unmeasured and stale by one: there are THREE measured orders. The
// derived order reproduces the main menu and the developer splash EXACTLY
// (0 inverted pairs each) and differs on the title by 8 pairs, all of them
// same-layer-key ties, two being total occlusions. Of the port's five
// screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs,
// 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md.
let order: Vec<usize> =
measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle));
for &ei in &order {
@@ -919,8 +1019,11 @@ fn fill_quad(
if sw == 0 || sh == 0 {
return false;
}
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y };
// `scale = 0` is COLLAPSED TO NOTHING, not "unset" — see `blit`.
let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y);
if sx_pct == 0 || sy_pct == 0 {
return false;
}
let dw = (sw * sx_pct / 100).max(1);
let dh = (sh * sy_pct / 100).max(1);
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
@@ -977,8 +1080,16 @@ fn blit(
if sw == 0 || sh == 0 {
return;
}
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y };
// `scale = 0` means COLLAPSED TO NOTHING, not "unset". This used to coerce
// 0 → 100 %, which drew a fully-collapsed element at full size. The disc
// settles it: of 15 493 elements with a keyframe group, **2 166 have at
// least one zero-scale keyframe and not one has zero on every keyframe** —
// and 1 762 of them grow back out of it (`ptlogo_eff3.t32` runs 0 % → 200 %).
// An "unset" marker that no element ever uses throughout is not a marker.
let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y);
if sx_pct == 0 || sy_pct == 0 {
return;
}
let dw = (sw * sx_pct / 100).max(1);
let dh = (sh * sy_pct / 100).max(1);
// Keep the pivot point fixed as the element scales.
@@ -1032,6 +1143,10 @@ fn blit(
continue;
}
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
// Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an
// ADDITIVE selector and REFUTED — it moved every metric against the
// title capture the wrong way (see the doc comment on
// `T8adImage::flags`), so the bit is carried but not acted on.
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
let dc = canvas[di + k] as u32;
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
@@ -1084,6 +1199,9 @@ mod tests {
fn kf(x: i32, y: i32, time: u32) -> Keyframe {
Keyframe {
fade: 0xffff_ffff,
rotation_deg: 0,
unknown_4: 0,
unknown_8: 0,
scale_x: 100,
scale_y: 100,
tint: 0xffff_ffff,
@@ -1197,12 +1315,16 @@ mod tests {
// leaves the top-left quadrant bare. See
// `docs/re/structures/ui-rat-layout.md`.
let img = t8ad::T8adImage {
flags: 0,
width: 640,
height: 360,
rgba: vec![255u8; 640 * 360 * 4],
};
let k = Keyframe {
fade: 0xffff_ffff,
rotation_deg: 0,
unknown_4: 0,
unknown_8: 0,
scale_x: 200,
scale_y: 200,
tint: 0xffff_ffff,
@@ -1230,6 +1352,7 @@ mod tests {
// `ptcopyright.t32` is 694x20 at (293,655), and the capture's glyph run
// starts at x = 295 — inside that rect, not offset by a pivot.
let img = t8ad::T8adImage {
flags: 0,
width: 694,
height: 20,
rgba: vec![255u8; 694 * 20 * 4],

View File

@@ -27,26 +27,386 @@ authored version can be deleted.
| | Question | State | Answer / link |
|---|---|---|---|
| Q1 | keyframe time unit + ramp shape | ✅ answered | ramp is **linear**; the clock advances **2 units per rendered frame**; working conversion **1 unit = 1/60 s** — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md) |
| Q2 | which build is which screen state | 🟡 partial | build 4 title, 5 main menu, 6/8/9 submenus, `palogo` splash — unconfirmed against captures |
| Q3 | paint order for the six screens | ❔ open | runtime-solved only; declaration table is refuted |
| Q4 | button → GamePart | ❔ open | labels are baked into sprites |
| Q5 | navigation semantics | ❔ open | |
| Q6 | boot sequence + what drives it | 🟡 partial | order observed; the driver is not decoded |
| Q7 | transitions | ❔ open | |
| Q8 | menu audio bindings | ❔ open | cue table complete, event binding is not |
| Q9 | video binding + playback rules | 🟡 partial | `ADV.wmv` is the boot intro; new-game intro unidentified |
| Q10 | music-bank sub-wave roles (intro+loop?) | ❔ open | we concatenate blindly today |
| S1 | Ready Room go/no-go | ❔ open | probe not run |
| Q1 | keyframe time unit + ramp shape | ✅ answered, 🟡 one gap | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s` — settled**, the idle title presents at 28.5 fps so the game is 30 Hz. 🟡 **The interpolation law is settled; the group TIMELINE for multi-keyframe elements is not**`palogo_gamearts` is still at full alpha 9 frames after its declared `a=32`, and its declared 80-frame fade-in never draws — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md). ✅ **REPLICATED 2026-08-29 — for ANIMATION, read `+36` as the time the NEXT pose is reached.** Three elements across two screens: `palogo_gamearts` and `palogo_seta` hold full alpha for **83 frames** and `palogo_sqex` for **≥77**, where the current reading predicts **68** and the shifted one **80102**. The elements that cannot discriminate (the `_eff` glows, on which the linear law was measured) fit both. ⚠️ Our decoder still defaults to the other reading (`SYLPHEED_KF_TIME_SHIFT=1` to flip) because it changes `rest()` on one element — but that is an unsound fallback guessing either way, so **static rendering is unaffected and animation timing should use the shift** |
| Q2 | which build is which screen state | ✅ answered | `GP_TITLE` is **8 screens shipped twice, EN/JP**: 4/7 title art, 2/3 the `PRESS Ⓐ` plate, 5/8 main menu, 6/9 `EXTRAS`, 0/1 and 10/11 two unidentified `DELTASABER` plates — [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
| Q3 | paint order for the six screens | ✅ answered, ❔ tie-break | **decoded**: a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index; unkeyed elements get an implied key. Confirmed on 5 measured orders + `EXTRAS` vs a capture. One residual: the **tie-break** is unknown and bites on one element of the title — [`structures/ui-paint-order-key.md`](../re/structures/ui-paint-order-key.md). ⚠️ **The key does not fully order a screen**: elements sharing a key are tied, and the tie-break is ❔ **undecodable from the bundle** — declaration table, `T8aD` header (exhaustive: every offset 0x000x7f at u8/u16/u32, both directions, **0** fields match the measured order against **64** for the control) and the RATC child order all give the same order the game does *not* use. Your exposure is **2 overlapping tied pairs on `EXTRAS`** — [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) |
| Q4 | button → GamePart | ✅ answered | **measured** which screen all **5** buttons open — `NEW GAME``DIFFICULTY``SELECT DATA`, not a hang. The **GamePart id is still a name match**, not a measurement — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
| Q5 | navigation semantics | ✅ answered | **measured**: initial focus varies boot to boot (2× `TUTORIAL`, 2× `NEW GAME`); ⬆⬇ one step, **wraps both ends**; ⬅➡ do nothing; Ⓑ returns to the parent **with focus restored**; Ⓑ on the main menu → title; Ⓑ on the title → nothing — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
| Q6 | boot sequence + what drives it | ✅ answered | sequence **measured** end to end; the driver is **code, not data** — four search spaces closed, so the port **authors** the sequence — [`boot-config-and-gamepart-registry.md`](../re/boot-config-and-gamepart-registry.md) |
| Q7 | transitions | ✅ answered | a **fade through black**, drawn by the screen's own last-painting `.prm` quad. Fade-in ramp is **decoded** from its keyframes; the ~0.4 s fade-out is **measured** (not in the file) — [`screen-transitions.md`](../re/screen-transitions.md) |
| Q8 | menu audio bindings | ✅ answered | cue vocabulary + bank **decoded**; event binding is a **name match** (the authors' own event names). ✅ **You CAN have the SE audio** — ⚠️ an earlier version of this row said it was "undecodable from the disc"; that was **retracted** and the row was stale. Three cues are located in `Static.slb` and **decode to PCM**: d-pad move `0x1ec0` (4 packets), Ⓑ back `0x0ec0` (2), Ⓐ confirm `0x5d6c0` (6), all mono 48 kHz. The bank is a packed run of XMA waves with no delimiter, so a wave is only (offset, packet count) — and ⚠️ the file order is **not** cue-id order, so the index cannot be counted out — [`menu-audio-cues.md`](../re/menu-audio-cues.md) |
| Q9 | video binding + playback rules | ✅ answered | **decoded** from the movie manifest: `ADVERTISE_MOVIE``ADV.wmv` (boot intro *and* attract are one asset), `MS00A``S00A.wmv` is the new-game intro, `STAFF_ROLL`→the credits reel. ✅ **one Ⓐ skips a movie** (title at 57 s vs a 193 s baseline) — [`movie-binding.md`](../re/movie-binding.md) |
| Q10 | music-bank sub-wave roles (intro+loop?) | ✅ answered | **two stems of one performance, played together** — sample-synchronous, equal duration, 32/32 banks. **Concatenating is wrong.** Not a seamless loop either — [`structures/bgm-two-stems.md`](../re/structures/bgm-two-stems.md) |
| S1 | Ready Room go/no-go | **no-go** | it is 2D and enumerates fine (60 builds), but `GP_READY_ROOM.pak` holds **briefing/tactical-map** content, not the six-button Ready Room menu — [`ready-room-probe.md`](../re/ready-room-probe.md) |
## Already settled — the port can rely on these today
* **`GP_TITLE.pak` is the whole title-side tree.** Build 4 is the title with the
animating wordmarks, build 5 the five-button main menu, builds 6/8/9 are
submenus, and the developer splash is the `palogo` bundle in the same archive.
✅ decoded (enumeration), 🟡 the state labels are not yet capture-confirmed.
* **`GP_TITLE.pak` is eight screens, each shipped twice — English and Japanese.**
Build 4 is the English title art and 7 its Japanese twin; **2/3 are the
`PRESS Ⓐ BUTTON` plate, a build of their own** composited over the title and
faded in a beat later; 5/8 are the five-button main menu; 6/9 are the `EXTRAS`
submenu — the **only** submenu inside this archive. Builds 0/1 and 10/11 are a
`DELTASABER / SYLPHEED A.I.` plate that was **never seen running**, in the boot
path, any title-side screen, or the attract loop. ✅ measured against live
captures for the four English screens the boot path shows;
[`ui-title-build-map.md`](../re/ui-title-build-map.md).
**Withdrawn:** the earlier "builds 6/8/9 are submenus" — 8 is the Japanese main
menu. The other four main-menu buttons leave the archive, and where each one
goes is **measured** — see the button-destination bullet below.
* **Buttons are identifiable as data.** Element kind `0x3002` = button, `0x0` =
decoration, `0x10` = primitive. ✅ decoded.
decoration, `0x10` = primitive. ✅ decoded **for the title-side screens**.
⚠️ `0x3002` is one member of a `0x3000` family with sub-bits, and it is not the
only button kind on the disc: `GP_READY_ROOM`'s 902 bundles contain **zero**
`0x3002` and use `0x3000` / `0x3004` / `0x300c` / `0x3008` instead. Nothing in
this milestone changes — every screen in scope is `GP_TITLE` — but do not ship
`kind == 0x3002` as a general button test.
(The kind is the 4th `u32` of the 60-byte declaration entry, at `+40`.)
* **The title's settled pose is `rest` — and always pass `--primitives`.**
Against a plate-free capture of the real screen, `screen render --build 4
--black` edge-correlates **0.9163 at (0,0)**, so the geometry of `rest` is the
arrived pose; the timeline is not needed for the title.
⚠️ **But without `--primitives` the whole frame is +13.14 too bright** (R +12.35,
G +13.58, B +13.48); with them, **+0.55**. The missing element is
**`pteff02.prm`**, the 25 % dim, and `--primitives` is off by default. **That is
the "washed-out cyan slab"** — a dim that should be there and isn't, not a glow
that shouldn't. Because the art is blue-dominant, the shortfall reads cyan.
🔴 **A second, separate defect — and it is the "slab".** With the dim in place
the residual is localised to one band (y ≈ 112225): the game draws the logo's
`Z` **swoosh thin with a pink/magenta edge**, our render draws it **thick and
solid white**. Crop:
[`title-swoosh-capture-vs-render.png`](../re/captures/title-builds/title-swoosh-capture-vs-render.png).
The elements are `ptlogo_back2.t32`, `ptlogo_back2eff.t32` and
`ptlogo_back2eff1…5`. 🟡 Those five are also the group with the **known
unsolved paint-order tie-break** (key `0x8083`) — same screen, same elements —
but a blend-order swap explains white-instead-of-pink poorly.
🔴 **The pivot mismatch is NOT the cause — checked and refuted.** These elements'
declared pivots really do belong to the *other language's* sprite
(`ptlogo_back2`'s `(500,117)` is exactly half the Japanese 1000×234, not its own
English 1118×262; 24 of 109 title elements are off by > 8 px). **But it cannot
affect this render**: `blit` sizes a sprite from its **texture**, and applies the
pivot only as `kf.x pivot·(scale100)/100` — and **all seven swoosh elements
are scale `(100,100)` at every keyframe**, so the term is zero.
**That formula is now MEASURED, not just implemented (2026-08-28).** The
title's two `ptloop` sweeps scale **600 %** and **800 %** vertically, where the
pivot term is worth 450 and 630 px, and the GPU capture puts both quad centres
at y **359.1** and **360.0** — against the formula's **360.0** for both.
Top-left anchoring predicts 810 and 990; treating the position as the centre
predicts 270. Horizontally the same term makes the group's `t` solved from
**position** agree with `t` solved from **vertex alpha** to **0.33 / 0.65
units**, versus ~8 units without it. So: **anchor scaling on the pivot, not on
the top-left corner.**
🟡 It does *not* prove interpolation is linear — both fields were inverted
through the same linear map, so a shared easing curve would cancel. It does
show position and alpha ride **one shared parameter**.
⚠️ It *would* bite `ptlogo1`/`ptlogo2`, which scale 100 → 150 during the
build-in. Not at rest, and not on the swoosh.
🔴 **Ruled out for the COLOUR**: every swoosh keyframe's fade is `0x??ffffff`
(white RGB, alpha only), no tint is non-white, and the texture decodes
*blue*-leaning (175,174,198).
🟡 **What is left is the BLEND.** Size and position are the texture's own and
match; fade, tint and texture colour are all ruled out. Seven overlapping
mostly-transparent sprites (`ptlogo_back2` 5.4 % opaque, its glow 10.3 %, the
five `eff` segments 1023 %, all white or warm) stacked with plain alpha-over
saturate to opaque white — which is exactly what we draw, and would read as
"thicker" against the game's thin coloured stroke.
🟡 **And there is a candidate field for it.** The `T8aD` header word at
**`+0x04`** splits the title's sprites exactly along effect-vs-normal:
`pteff01`, `pteff03a`, `ptlogo_back2eff1…5`, `ptlogoall_eff`/`_eff2` are
**`0x8832`**; `ptlogo1`/`2`, `ptlogo_tm`, `ptbase2`, `ptlogo_back2`,
`ptcopyright` and `ptlogo_back2eff` are **`0x8830`**. One bit — **`0x02`**.
Disc-wide it is a real independent flag: 19 216 sprites, 18 distinct values,
bit `0x02` set in **27.1 %**, and it toggles against otherwise-identical words
(`0x8830`/`0x8832`, `0x0830`/`0x0832`, `0x0810`/`0x0812`, `0x0030`/`0x0032`).
⚠️ **Correlation only — untested.** Nothing yet shows it *means* additive; the
test is to blend bit-`0x02` sprites additively and re-correlate the title
against the capture. Note `ptlogo_back2eff` is `0x8830` despite its name, so
this is a field and not a naming pattern. ❔ Not diagnosed — but two candidate meanings are now dead: **not** "the name contains `eff`" — and not even the one-way "bit ⇒ `eff` name", which held 10/10 on the title but fails on **2 657 of 4 995** bit-set sprites disc-wide (`P(eff|set) = 0.468`). What survives is a 3.3× association, and the counterexamples are rings, glows and lights — effect-like art without the naming convention and **not** "the element is transient" (`pteff03`/`pteff03a` carry the bit and persist to `t=250`). 🛑 **Parked** — four candidate meanings are now dead (additive blend; `eff` name, both directions; transient element; **premultiplied alpha**, refuted because flagged sprites violate `RGB ≤ A` *more* than unflagged, 55.5 % vs 33.7 %) and none produced a positive account. It blocks nothing — your screens composite at 0.947 correlation against a capture without it. ⚠️ Practical note for an asset pipeline: `T8aD` headers appear in **RATC child order** (18/18 verified on build 4 against decoded dimensions), which is the only sound way to attribute a header to a name when two sprites share a size — and two here do.
⚠️ **The rotated draw is NOT the swoosh.** It was identified as the swoosh by
elimination; that is **refuted** — its quads span y 209…925 in screen space,
the swoosh is a band at y 126…360. ✅ **It is the two `ptloop` sweeps**
(`ptloop01.rat``pteff03.t32`, `ptloop02.rat``pteff03a.t32`), confirmed by
edge length: 400 × 1076 and 400 × 1444 against 399×180 at the two elements'
**different** declared scales, 600 % (= 1080) and 800 % (= 1440). What stands
from the old bullet: the game *does* submit rotated quads this compositor
cannot draw, and vertex colours are white.
**SOLVED 2026-08-28 by draw capture — it is the GEOMETRY.** The game submits
the swoosh as **two rotated parallelograms** (edges `(0.54,0.56)` and
`(0.44,0.79)`, ~45° and ~61°, extending to `y=±1.81` NDC).
`ui_layout::blit` draws **axis-aligned rectangles only**, so it blits the sprite
upright — right on average, right in position, wrong in shape, which is the
measured signature exactly. **A port that blits upright rects will have the same
defect.**
🔴 Vertex colour is refuted with it: every colour in the capture is
`<alpha>FFFFFF`, white RGB.
**CLOSED 2026-08-28 — the rotation IS on the disc, at keyframe `+12`.**
It is a signed angle in **degrees**, clockwise-positive in screen space
(Y down), and `ui_layout::Keyframe` now carries it as `rotation_deg`.
Confirmed against the framebuffer, not against our own renderer: the two
`ptloop` records declare `+12` = **30** and **45**, and the GPU capture
submits their quads at **+30.26°** and **45.28°** — magnitude and sign, on two
different values. Corroborated separately by shape: `GP_BUNK` entry `117ca14f`
holds a group whose `+12` ramps **0 → 360** with position, scale and alpha all
constant — a spin in place. Disc-wide `+12` is non-zero in **14.50 %** of
83 862 keyframe blocks.
⚠️ **Two things the port must know about it.**
(1) **Rotation lives in BOTH the top-level table and nested `.rat` leaf
records.** ⚠️ I told you one iteration ago that it looked nested-only; that was
three archives' worth of pattern and **it is refuted**`GP_DIALOG` and
`GP_DEBRIEFING_PILOTLOG` rotate top-level elements. The actionable half stands:
the *title's* rotations are nested, so a composer reading only the declaration
table gets zero rotation on exactly the elements that move there.
The clearest examples are top-level and show up in
`screen info --build 0 --geometry dat/GP_DIALOG.pak`: `pceff03.t32` and
`pceff04.t32` ramp `r=` **90 → 30 → 10 → 3 → 0** while their alpha ramps
0 → 255 and they slide into place — a swing-in that settles upright; and build
6's `pzeff02.t32` ramps **43 → 61 → 75 → 90** while scaling 112 % → 200 % and
fading to 0 — a spin-out burst.
(2) **`sylpheed-cli screen render` still does not rotate.** The field is
decoded, not rendered; `ui_layout::blit` is axis-aligned only. So the reference
renderer and the port will *both* draw these upright until a rotating blit
exists — and per your own rule, the two of them agreeing about it means
nothing.
**Checked 2026-08-29: this does NOT affect your five screens at rest.**
Title, main menu, `EXTRAS` and both splash halves have **zero** top-level
elements with a non-zero rotation. The only rotations on any of them are the
title's two nested `ptloop` records (`r = 30` and `45`), and at rest those sit
at `x = 1521` and `x = 839` — a 399-wide sprite entirely off both edges of a
1280 screen. So a static composite is unaffected; the caveat applies only if
you animate the title's build-in, where the sweeps cross the screen rotated.
🟡 The neighbouring words `+4` and `+8` are still unexplained: signed, non-zero
in ~4.8 % / 4.6 % of blocks, almost entirely `±180`/`±90`. That distribution
looks like a flip flag rather than a free angle, but nothing observed turns on
them — **do not transcribe them as X/Y rotation.**
[`ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
⚠️ The earlier "pink versus white" reading compared two differently-shaped
renderings and should be re-checked after geometry, not carried as a separate
defect.
**Classified: undecodable from the disc, with reach.** Seven candidates
eliminated — pivot (inert at scale 100 *and* no measured displacement), `fade`,
`tint`, texture colour, additive blend via `+0x04` bit `0x02`, and
capture-not-settled (the band is identical from t = 4.0 s to t = 21.5 s). The
residual is stable and modest: band mean **+1.83**, edge-corr **0.70** vs ≈ 0.92
frame-wide. A next attempt should use a **per-draw GPU capture** of the running
guest — not another field — but ⚠️ that capture records prim/indices/shader
hashes/**texture bindings**/**vertex attributes** and **no blend state**, so it
can test a per-draw *vertex colour* today and would need a Canary change to dump
`RB_BLENDCONTROL`. ✅ And the plate-free capture is sound; use it.
🔴 **Refuted:** it is *not* that our dim covers the whole frame instead of
sitting beneath the UI — the logo reads +2.36 against a background of 0.74, so
the paint order is being honoured.
* **The title's motion, decoded and attributed.** After building in, the *title
art* is essentially static — a 22 s capture measures the wordmark region at
sd 0.06 and the bottom-right corner at sd 0.003. Two things do move:
***Two slow light sweeps in build 4.** `ptloop01.rat` (an `opt `-linked
`RATC` at `0xbb5966`) sweeps `pteff03.t32` left→right over **450 units =
7.5 s**; `ptloop02.rat` sweeps `pteff03a.t32` right→left over **570 units =
9.5 s**. Ordinary 40-byte keyframe blocks from `+0x68`, three keyframes each.
***The `PRESS Ⓐ BUTTON` plate pulses**, and it is the loudest thing on
screen — a capture's per-tile amplitude map puts **sd 7.65** in the band
x ≈ 318954, y ≈ 560672 against **0.06** on the wordmark. It is
`ptbtn00f.rat`, the plate's highlight variant (build **2**, not build 4),
whose alpha ramps **`0x00 → 0x06 → 0x4a → 0x50` (hold) `→ 0x4a → 0x06 → 0x00`**
over **eight** keyframes at `t = 6, 29, 35, 50, 58, 97, 105, ?` — a glow that
fades in and back out, **closing on fully transparent**, so it is a complete
cycle rather than a one-shot ramp.
🟡 Its cycle **length** is not readable, and this is now observed rather than
assumed: the eighth block's time slot literally contains the ASCII terminator
`end `, so the record ends there and the value does not exist. Declared span is
therefore **≥ 105 units = 1.75 s** against a measured **≈ 2.3 s** — which would
need a final step of ≈ 33 units. That number is **fitted to the measurement, not
read**; the port should take ≈ 2.3 s as measured.
⚠️ An earlier version of this bullet said "the title screen loops at ≈ 2.2 s"
and attributed it to `ptloop01/02`. Both halves were wrong: it is the **plate**,
and it is a different build.
* 🟡 **Our composite is brighter than the emulator's frame — measured, and you
would be *authoring* if you apply it.** Alignment is exact (best offset
dy=0 dx=0, correlation **0.9466**), so only the tone differs. Fitting on 16×16
patches that are flat in **both** images: `capture ≈ 255·(render/255)^γ` with
γ = **1.491** (main menu), **1.493** (`EXTRAS`), **1.338** (title).
⚠️ **Narrow reach.** Those flat patches span only render values ~060, where a
gamma and a plain scale are nearly indistinguishable — on both menus the errors
are 0.28 vs 0.34 and 0.22 vs 0.28. Only the title separates them (1.08 vs
9.02). Nothing here constrains midtones or highlights.
🔴 The held-out control **failed to discriminate**: the splash's 2 918 flat
patches are pure black (render 04), so every model scores ≈ 0.
🔴 **My "it may be the emulator" caveat is withdrawn.** I said canary applies
`kernel_display_gamma_type = 2` (BT.709) on output. It does not — that cvar is
a value a **kStub getter reports to the guest** (`VdGetCurrentDisplayGamma`),
which the game uses to build its own ramp; canary then applies **the guest's**
ramp from the `DC_LUT` registers in the swap path (`apply_gamma_table.ps` /
`apply_gamma_pwl.ps`). So there is no emulator post-process to subtract, and
any gamma in a capture is one the game installed.
**Measured 2026-08-29: the game DOES query the display gamma.** Booted with
Kernel logging on (`--log_mask=12 --log_level=3`, which changes nothing about
the output), `VdGetCurrentDisplayGamma` is called once at video init, between
`VdGetSystemCommandBuffer` and `VdSetDisplayMode` — the moment a ramp-builder
would ask. Control in the same log: 359 `VdRetrainEDRAM` lines, so an absent
call would have shown.
🟡 **That it then writes the ramp is inferred, not observed** — but the chain
is closed: canary's swap-path gamma stage is a **pure 256-entry LUT** with no
other transfer (`apply_gamma_table.xesli`), and that LUT **defaults to
identity** (`CommandProcessor::Initialize`, whose comment says the linear
default is "what games set when starting with the sRGB return value"). Identity
cannot produce the measured γ ≈ 1.4, so a non-identity ramp was written.
⚠️ The weak joint is that this assumes our composite reproduces the *pre-ramp*
framebuffer; what carries it is that a systematic ~1.4 across three screens is
not the shape of a compositor bug. A fixed sRGB stage does not fit either
direction (encode brightens; decode darkens far more).
**Practical upshot for you is unchanged:** the darkening is the game's own
display ramp, so it belongs in a port as a display profile, not baked in.
⚠️ Note the ramp depends on the display type the game is *told*; canary
hard-codes TV/BT.709, which on hardware is a console setting. **So this is a
display profile, not a fixed property of the game** — reasonable to expose as a
setting rather than bake in.
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md)
* 🟡 **`screen render` silently drops one full-screen element per screen — and
you must NOT simply draw it.** Auditing what the composer omits on your five
screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm`
primitives, `loop*` animations) except **`pteff04.t32`** on the title and
**`pteff05.t32`** on both menus. Those are `kind 0x0`, one keyframe, rest
`a = 255`, pivot `(640,360)` — full-screen and opaque.
**Cause:** the element declares `pteff05.t32`, but the `T8aD` behind its `opt `
link is registered under the name **`8AX`**, so the sprite lookup misses and a
silent `continue` drops it.
**It does not currently show,** because `ptbase.t32` (640×360, drawn at
200 %) is *the same artwork at half resolution* — its 2× upscale differs from
`8AX` by mean 2.05, and our background is pixel-identical to `8AX` in every
patch sampled.
**SETTLED 2026-08-29 — the game draws the full-res `8AX`, so use it.**
Previously parked as "needs a per-draw capture"; it did not. The two carry the
same art at two resolutions, so what separates them is the detail `8AX` has
that an upscale cannot. Correlating the capture's departure-from-upscale
against the 8AX-only detail (both first mapped through the measured gamma):
main menu **+0.0475** vs controls +0.0032 / 0.0075, title **+0.0634** vs
+0.0095 / +0.0086 — **two screens, both 68 % of the theoretical ceiling, 715×
their matched controls**.
**So: resolve the name and draw `8AX` at 1:1.** Upscaling the 640×360 `ptbase`
2× is *wrong*, not merely softer. ⚠️ Do not draw **both** — an opaque
full-screen layer over an identical one costs fill and hides later changes; and
note `ptbase`'s element is the one carrying the keyframes, so you need its
timing with `8AX`'s pixels.
⚠️ It does not show whether `ptbase` is *also* drawn underneath — `8AX` is
~86 % opaque and would hide it either way.
[`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md)
***Paint order: your exposure is two element pairs, on one screen.** We use
an order *measured from the running game* where one exists and a derived order
(a sort on each sprite's layer key) elsewhere. Checked, rather than assumed:
the derived order reproduces the measured one **exactly** on the main menu
(0 inverted pairs) and the developer splash (0). On the **title** it differs by
8 pairs — **all same-layer-key ties** — and two of those are total occlusions
(`back2eff5` is 1133×280 and *fully contains* `back2eff3` and `back2eff4`;
derived puts it on top, the game puts it underneath). The title is unaffected
in practice because it has a measured order.
Per screen: title **measured**, main menu **measured**, developer splash
**measured**, publisher splash derived but with **0 ties** (fully determined),
and **`EXTRAS` derived with 15 tied pairs of which only 2 overlap**.
**That narrows again to ONE, and the capture is consistent with it.** Of the
two overlapping pairs, `ptloop01`×`ptloop02` are `loop*` animations that
`compose` skips by default, so their tie is unreachable. The remaining pair is
`ptframe3`×`ptframe4`, overlapping 102×132 px — and against `live-extras.png`
that contested region correlates **+0.9622**, *better* than the whole frame
(+0.9440) and inside the range of regions where order cannot matter (+0.8502 /
+0.9903). 🟡 Consistent with, not proof — correlation cannot see a swap between
locally similar art. **15 → 2 → 1 → consistent** is the whole paint-order risk
on your five screens.
[`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md)
***How good are the five screens, actually?** One page with the numbers:
[`five-screens-acceptance.md`](../re/five-screens-acceptance.md). Rendered and
correlated against the live captures — title **0.9500**, main menu **0.9460**,
`EXTRAS` **0.9440**, publisher splash **0.9600**, developer splash **0.9643**,
and every one aligns at **exactly dy=0 dx=0** over a ±2 px search, so placement
and scale are right and the residual is tone and detail rather than geometry.
Every undrawn element is accounted for (ghost instances, `.prm` primitives,
`loop*` animations, and the `8AX` name mismatch whose art is on screen anyway) —
the counts add up exactly, with nothing unexplained. The residual, ranked: tone
(γ≈1.4, the game's own ramp), `8AX` resolution, one `EXTRAS` paint-order tie,
and rotation-not-rendered which does not affect these five at rest.
⚠️ Reach: static composites at rest against single frames — this says nothing
about animation.
* ✅ **A static composite is only meaningful for a screen that SETTLES — the two
splashes are animations.** Measured with its control: suppressing the `_eff`
glows takes the **publisher splash 0.9604 → 0.9982** and the **developer splash
0.9659 → 0.9980**, while the same edit makes the title 0.002, the main menu
**0.092** and `EXTRAS` **0.107** worse. The draw log says why — on the
developer splash the glows draw on frames 94115 and the logos on 116211, so
at the captured moment every glow is already finished, including the two with
plateaus that `rest_plateau` renders visible.
So `rest_plateau` is right where a screen settles and over-draws where it does
not. **There is no "resting pose" for the splashes** — they play through and
leave, and a static composite of them is a picture of one arbitrary frame
(≈0.998 for the frame these captures hold). **Play the timeline for the two
splashes; composite statically for title / main menu / `EXTRAS`.**
[`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)
***A group's DURATION is in the data; its START TIME is not.** Testing whether
the splash timeline *played* reproduces the capture: each element is on screen
for its declared span to within 2 % (glows 44 units observed vs 45 declared;
logos 192 vs 195). But every glow declares the same times `15,30,45` and every
logo the same `15,30,190,194,206,210`, so on one clock they would overlap almost
entirely — and they **do not overlap at all**. The glows run frames 94115, the
logos 116211, strictly sequential.
🔴 The obvious candidate is dead: each group header carries an undecoded
**lead-in word**, and it is `0x00000000` for all seven elements. Not the
keyframe times, not that word, not declaration order, not the RATC child order.
**Here is the sequence to author, measured** (1 frame = 1/30 s):
publisher `palogo_sqex` **3.00 s+** (a floor — it is already at full alpha on
the capture's first frame), a **0.10 s** gap with nothing drawn, then the
developer **glows for 0.73 s**, then the developer **logos for 3.20 s**, the
glow→logo switch being a single frame boundary with no overlap. Each phase is
within 2 % of its element's declared span, so the *durations* come from the
bundle and only the *ordering* is authored.
⚠️ One capture, one run; and `palogo_anima`/`_eff` get 0 draws in all 214
frames, so a third pair's phase is not in this measurement.
[`data/splash-phase-timeline.txt`](../re/data/splash-phase-timeline.txt)
⚠️ **So you must author the sequencing.** The observed order on the developer
splash — both glows, then both logos — is *measured for one screen*, not a
decoded rule.
**Verified against its own refutation:** across all 235 captured frames,
**zero** contain both a glow and a logo; the switch is one clean boundary with
two sprites either side.
🔴 **And it goes further than start times — a bundle's declared elements are
not what gets drawn.** ✅ **And the mechanism is now established, by
elimination.** The competing story — "two compositions shown in sequence" —
needs a bundle declaring the glows *without* the logos, and no such bundle
exists: only four entries in `GP_TITLE` carry `palogo` elements, and each
developer entry (11, 14) declares **all six** logos and glows. So whichever
bundle was active, only a subset of its elements was drawn at a time.
(An earlier texture-base test could not separate the two — it failed its own
control, since the publisher splash is a different bundle and shares the base
`0x11C30000`, a reused upload slot rather than an identity.) Entry 11 declares *three* logo/glow pairs; only two are ever drawn.
`palogo_anima` gets **0 frames** while `palogo_gamearts` gets **95**, from
byte-identical keyframe times. (Reach: the capture covers frames 1214, so
"never in the window".) **Compositing every element of a bundle does not
reproduce what the game shows over time** — it is right for a static screen
that settles, and it is not a timeline.
[`structures/ui-group-start-time.md`](../re/structures/ui-group-start-time.md)
* ✅ **The splash's backdrop is an opaque black `.prm` from the bundle, painted
first.** Measured: every frame opens with the clear and then an **untextured
1280×720 quad, vertex colour `FF000000` in all 212 frames** — matching
`palogo_eff0.prm`'s declaration (`kind 0x10`, pivot 640×360, one keyframe,
`a=255`). That is why a splash render needs `--black` rather than the default
backdrop. ⚠️ Not a general rule: the main menu's measured order puts one `.prm`
at position 4 and the other **last** (the transition fade).
✅ **The splash's recorded paint order `[0,2,4,6,1,3,5]` is a real depth
order** — read off the runtime child array, and independently backed by the
static layer key (glows `0xa100` sort before logos `0xa110`;
`paint_order_audit` reports derived == measured, 0 ties). A same-day claim on
this page that it was "a temporal sequence, not depth" was **withdrawn**; see
the ❌ section in the linked page for why, if you read it before the fix.
⚠️ Separately and still true — and it is an **activation** fact, not a
paint-order one: the splash's glows and logos are never on screen together
(glows f94115, logos f116211,
[timeline](../re/data/splash-phase-timeline.txt)). Compositing all seven at
once reproduces no frame of the real screen. Depth says *what covers what*;
it does not say *what is up*.
[`structures/ui-prm-primitives.md`](../re/structures/ui-prm-primitives.md)
* **Menu order is geometric.** Buttons sorted top-to-bottom by resting Y. This is
✅ correct for a vertical menu and is **not** a decoded neighbour graph — the
disc's real navigation structure is unknown, and `opt ` is *not* a focus link
@@ -54,6 +414,28 @@ authored version can be deleted.
[`ui-focus-and-effect-elements.md`](../re/structures/ui-focus-and-effect-elements.md)).
* **Highlighted states pair by name** — `ptbtn01.rat``ptbtn01f.rat`. 🟡 a
naming convention that holds for all 54 real pairs, not a decoded field.
* **`rest()` was wrong for elements with no exit animation — fixed 2026-08-28.**
A trailing run of identical keyframes was always treated as the exit and
excluded; on an element that has no exit it *is* the hold, and `rest()` fell
back to the element's **first** keyframe — off-position and transparent. Six
elements on the main menu were affected, including `ptframe1`/`ptframe2`, the
bright circuit bracket around the menu, which both the port's composite **and**
`sylpheed-cli screen render` were dropping.
The rule now: **a trailing run is the hold exactly when it is visible** (alpha
≠ 0). ⚠️ Not the pose-equality test the report proposed — `pgptitle.rat`'s
trailing run also matches its last timed keyframe, and adopting that would erase
the word PAUSE. Oracle correlation over the bracket region improved
**0.9596 → 0.9748**; the PAUSE control is unchanged.
**Disc-wide**: over 2 859 bundles / 13 991 elements, `rest` moves for **30**
(0.21 %) — **4 invisible → visible, 0 visible → invisible**. Tests green
(131 passed).
**Back to the port agent:** on the English main menu exactly **two** elements
satisfy your pose-equality condition (`ptframe1`/`ptframe2`), so your six span
the whole export. If any of the other four have a **transparent** trailing run,
this rule leaves them alone on purpose. **Which screens are they on, and does a
capture show any of them drawn?** If so the alpha rule is incomplete.
This also closes the old ❔ on `ptframe1`/`ptframe2` "resting at alpha 0 but the
capture shows the frame plainly".
* **The resting pose is the hold**, not the first, last or longest-dwell keyframe;
a keyframe is the **start of a ramp**.
[`ui-resting-pose.md`](../re/structures/ui-resting-pose.md). ✅
@@ -62,39 +444,372 @@ authored version can be deleted.
declared 15-unit fade lands on `round(255·k/15)` for all seven of its samples,
with `k` stepping 2, 4, 6, 8, 10, 12, 14 on seven consecutive submitted frames.
**measured**, not decoded — the disc says `t=30`, it does not say what a `t` is.
The seconds conversion (`1 unit = 1/60 s`, so a 30 fps screen) rests on a
measured 27.6 present-frames/second and is the one part still worth re-testing;
[`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md) names the test.
If it turns out the game presents at 60 Hz, every duration halves — nothing
else on this page changes.
🟡 **But a multi-keyframe element's TIMELINE does not reproduce (2026-08-28).**
The linear law and the 2-units-per-frame rate rest on the splash's `_eff`
glows, and those are exact. Applying *their* calibration — with no free
parameter left — to `palogo_gamearts` in the same bundle and the same frames:
the logo is still at `a=255` nine frames after its declared `a=32`, its
fade-out runs ~17 frames late, and its declared 80-frame fade-in is never
drawn at all (and that is not culling — the same element is submitted down to
`a=7` on the way out). Its declared fade-out also spends 12 of 16 units
dropping only 23/255 of the alpha; the capture shows no such plateau.
🔵 **Followed up, and the candidate is now strongly favoured — but not
adopted.** That `+36` holds the *next* pose's time is supported by a
calibration-free measurement: the observed full-alpha **hold : fade-out** ratio
is 83 : 13 frames = **6.38**, the shifted reading predicts **8.00**, and the
current reading predicts **0.25** — off by **26×**. With the glow's 2
units/frame fixed and nothing else free, the current reading says the logo
holds full alpha for **2.0 frames**; the game holds it for **83**. The shift
also makes `rest()`'s plain dwell rule pick the visible hold instead of a
fully transparent pose, and removes the decoder's "last block's time is
unreadable" special case.
~~🔴 **What stops it:** … `GP_TITLE` build 7 … twins should match in
brightness …~~ **Withdrawn 2026-08-28.** That render difference is **not**
evidence about the time association. It is one element — `ptlogo_eff3.t32`, a
transient bloom that grows `0 % → 200 %` at full alpha while rotating, then
collapses — and it has **no resting pose at all**. `rest()` falls through to
its dwell fallback and returns whichever end of that movement the indexing
lands on: the invisible frame as decoded, the 200 % peak shifted. An 896×389
sprite at 200 % is larger than the screen, which is the whole 13.1 % and the
whole luminance gap. I was comparing a heuristic, not a decode.
🔴 **And that is a real defect you should know about:** `rest()`'s dwell rule
is unsound *whenever it runs*. A dwell gap is time spent interpolating **from**
pose k **to** pose k+1; neither is held unless they are equal — which is a
plateau, and the plateau path has already returned by then. **So any element
with no two adjacent identical poses has a guessed rest pose**, in our renderer
and in anything built from it. Measured disc-wide: **2 305 of 15 493 elements
(14.88 %)** — ⚠️ **corrected 2026-08-29 down from a published 3 807 (24.57 %)**,
which counted 1 502 *single-keyframe* elements as guesses; those have one pose
and it is unambiguously their rest. Of the real 2 305, **195 get a degenerate
`scale = 0 %` pose**, and the two
candidate rules agree only **50.2 %** of the time.
✅ **This does not block you — your exposure is TWO elements, both on the
splashes.** The fallback is reached only by an element that is plateau-less
*and* multi-keyframe; a single-keyframe element short-circuits. Title, main
menu and `EXTRAS` reach it **zero** times, which is why three different
fallback rules render them to identical correlations. The publisher and
developer splashes reach it once each — and there, `SYLPHEED_REST_RULE=last`
(the final keyframe) scores **+0.9982** and **+0.9758** against the current
rule's +0.9600 and +0.9643. ⚠️ Measured against single frames of a transient
animation, so it fixes which pose matches *those* captures, not which is
canonically at rest. Default unchanged — it is better on both screens where it
fires and identical on the other three, but it would move 2 305 elements
disc-wide on two measurements.
**And the picture is now coherent.** Applying the last keyframe to *every*
element (not just the plateau-less ones) collapses all five screens — title
0.9500→0.6819, main menu 0.9460→0.6416, `EXTRAS` 0.9440→0.5745, and both
splashes render **blank**. The reason is the model: a group is **entry → hold →
exit**, and the exit is the screen's *dismissal*. A displayed screen is sitting
at the **hold**, not at its final pose — so `rest_plateau` is right, and the
last keyframe is the *post-exit* state. It is right for a **transient** element
precisely because a transient's settled state is "gone". The draw capture agrees
independently: on the developer splash the `_eff` glows draw on frames 94115
and the logos on 116211, so the glows are already over when the logos are up.
**Three independent observables — animation timing, static composites and the
per-frame draw log — all support: plateau where there is one, last keyframe
where there is not.**
📊 **Structurally, `last` is defensible for 2 293 of the 2 305 (99.5 %)**: 1 618
end invisible (a transient, gone at rest), 675 end at maximum alpha (faded in
and stopped — the final pose *is* settled), and only **12** end visible below
full alpha, which are genuinely unclear. The dwell rule returns a mid-movement
frame by construction. ⚠️ The 1 618 rest on an assumption worth seeing: that
such an element's animation has finished by the time the screen settles — shown
by the draw log for the two splash glows, unshown for the rest.
📊 The disc-wide blast radius, for whoever decides: the rules **differ on 82.3 %**
of those 2 305, so "either is fine" is not available — and the current rule
returns a **zero-scale** (collapsed, pre-roll) pose for **195** of them against
**43** under `last`, a 4.5× reduction in provably-degenerate results. That
argues the same way as the captures, from the data's own structure. The single disagreement
is `palogo_anima_eff.t32` on the developer splash, and the current answer is
the defensible one there — see below. Still worth flagging plateau-less
elements in an export rather than silently inheriting our guess; it is one pass
over the keyframes.
✅ **One concrete part of it is fixed (2026-08-28): `scale = 0` no longer
renders at full size.** `blit`/`fill_quad` coerced `scale == 0` to 100 %, so an
element collapsed to nothing was drawn full-size. The disc settles the reading:
2 166 elements have a zero-scale keyframe, **not one is zero throughout**, and
1 762 grow back out of zero — so 0 means collapsed, not "unset". **Renders are
24/24 byte-identical** on `GP_TITLE` (all 16 builds), `GP_PAUSE_MENU` and
`GP_OPTIONS`, so nothing you rely on moves; the 126 elements the old
code actually painted are **all in `GP_READY_ROOM.pak`**, already a no-go.
[`structures/ui-rat-layout.md`](../re/structures/ui-rat-layout.md)
🔴 **"rest = last keyframe" is refuted** as the fix. The splash has three
sibling glows with identical structure and times, differing in one byte
(`a=212` vs `a=255` at `t=45`); that rule would make `anima_eff` alone
invisible while its two siblings stay lit. The capture agrees weakly — box-mean
ratios capture/render are gamearts 0.717, seta 0.723, **anima 0.772**, and a
glow we drew that the game does not would put anima *below* its siblings.
[`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)
🟡 **The shift is still not adopted**, now for a different reason: it flips
this element to the visibly wrong answer, so it and a decision about
plateau-less elements have to land together, and neither half has a capture to
verify against.
**Default unchanged**, experiment reachable via `SYLPHEED_KF_TIME_SHIFT=1`.
**What this means for you:** the interpolation *law* is settled (linear, 2
units/frame); a multi-keyframe group's *timing* is not — do not expect a
2-frame hold where the game holds 83.
**The seconds conversion is settled: `1 unit = 1/60 s`, a 30 Hz title.** The
re-test this page used to name has been run — 300 submitted frames timed on the
**idle** title (nothing loading) came out at **28.8 and 28.3 fps**, the same
rate as the 27.6 fps measured during the loading splash. The competing 60 Hz
reading is excluded: it needs the emulator at 47 % of real time while idling on
a screen that costs ~5 draws per frame.
A second, independent line agrees — the transition quad is declared black for
12 units (0.20 s under this conversion) and a capture measured the pure-black
plateau at 0.170.23 s
([`screen-transitions.md`](../re/screen-transitions.md)).
⚠️ Still **measured, not decoded**: no field on the disc says "sixtieths of a
second".
* **The paint order is derivable from the file.** Each `T8aD` sprite header
carries a **`u16` layer key at `+0x0A`** (the upper half of the 32-bit word at
`+0x08` is zero in all 21 184 sprites on the disc). Paint order is that key,
**stable-sorted** so equal keys keep declaration order; elements with no sprite
(`.prm` primitives, `.tbm`) have no key and take an implied one — the backdrop
and dim quads sort early, the screen-transition fade (`pteff00.prm`,
`pfeff00.prm`) sorts **last**. ✅ decoded. Checked against five paint orders read
off the running game, one of them (`GP_SAVE_LOAD`'s slot-list header, 6
instances) **exact and independent of the screens the rule was fitted to**, and
against a fresh `EXTRAS` capture. It reorders 341 of the disc's 965 builds, so
it is not a no-op dressed as a rule.
🟡 **The one residual: ties.** Where two elements share a key the game
sometimes paints them in an order nothing predicts — eight candidates refuted,
including declaration order, RATC child order, keyframe times, resting X/Y and
`kind`. Measured cost: on the three screens with ground truth it changes the
blend of **one element on one screen** (a title glow). Take the stable sort and
accept that.
* **Ⓑ returns to the title, and that title still works.** Ⓐ on the Ⓑ-returned
title opens the main menu — measured, with Ⓐ on the boot title as the control in
the same run. (Only the *attract*-returned title is inert, which is an emulator-
harness curiosity, not a port concern.)
* **Menu movement, measured off the running game.** ⬆⬇ move one item per press
and **wrap at both ends** (5-item main menu and 3-item `EXTRAS` both). ⬅➡ do
nothing. Ⓑ goes up one level **and restores focus to the item you came from**;
Ⓑ on the main menu returns to the title; Ⓑ on the title does nothing. **Initial
focus is not stable**: four boots of the same script gave `TUTORIAL`,
`TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not hardcode it; pick one and say you
picked it. All **measured**, none of it on the disc.
* **Each button's destination is measured; its GamePart id is not.**
**`NEW GAME``DIFFICULTY`** (`EASY`/`NORMAL`/`HARD`/`BACK`, opening on
`NORMAL`) **`SELECT DATA`** — it does *not* hang; the run then hits the
already-documented `sub_823070B0` cache crash, which is not a menu problem.
`LOAD GAME` → the save-slot list, `TUTORIAL` → the lesson list, `OPTIONS`
the settings menu, `EXTRAS``GP_TITLE` build 6, `EXTRAS ▸ MISSION SELECT`
the stage list. The
GamePart ids (`3`, `25`, `8`, `5`, `7`) are the entries of the decoded id table
whose **names match the screens seen**; that binding is authored, not measured.
* **A screen change is a fade through black.** Each screen carries a full-screen
black `.prm` quad that paints last (`pteff00.prm` / `pfeff00.prm`) whose
keyframe group *is* the transition: black at `T0`, clear by `T1`, clear until
`T2`, then back to black on exit. ✅ decoded, with a disc-wide check — and in
`GP_TITLE` exactly the six **screen** builds carry it while the six overlays do
not. The fade-in length is `T1 T0` and is read from the file (0.87 s for
`EXTRAS`, 0.97 s main menu, 4.08 s title). ❔ **The fade-OUT length is not on
the disc** — the last keyframe has no time slot; **measured ~0.4 s**, twice.
The black hold measures 0.170.23 s. ⚠️ Do not time the fade-in off a capture's
brightness: the incoming screen's own element animations dominate it and run
much longer than the quad.
* **The movie manifest names every boot-side video by role.** `dat/tables.pak`
entry `0x5b983a08`: `LOGO1``LOGO4``logo1.wmv``logo4.wmv` (**not on the
disc** — this is why the splash is a screen), **`ADVERTISE_MOVIE``ADV.wmv`**,
`STAFF_ROLL``SYLPH_HD720p_8M-CBR_2ch.wmv`, **`MS00A``S00A.wmv`** (the
new-game intro, 93.9 s, with subtitle + `VOICE_S00A` + a text overlay),
`MS01A``S01A.wmv`. ✅ decoded — and **`S00A.wmv` is now also measured**: matched off the running game at 0.961.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples. It starts ~4.5 s after Ⓐ on the save slot.
**The boot intro and the attract movie are the SAME asset** — there is no
separate boot slot, and 15 of 19 captured attract frames match `ADV.wmv` with a
monotonically advancing playhead ending at its full 137 s. One video, not two.
✅ The attract movie **plays to its end**; nothing cuts it short.
**A movie is skippable with a single Ⓐ.** Measured: one tap ~45 s into the
boot brought the title at ~57 s against a ~193 s no-input baseline over three
boots, with Canary's own keystroke counter proving exactly one press was
delivered — and the skipped-to title is **fully functional** (`PRESS Ⓐ` plate
present, Ⓐ opens the main menu). What breaks the boot is *hammering*: 88
presses left a permanent black screen. One press is fine.
* **The menu's sound events are named on the disc.** `tables.pak`'s `SOUNDS`
record carries 322 `SE_*` cues, and the low block is the UI vocabulary — named
after the *event*: `SE_UI_CURSOR` (2), `SE_UI_DECIDE` (3), `SE_UI_CANSEL` (4),
`SE_UI_IMPOSI` (5, the error), `SE_UI_SUB_WIN_OPN`/`_CLS` (8/9),
`SE_UI_SPLASH_IN`/`_OUT` (12/13). ✅ decoded, full list in
[`data/se-ui-cues.txt`](../re/data/se-ui-cues.txt). They all live in **one**
bank — `BANK_SE` is a single field naming `Static.slb`, and 0 of the 322 has
its own `FILES` entry.
🟡 **Which event fires which cue is a name match**, not a measurement — strong,
because these are the authors' own event names, but nobody has watched the game
emit cue 2 on a d-pad press. The port is authoring it.
**The SE audio IS extractable — by playing it.** (This corrects an earlier
"cannot be extracted" on this page.) The disc carries no index: `Static.slb` has
**0 `RIFF`/`seek`/`WAVE`** and there is **no XACT container anywhere** (0 ×
`XGSF`/`SDBK`/`WBND` in 1.08 GB of `sound.pak`; no `XACT` string in the
executable — those extensions are the authoring tool's). But Canary's
`--xma_param_probe=true` logs every stream's head bytes, and searching them in
`Static.slb` locates the wave exactly:
**d-pad move → `0x1ec0` (4 pkts / 8 192 B, 0.533 s); Ⓐ confirm → `0x5d6c0`
(6 pkts / 12 288 B, 1.016 s); Ⓑ back → `0x0ec0` (2 pkts / 4 096 B, 0.344 s)** —
every cue the five screens need. Move and back reproduce with identical head
bytes across **two independent boots**. Each matched at one offset only, and
the first two are contiguous
(`0x0ec0 + 4096 = 0x1ec0`) — the bank is a packed run of whole 2 048-byte
packets with no delimiters, which is why nothing could be scanned for.
**⬅ and ➡ play nothing distinct** — no new stream on either, so leave them
silent (the probe dedups, so this excludes a *distinct* invalid cue, not a
quiet replay of an already-heard one).
🟡 The Ⓐ press both confirms and opens a screen, so whether its wave is the cue
named `SE_UI_DECIDE` or `SE_UI_SUB_WIN_OPN` is not separated.
⚠️ The order is **not** cue-id order, so the index must be observed per cue, not
counted. ✅ **The slices decode**: 0.533 s, 0.344 s and 1.016 s of mono 48 kHz
audio with the attack-and-decay shape of UI blips, via
`tools/re-capture/slb_extract_wave.py` — whose wrapper reproduces a known-good
`BGM_001` decode to the same 173.808875 s, so it is verified, not assumed.
* **The boot sequence is not data-driven — the port authors it.** ❔ Four places
were checked and the order is in none: `config.ini`'s `[SYSTEM]` is empty, the
movie manifest carries assets not transitions, the requested GamePart id lives
**only as a stack argument in flight** (no persistent field, no literal store),
and the string `GP_ADVERTISE_DEMO` has **zero xrefs**. A transition is a call
with an id argument, chosen by code.
🟡 **But the states have names, and the transition uses them.** `sub_821C6458`,
the title part's state function, calls **`sub_821CC860(…, "<NAME>", 0)`** with
`TITLE_SCREEN`, `TITLE_MENU` and `LOADING` — the three states measured off the
game, in the game's own words — and installs the result. So a transition is a
call with a **name** argument, which is also why `GP_ADVERTISE_DEMO` has no
xrefs: at this level the screen graph is name-keyed, not id-keyed.
✅ The argument is now **decoded** at 46 of that function's 48 call sites (28
distinct names). ⚠️ It is a **generic name-keyed lookup**, not a screen factory —
its arguments include `BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`,
`GAMMA_RGB`. An earlier version of this page claimed `DIFFICULTY` and
`EXTRA_MENU` as corroborated screen names; **neither is ever the argument**, and
only `TUTORIAL_MENU` survives.
**And the state machine itself is decoded**: `state = this+136`, ten states
dispatched through a jump table at `0x821C6498`, with **18 transitions** each a
literal `li`/`stw`. States **0, 2, 8** install `TITLE_SCREEN`, `TITLE_MENU`,
`LOADING`. `4 → 0` is the only edge back to the title, reached from `2 → 4`
which matches Ⓑ-returns-to-title as measured. Full graph in
[`data/title-state-machine.txt`](../re/data/title-state-machine.txt).
⚠️ **All of that is ONE PHASE.** `GamePart_Title` dispatches on an outer phase
field at `this+132` (five values) before reaching any of it: phase 0 is the
**developer splash** (`sub_821C5690`, the same function the corpus fingered
independently), phase 4 is the title/menu machine. The ten states and
eighteen edges above live inside phase 4 alone.
**State 4's edge conditions are an event code**`sub_821C6458`'s third
argument. State 4 is the input-waiting state (reached straight after the menu
is installed) and handles 6 of 26 events: **`0` → title**, **`3`, `5`, `8`,
`25``LOADING`**, `10` → state 5.
🟡 What the event *numbers* mean is not decoded — button id, menu row, or
message id — so the port still takes the button→destination map from
measurement. The one-to-title / four-to-loading shape matches the five-item
menu with Ⓑ, but that is a **count-match, not a mapping**. The sequence itself is fully measured:
splash → `ADV.wmv` → title + `PRESS Ⓐ` → (idle ~810 s → `ADV.wmv` in full →
title) → Ⓐ → main menu, with Ⓑ from the main menu returning to the title.
* **`config.ini` picks the language, and that is what picks the EN/JP build.**
The disc's **only** config file (400 bytes, at the root; one `find` over the
whole extract). Its `[LANGUAGE]` section maps the console's `XC_LANGUAGE_*`
value to `eng`/`jpn`/`deu`/`fra`/`esp`/`ita`, defaulting to `eng` — that code
selects `GP_TITLE`'s English or Japanese build and the `<lang>.pak` families.
✅ decoded.
❔ Its `[SYSTEM]` section — which the file's own comment says holds what the
game and every game part share — is **empty**, so the boot *order* is not in
disc-side configuration at all.
* **Five GameParts are named but never registered.** Pulling every
`RegisterToFactory<N, class silph::GamePart_X>` diagnostic string binds **24 of
the 29 ids to a C++ class**
([`data/gamepart-class-ids.txt`](../re/data/gamepart-class-ids.txt)). Ids `1`,
`2`, `16`, `18`, `28` have no registration site — including
**`GP_ADVERTISE_DEMO` (1)**, which agrees with the measurement that the attract
loop is the *title* replaying `ADV.wmv` rather than a separate part. 🟡 an
argument from a diagnostic string, not from the code.
Also: **`3` and `4` are both `GamePart_SaveLoad`** — one part, two ids.
* **The GamePart id table** — 29 entries at `.rdata 0x820A1630`, confirmed by the
executable's own registration strings. ✅ This is the screen vocabulary; which
button reaches which entry is Q4 and is *not* part of it.
* **The logo splash is a screen, not a video.** `logo1``logo4` are
manifest-bound with no `.wmv` on the disc. ✅
* **The logo splash is a screen, not a video, and it renders.** `logo1``logo4`
are manifest-bound with no `.wmv` on the disc. ✅ The screen is **two** bundles
in `GP_TITLE.pak`, each shipped twice: entries **10/13** the white
`SQUARE ENIX` publisher logo, entries **11/14** `GAME ARTS`/`SETA`/`studio
anima`. ⚠️ They are **invisible to the default `screen list`/`render`**
`is_build` rejects them for having no `.rat` child. Pass **`--all`**, which
renumbers `--build`. So the first of the five screens does have a reference
composite. ✅ **And a capture**: both halves edge-correlate to their renders at
**0.91** and **0.98** at zero shift, each rejected by the other (0.03, 0.03).
**Its timing is DECODED, not just measured.** The splash **fades both ways**
and the bundles carry the keyframes: `SQUARE ENIX` `[15 30 235 239 251 255]`,
developer logos `[15 30 190 194 206 210]`, each with an `_eff` glow child on
`[15 30 45]`. Under `1 unit = 1/60 s` that is a 0.25 s ramp in, a **3.42 s**
/ **2.67 s** hold, and a **0.33 s** fade out — and a 10 fps capture measures
≈ 3.5 s / ≈ 2.4 s holds and **≈ 0.3 s** fade-outs. The visible brightness
overshoot on the way in is the `_eff` glow ramping after the logo, not a
rendering artifact. Wall-clock: logos ≈ 0.44.7 s and ≈ 4.98.4 s, then
`ADV.wmv` from ≈ 8.9 s. ⚠️ The cyan `SQUARE ENIX` at ≈ 9.5 s is the
**movie's** opening, not a third splash screen.
* **Sprites carry their own labels.** No font rendering or localisation is needed
for this milestone. ✅
for this milestone. ✅ — and the localisation is *already baked in*: the
Japanese screens are separate builds in the same pak, not a text swap.
## Facts the port will trip over
* **`ADV.wmv` is WMV3 video + WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4
plays only Ogg Theora natively. How to handle that is the port's decision, not
ours — but it is not optional.
* **The disc holds 3.3 GB of video.** Only the boot intro and the one new-game
intro are in scope.
* **The disc holds 3.3 GB of video, and exactly two files are in scope:**
`dat/movie/ADV.wmv` (the boot intro *and* the attract loop — one asset) and
`dat/movie/S00A.wmv` (the new-game intro, 93.9 s). Named from the movie
manifest, ✅ decoded.
* **`Static.slb` over-declares its size** by 616 768 bytes — it is the
highest-offset entry in `sound.pak` and its size field is an allocation size. A
reader must allow a short read there and only there.
* **Voice downmixes to mono, music does not.** The left-channel downmix is correct
for spoken lines and discards half a music mix.
* **A music bank has several sub-waves and we glue them together.** `BGM_001`
is 10 KB + 4.47 MB + 4.67 MB, concatenated into one 347 s track. Nobody has
established whether those are intro + loop, two variations, or two halves —
see Q10. Do not build menu looping on the concatenated track until it is
answered.
* **A music bank is TWO STEMS THAT PLAY TOGETHER — do not concatenate.**
✅ The "10 KB + 4.47 MB + 4.67 MB" reading was wrong: the 10 KB is the bank
header, and a bank is exactly **two waves of identical duration** (32/32 banks
on the disc). `BGM_001`'s two are **sample-synchronous** — transient
correlation peaks at lag 0.00 s over ±5 s, and both stop at the same
millisecond, 167.663 s. Wave 1 is quieter, far more L/R-decorrelated and has
almost no bass, so it reads as a surround-rear pair or a second intensity
layer — 🟡 which of those is unsettled, and `ChannelMask` is `0x0002` on both,
so the file will not say. Today's 347 s concatenation plays the piece twice,
the second time as a bass-less stem.
**And it is not a seamless loop**: `BGM_001` fades out at 167.663 s and is
followed by 6.15 s of silence, with no loop-point field identified. A menu loop
is authored.
**The menu's music is `BGM_103`.** The cue *table* cannot say — its BGM
entries are numeric — but `GamePart_Title`'s `sub_821C5580` plays **cue 1103**,
and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are
byte-for-byte the two streams the XMA probe saw decoding at the main menu.
Static code, disc census and runtime all agree. The port does **not** have to
choose a track.
* **`JNGL_001.slb` does not decode.** One bank in 9 519; its payload is not a whole
number of XMA1 packets from any known data offset.
## What is still open
Every question above is answered, so this is the honest residue rather than a
work queue. **None of it blocks the five screens.** (Q1's seconds conversion was
here until 2026-08-28 and is now settled.)
| | what | why it is stuck |
|---|---|---|
| 🟡 | **cue NAME → event binding** (Q8) | event→**wave** is measured for move/confirm/back; that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still read off the authors' identifiers |
| ❔ | **the other ~319 SE cues** (Q8) | located one at a time by triggering them; only the three the menu needs have been done |
| 🟡 | **the paint-order tie-break** (Q3) | eight candidates refuted; costs one element's blend on one screen |
| 🟡 | **GamePart ids behind the buttons** (Q4) | the *screens* are measured; the ids are a name match onto the executable's class names |
| 🟡 | **the boot transitions in code** (Q6) | both levels decoded — phase at `this+132` (`entry→2`, `2→0`, `2→3`, `3→4`, `4→2`) and state at `this+136` inside phase 4. Phase 0 = splash (`LOGO`), phase 2 = title + `PRESS Ⓐ`, phase 4 = menu. Unknown: what the event *numbers* mean |
| ❔ | **builds 0/1 and 10/11**, the `DELTASABER` plates (Q2) | never seen anywhere in the boot path, the title-side screens or the attract loop. A mission load is the remaining candidate and this container kills runs before one completes |
(An earlier version of this table called the audio items blocked on "an emulator
whose audio path can be observed". That was wrong — this build already has
`--xma_param_probe`, and using it settled both.)
⚠️ **A container caveat that bounds all of the above:** the emulator has twice
been killed mid-run with no crash line in its own log (at ~50 s and ~145 s), on a
box sitting at ~1 GB free with swap exhausted. Dynamic experiments here have to
fit in roughly two minutes of guest time, which is why several of these residuals
are unfinished rather than unattempted.
## Reference data
Committed alongside the findings, so the port can be built without a disc in the

View File

@@ -75,7 +75,138 @@ alongside it.
| **Q8** | **Menu audio.** Which BGM per screen; which cue on move / confirm / back / error. The cue table is complete; the event binding is not | Cue names bound to events, with how you established each |
| **Q9** | **Video binding.** Which movie is the boot intro vs the new-game intro; whether playback is skippable and what ends it | Named movies plus the playback rules |
| **Q10** | **What are a music bank's sub-waves?** `BGM_001.slb` is three sub-waves — 10 KB, 4.47 MB, 4.67 MB — and we currently **concatenate them blindly** into one 347 s track. Two near-equal halves could be intro + loop, or two variations, or two halves of one piece. A menu that loops its music needs to know which | The role of each sub-wave, established for at least the menu BGM. "Concatenate" is a decision, not a default — right now it is a default nobody chose |
| **S1** | **Ready Room probe.** *Gated* — one iteration, then stop | A written go/no-go (see below) |
| **S1** | ~~**Ready Room probe.**~~ **DONE 2026-08-28 — [no-go](../re/ready-room-probe.md).** It is 2D and enumerates fine, but the pak is briefing/tactical-map content, not the Ready Room menu | ✅ go/no-go written |
## 🔴 Emulator-side questions are blocked — the title is not reachable here
**Status 2026-08-29, instrument-verified.** Two open items need a running menu:
the gamma control behind [tone curve](../re/structures/ui-render-tone-curve.md),
and separating `8AX` from `ptbase` in
[8AX](../re/structures/ui-8ax-fullres-background.md). Both are blocked.
**The negative is now solid.** A probe that restarts its capture stream every
30 s and cross-checks itself against an independent grabber every 60 s
(9 checks, 8 agreeing to within 2 %) ran **560 continuous seconds from launch at
3.30 fps**: the interactive title's green Ⓐ plate never appeared, while the game
rendered throughout. The final frame correlates 0.0145 / 0.0047 / 0.0102 with
our title / main-menu / `EXTRAS` renders — attract-movie content.
**Why is unknown.** `live-title-press-a.png` (753 glyph pixels) proves it was
reachable from this container on 2026-08-28. Clearing the shader cache fixed a
*black surface* but not this.
⚠️ Neither blocked item blocks the five menu screens. See
[capture-harness-status](../re/capture-harness-status.md) for the full trail,
including three earlier "the title never appears" claims that were withdrawn
because the instrument was broken each time.
## 🟡 Needs one more run — a Japanese-locale capture
Recorded rather than worked around, per "do not improvise around a blocker".
Two questions have converged on needing **one capture we cannot take**: a running
capture of `GP_TITLE` **build 7**, the Japanese title screen.
* Q1's keyframe-time association. The shifted reading is favoured 26× by a
calibration-free measurement, and the only render it changes on the whole disc
is build 7 ([`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md)).
* What `rest()` should return for a plateau-less element. The one element that
discriminates, `ptlogo_eff3.t32`, is also in build 7
([`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)).
**🔴 The "cannot" was wrong, and is withdrawn (2026-08-29).** It is true that
`user_language` is only ever `DECLARE_int32` here, with no `DEFINE` and no entry
in `xenia-canary.config.toml`, so there is no flag to pass — and passing an
unknown one is specifically dangerous, because `run-canary`'s own header records
that xenia calls `ShowSimpleMessageBox` from `ParseLaunchArguments` *before*
logging starts. But the cvar is not the only route, and I stopped at the first
one I checked.
**The language is persisted, and canary's own file is writable.**
`kernel_state.cc` builds `XConfig` over `<storage_root>/xconfig.settings`, which
exists here at `/sylph-home/re/.local/share/Xenia/xconfig.settings` (6 680 B).
`user.language` is a **big-endian u32 at file offset `0x912`**, located by three
independent landmarks rather than guessed:
| landmark | expected | found |
|---|---|---|
| `music_volume` (`User+449`) | `0.7f` | BE float at 2727 → `User` base `0x8e6` |
| `language` (`User+44`) | `1` = `kEnglish`, the hard-coded default | **1** at `0x912` |
| `country` (`User+64`) | United States | `103` at `0x926` |
`XLanguage::kJapanese = 2` (`xbox.h:307`). Writing 2 there and restoring
afterwards is using canary's own persistence, not patching its code.
**🔴 DIAGNOSED 2026-08-29 — the harness, not the game.** `screenshot` costs
**10.8 s while xenia is running** and **0.117 s once it is killed** (92×, at a
1-minute load average of 1.80, so it is contention with the emulator). A
two-grab polling loop therefore samples every **~41 s**, against a title screen
this corpus documents as lasting *a few seconds*. Four runs — two locales, two
launch paths, two gamma settings — were all blinking slower than the event.
🔴 **…and that diagnosis is itself refuted (same day).** The faster probe was
built — 3.98 s → **0.29 s** per sample, 13.7×, control-verified at 753/327 — and
at 3.99 fps for **420 continuous seconds (1 674 samples)** the interactive title
*still* never appeared. Sampling rate was a real defect and not the cause.
✅ So "the interactive title does not appear mid-run without a pad press" is
reinstated, now as a dense measurement. ⚠️ Its reach is a **mid-run** window; it
says nothing about the boot title.
🟡 Leading hypothesis, unconfirmed: the plate appears only in the **boot** title
window and the attract loop's title has none — which is precisely what
`title_states_capture.sh` was written to test. The experiment is to start the
fast probe from t=0, not attach to a run already in progress.
See [capture-harness-status](../re/capture-harness-status.md).
**🟡 Still not settled — two runs, and the reason moved again.**
*Run 1 (2026-08-28)* reported "title not seen" in 787 s. **That was a broken
tool, not the game.** `wait_title.sh` still carried the single-pixel oracle that
`is_title.py` was written to replace — a 1280×720 coordinate sampled against the
1279×675 game surface, so it always reads the copyright line. Fixed; it now
delegates to `is_title.py`.
*Run 2 (2026-08-29)*, with the working oracle and the profile flag the English
captures use, **still did not reach the interactive title.** Not one frame in the
run showed a single green-Ⓐ glyph pixel, and content correlation against either
build-7 render never exceeded **0.22**. The game sat in the attract movie
throughout, polling `XamInputGetKeystrokeEx` (601 calls) — i.e. alive and waiting
for input, not hung.
**✅ The control was run (2026-08-29), and it removes the locale from the
picture.** Same flags, same oracle, English locale: **75 samples over 734 s, every
one glyph = 0.** The English boot does not present the interactive title either.
Canary was alive throughout and polling `XamInputGetKeystrokeEx` (1 801 calls);
the frame at 734 s has content but correlates only **0.15** with build 4's render
and 0.05 with the main menu, i.e. it is an attract-movie frame, not title art.
So the Japanese run was **not** failing because of the locale — neither locale
reaches the interactive title in ~12 minutes of no pad input. **🔴 Tried the proven path too, and I am stopping this line (2026-08-29).**
Run 3 launched exactly the way `boot_menu.sh` does — `DISPLAY=:98`, `--apu=sdl`,
`/dev/shm/xenia_*` cleared, the existing profile signed in — and drove
`skip_intro.sh`, the detector that is documented to work. It classified **every
one of 20 samples over 604 s as "movie"** and waited them all out, then timed
out. A direct check at 604 s confirms it was right: zero green-glyph pixels,
`screen_id` = other, warm mean (69,53,40), correlation **0.09** with build 7. The
game really was playing attract movies for ten minutes.
So: **three runs, two locales, two launch paths, ~35 minutes of emulator time, no
interactive title.** Either the attract loop is far longer than the 600780 s
windows tried, or something has regressed since `live-title-press-a.png` was
captured (that one came from a pad-driven boot — commit `88b3ce9`, "booting to
the main menu and walking it").
⚠️ **Not worth more loop iterations.** This capture does not block any of the
five menu screens, and it has now cost five. It is written down so a later
session with a reason to spend an hour on the boot path can pick it up; the
locale mechanism, the fixed oracle and the launch recipe are all in place, so
what is left is patience with the attract loop, not tooling.
The locale is restored to English; `set_console_language.py ja` flips it back in
one command.
⚠️ Neither question blocks the five menu screens. Q1's *interpolation law* is
settled and only multi-keyframe absolute timing is open; `rest()` differs from
its alternative on **one** element across all five screens, and the current
answer there is the defensible one.
## Known unknowns — say so, do not fill them in

View File

@@ -19,7 +19,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` + `tests/idxd_records_disc.rs` ([container](structures/idxd-container.md)) | **The binary record/index region in front of the string pool is DECODED** (2026-08-25), closing the parser's long-standing "not yet decoded" note. Uniform 16-byte records `{name_hash, name_off, field_begin, field_end}` sorted by hash and binary-searched, then a field count, 12-byte fields `{key, name_off, value_off}` sorted by key, a pool size, and the string pool; the trailing `pool_size == file_len - pool_base` identity makes the layout self-checking. Verified over the **whole disc** with **zero** failures: 7 750/7 750 objects, 190 782/190 782 records reproducing their stored `tag_hash`, 1 271 462/1 271 462 named fields reproducing their key — and `IXUD` is the same container with `ixud_hash`, UTF-16BE and all offsets in **chars** (1 104/1 104 objects, 628 165/628 165 fields). **Field names are stored on disc** — a field's middle word points at its own name — so nothing needs preimage search except the **504** field entries disc-wide that are hash-keyed with no name — which are only **42 distinct keys**, each in 12 places (the page for these was never written — the finding is in this row), and are provably unrecoverable from the hash alone; the other 1 485 073 nameless fields are *positional*, keyed by a literal integer (line slots, movie ids). ⚠️ **Two long-held beliefs WITHDRAWN**: the word at `0x08` is **not a schema hash**, it is record 0's `name_hash` (7 750/7 750) — the header has no type field at all, so an object's kind is known only from the caller that loads it; and the field's middle word is **not** an always-`0xFFFFFFFF` flags word. The first was caught by a test asserting that every movie id names a real record: `1005 -> STAGE10_PHASE01` failed because `tag_hash("STAGE10_PHASE01")` **is** `0x067025B9`, that table's supposed schema id. 🟡 the legacy value-before-key string-pool reader is now known to be an *approximation* of the real table, and every number derived from it is re-checkable but not yet re-checked |
| IDXD nameless field keys | ✅/❌ | [idxd-unnamed-keys](structures/idxd-unnamed-keys.md) + [`tools/re-capture/idxd_unnamed_keys.py`](../../tools/re-capture/idxd_unnamed_keys.py) | Census of every field entry whose `name_off` is `0xFFFFFFFF`, disc-wide: **7 750 objects, 2 757 039 field entries, 0 parse failures**, `tag_hash` reproducing **1 271 462/1 271 462** named keys. **7 094 distinct keys are never named — and 7 052 of them are not hashes at all**, but author-assigned element ids (equal to the field's own index in 1 404 924 of 1 485 577 cases; `tag_hash("BGM_001")` is `0xC662435B` while the key valued `BGM_001.slb` is `0x000003E9`). ⚠️ **The "504 hash-keyed nameless fields" figure is 504 ENTRIES, not 504 names** — 42 distinct keys × 6 language copies × 2 records. All 42 are **ISL script-symbol hashes** in `<lang>\script\ID.tbl` (GP_READY_ROOM.pak), the link map built by `PrepareScript`'s "isl script prescanning"; 41 of 42 appear as little-endian call targets inside the `.isb` bytecode, forming a coherent launcher/helper call graph. The hash's own algebra pins the **trailing digits of 30 of the 42 names** (deltas of exactly `+0x01000001` across `stage01..09`, `stage10..16`, `challenge01..06`; `+0x01010000` across `tutorial0101..0601`). ❌ **No name was cracked, and the negative is quantified**: seven attacks up to a 3.5×10⁸ composition space found nothing above the noise floor; exhaustive preimage search recovers `"Stage01"` from its own hash but returns nothing for the real targets at ≤6 characters, and at 7 characters one target already has **1 176** preimages — a 24-bit modulus cannot name an 8+ character identifier uniquely |
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔. **2026-08-29, for the UI path only:** our composite is brighter than the emulator's frame by a gamma of **≈1.341.49** across three screens ([tone curve](structures/ui-render-tone-curve.md)) — 🟡 measured, not decoded, constrained only over render values ~060, and possibly canary's own `kernel_display_gamma_type = 2` (BT.709) output stage rather than the game's |
| T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
| RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat``pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** |
| LSTA sprite list | ✅ | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both**`count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix |
@@ -30,7 +30,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| Weapon fields defaulted on disc ⚠️ | ✅/❌ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv). ⚠️ **CORRECTED 2026-08-26: the disc DOES carry a third of them.** Re-read through the [record table](structures/idxd-container.md), **1 514 of the 4 393** rows labelled `defaulted-on-disc` have a value on disc (2 879 genuinely absent) — measured here as an upper bound, since a field is counted when it appears in *any* record of the object; a per-record count gives ~1 448. The old reader could not name a record, so per-record fields read as absent. Spot-checked exactly: `wep_05`/`wep_60` `TriggerShotCount` = **4**, `wep_02` `Power` = **100.0**, `wep_60` `Power` = **1000.0** (refuting the recorded "C band ≈150…500" bracket), `wep_25` `MaximumRange` = **4000.0**, `wep_11/28/36/70` `LoadingCount` = **6/5/5/0**. The runtime capture's numbers all match the disc — what is withdrawn is the premise that it was reaching values the disc lacks. See [weapon-datasheet-runtime](weapon-datasheet-runtime.md) Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc ⚠️ | ✅/❌ | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). ⚠️ **CORRECTED 2026-08-26:** those fields are **on the disc**. The ~30-field player-craft table is on disc at exactly the values the runtime "recovered", spread across the `Generic` / `Shield` / `Mass` / `SE` records — which is why a reader that could not name a record saw them as absent. And **"18 of 23 vessel records are missing at least one of `Size_X/Y/Z/HP`" is false: 0 of 114** objects with a `Generic.Type` (43 Craft + 71 Vessel) miss any of the four. See [unit-struct-runtime](structures/unit-struct-runtime.md) Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — ❌ **the sibling-default rules are WITHDRAWN (2026-08-25)**`Size_Y` is on disc for **114/114** unit tables and **differs from `Size_X` in 90**; the old reader missed it exactly when the two were equal (a string-pool dedup artefact, cross-tab `seen+equal = 0` for all three pairs), so the rule was re-deriving the condition that hid the field. It also predicts wrong twice — `UN_e104_ADAN_Carrier.DefencePoint` and `UN_e011_ADAN_Attacker_B_HF_Wayne.FCSRange`. Read the record table instead — [values](captures/unit-runtime-fields.csv) |
| Arsenal develop economy | ✅/❔ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly. **✅ 2026-08-28: the keyframe block's `+12` is a screen-plane ROTATION in degrees**, clockwise-positive — confirmed against a GPU capture (declared 30 / 45 vs measured +30.26° / 45.28°) and non-zero in 14.50 % of 83 862 blocks disc-wide ([ui-keyframe-rotation](structures/ui-keyframe-rotation.md)). ⚠️ Every rotation on the disc is inside a **nested** `.rat` leaf record, and `ui_layout::blit` is axis-aligned, so the field is decoded but **not rendered**. 🟡 `+4`/`+8` remain unexplained |
| UI screen paint order | ✅ | [runtime screen object](structures/ui-screen-runtime.md) + [title capture](ui-title-paint-order-capture.md) | **SOLVED**: the game's screen object keeps a second, reordered list of its elements — the child array at `+0x30` — and that is the paint order, not the declaration table. Read live from guest memory (found by the item vtable `0x820b30b4`) and checked against the draw capture: the seven nameable elements sit at child slots 0, 6, 7, 13, 16, 17, 22, strictly ascending, exactly as captured; it also settles the one pair no static field could order. 🟡 deriving that order from the bundle — what the port needs — is still open |
| Scripted input / profile traps | ✅/🟡 | [canary-scripted-input-traps](canary-scripted-input-traps.md) | Why a scripted run appears unable to press Ⓐ: **F10 opens the emulator menu bar**, and any Xenia UI makes `XamInputGetKeystrokeEx` return SUCCESS with an empty keystroke *before* any driver is asked (Canary now logs `[RE-INPUT] … swallowed by IsUIActive`); the title needs a **signed-in profile** (hence `--create_profile_if_none`); and a **FIFO trace consumer that exits stalls the emulator**, which reads exactly like a dead pad. 🟡 The main menu HAS been reached — Ⓐ works, but only intermittently (1 in ~4), which is the open question |
| Title-screen guest crash | ✅ | [title-crash-stl-tree](title-crash-stl-tree.md) | The guest throws **`std::out_of_range`** from its cache-manager flush (`sub_823070B0`, an STL map/set erase that builds `'invalid map/set<T> iterator'`); the access violation after it is only the throw **returning**, because this build does not unwind guest EH. Trigger found and controlled: an **incomplete on-disc cache** (`~/.local/share/Xenia/cache/aab216c3`) throws ~100 s into a boot, a complete one never does — 2 runs each way. ❌ `mem_watch`, the handoff's suspect #1, is **eliminated**: cold cache + `--mem_watch=false` throws anyway |
@@ -103,6 +103,7 @@ files, which is how the same ground got covered twice.
| [`structures/slb-data-offset.md`](structures/slb-data-offset.md) | `.slb` data offset = `(cumulative start of the `.pNN` segment) mod 2048`; the leading bytes are the previous bank's audio, not a header | ✅ CONFIRMED — exact for 8 783/8 783 banks, 0 mismatches; the four values (1392/1468/1600/1728) are the running sums of the five segment sizes mod 2048, which supersedes the earlier `first_riff % 2048` scan heuristic. Decoding at the right offset yields more audio in 85 of 140 sampled banks (median 70×), 54 identical controls. Wave boundaries are exact — `seek` magic at `data_at + declared_size`, **7 620/7 620** — and `Channels` must be read from `RIFF+49` (2.12 % are stereo) |
| [`structures/sound-pak-contents.md`](structures/sound-pak-contents.md) | Census of `sound.pak`, and the limit of the leading-region rule | ✅ CONFIRMED, 5 135/5 135 names hash into the TOC, **9 519/9 519** entries accounted for, and a full 4 114-bank manifest (408.3 min of audio) computed from PsuedoBytesPerSec without decoding; ⚠️ leading-region rule holds for 1 571/4 382 eng and 0/5 100 jpn |
| [`structures/sound-cue-table.md`](structures/sound-cue-table.md) | The cue index in `tables.pak` — message id -> cue -> sound id -> `.slb` bank | ✅ CONFIRMED, 1 326/1 338 script message ids bind to a bank; SOUNDS and FILES agree on the same 12 absentees, 0 orphan files |
| [`structures/bgm-two-stems.md`](structures/bgm-two-stems.md) | A music bank is two stems of one performance, played together | ✅ decoded structure, 32/32 banks two waves of equal duration (4 explained by the leading-region straddle); stems measured sample-synchronous; 🟡 rear-pair vs intensity-layer unsettled; ❔ no bank names a screen |
| [`structures/cutscene-message-table.md`](structures/cutscene-message-table.md) | Cutscene dialogue — speaker, portrait, on-screen seconds, audio cue per page | ✅ CONFIRMED, field count = 9·PageCount+2 for all 7 PageCounts, 1 252/1 252 caption keys match, 138 ids close both ways |
| [`structures/preset-message-rules.md`](structures/preset-message-rules.md) | The reactive-chatter rule table — 64 events, the (message, Yes/No) pair payload, the `Sperkers` roster | ✅ CONFIRMED, the seven fields read off `sub_82213980`, phase tables merge non-additively (224 variants discarded); 8 firing sites share one entry, Probability is a per-cent roll against rand*100.0; Yes = one-shot line; queue is Priority-ordered and capped at 128; the cooldown is Interval + Fluctuation*rand held in the lingering queue node; the 14 player-facing events are voiced by the player set alone (13/15 Katana tables, 0/129 others) (three are seconds ×60, the Yes/No element is a u32 mask, hence the 32 clamp); MessageCount·2 == positional count in 9 216/9 216; five naming routes union to 144/144 (six names predicted from the speaker roster before hashing, control 0/4); 2 388/2 405 message ids bind to a cue |
| [`structures/mission-scoring.md`](structures/mission-scoring.md) | Mission scoring and the S/A/B/C/D rank thresholds — 24 tables x 3 difficulties | ✅ CONFIRMED, one 22-field schema over 72 records; difficulty moves 10 of 22 fields and never the RankScore thresholds; ✅ all 24 named stage\StageParameter_S<NN>.tbl (+ _Tutorial shared by six, + _Test) |
@@ -149,6 +150,13 @@ files, which is how the same ground got covered twice.
| [`title-crash-stl-tree.md`](title-crash-stl-tree.md) | The title-screen crash is an STL `map`/`set` erase on a bad iterator | ✅ CONFIRMED — the guest throws std::out_of_range from an STL |
| [`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md) | A third measured paint order — tool built and validated, screen not reached | ✅ the reader works and is CONFIRMED against both previously |
| [`ui-quad-class-foothold.md`](ui-quad-class-foothold.md) | The guest's UI quad class — a foothold found from the capture's vertex layout | 🟡 PROBABLE for the identification below (it is a static read, but |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; 4 of 5 destinations driven. 🟡 GamePart id is a name match, ❔ `NEW GAME` untested |
| [`screen-transitions.md`](screen-transitions.md) | Between two screens — a fade through black, and where its timing lives | ✅ the fade quad's keyframe group is decoded (disc-wide: per-pak all-or-nothing; `GP_TITLE` = the 6 screens, not the 6 overlays); the ~0.4 s fade-OUT is measured, not on the disc |
| [`menu-audio-cues.md`](menu-audio-cues.md) | Menu audio — the event vocabulary is on the disc, the binding is not | ✅ `SE_UI_*` cue names/ids decoded and `BANK_SE``Static.slb` (0/322 in FILES); 🟡 event binding is a name match; ❔ `Static.slb` has no wave boundaries, so SE audio is not extractable |
| [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md) | What the game reads at boot — `config.ini`, and which GameParts exist | ✅ `config.ini` selects the language (the disc's only config); ❔ its `[SYSTEM]` is empty so the boot order is not in config; 🟡 24/29 ids bind to a class, `GP_ADVERTISE_DEMO` is never registered |
| [`movie-binding.md`](movie-binding.md) | Which movie plays where — boot intro, attract loop, new-game intro | ✅ decoded from the movie manifest (`ADVERTISE_MOVIE``ADV.wmv`, `MS00A``S00A.wmv`); attract identity confirmed independently by frame matching; 🟡 skippability unsettled |
| [`ready-room-probe.md`](ready-room-probe.md) | S1 — the Ready Room probe: no-go, and not for the reason expected | ✅ it is 2D and enumerates (60 builds), but the pak is briefing/tactical-map content; and `kind == 0x3002` finds 0 buttons there |
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn |
| [`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md) | The title screen's paint order, measured from the guest's draw submissions | ✅ CONFIRMED — the order in which the running game paints the title |
| [`upstream-baseline.md`](upstream-baseline.md) | A stock-upstream baseline runs Stage 02 crash-free | ✅ CONFIRMED — upstream canary_experimental + only the pad |
| [`weapon-datasheet-runtime.md`](weapon-datasheet-runtime.md) | Weapon DATA SHEET — runtime capture (Route B) | 🟡 first dynamic capture, 2026-07-28. The Arsenal's Gallery Mode panel is a |

View File

@@ -117,3 +117,724 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
* **Measure animation in submitted frames, not in seconds.** `VdSwap` counts are
the guest's own frames, so an emulator at 80 % of real time does not move them;
a stopwatch reading does, silently and by an unknown factor.
* **~~This game's menus drop d-pad presses shorter than ~0.3 s.~~ WITHDRAWN
2026-08-28 — the menu WRAPS, and I had not measured that.** The claim came from
reading a cursor that ended up "one item short"; once wrap-around at both ends
was measured ([`menu-navigation-semantics.md`](menu-navigation-semantics.md)),
every one of those press counts is exactly right — four presses at 0.12 s moved
four steps *through the bottom*, which lands one above where a non-wrapping
menu would put it. **No press was ever dropped.** The real lesson is the
general one: *a step count is only readable once you know the topology*, and I
invented a hardware-flakiness story rather than testing the ends of the list.
Still true and worth keeping: screenshot after every step and read the cursor,
rather than trusting arithmetic over the press count.
* **A screenshot taken right after a transition can catch a screen mid-fade.**
A grab 2.5 s after Ⓑ returned the game to the title showed the title art with
no `PRESS Ⓐ BUTTON` plate; one second later the plate was there. That very
nearly went into the corpus as "the returned title has no plate". Sample a
changing screen several times before writing down what it does *not* contain.
* **Do not identify a menu cursor by label brightness.** The obvious oracle —
"the focused label is the brightest row" — fails on this game's menus, because
the background art is brighter behind some rows than the highlight is. It
confidently named the wrong item on a frame whose ring was plainly elsewhere.
Detect the **focus ring** in the gutter left of the labels instead
(`tools/re-capture/menu_focus.py`, 254 vs <82 — no threshold tuning needed),
and look at the PNG before believing either.
* **`screenshot` samples at 0.5 Hz — it cannot time an animation.** Measured:
~2 s per grab (an `import` of the root plus an ImageMagick crop). A 0.4 s fade
falls entirely between two samples, which is why a 40-frame burst across a
screen change looked like an instant cut. For anything timed, record the
display instead: `ffmpeg -f x11grab -framerate 30 -video_size <W>x<H> -i
:98+<X>,<Y> -t <secs>`, then read per-frame statistics off the file. Take the
geometry from `xwininfo -root -tree`, the same way `bin/screenshot` does.
* **A screen's brightness curve is not its fade quad.** The incoming screen's own
elements animate in *after* the transition quad has cleared, so mean luminance
keeps rising long after the fade is over — 1.47 s against a declared 0.97 s on
one screen. Time the fade from where the frame is *pure black*, and take the
ramp itself from the keyframes.
* **A `sound.pak` entry is not a bank.** Entry windows straddle bank boundaries
(the leading-region effect), so a naive "walk the RIFFs in this entry" gives
the tail of the previous bank, then one of your own, then the *next* bank's
header. It looks like a bank with the wrong number of waves. The tell is a
`BANK id=` that is not the entry's own number, and the fix is the `seek` packet
counts — they pin each wave's join exactly, so a straddle can be realigned
rather than thrown away. 4 of 32 BGM banks look like counterexamples until this
is applied.
* **Do not time a video whose start you did not see.** An attract cycle sampled
from partway through measured "≈85 s", which was used to argue it was not the
137 s `ADV.wmv`. It was `ADV.wmv`; the sampling began 39 s in. Identify a video
by **content** — match frames against the candidates and check the matched
playhead advances monotonically — before reasoning about its duration.
* **A near-black frame has no signature.** In frame-matching, low-contrast frames
drive every correlation toward zero and the ranking becomes noise; one control
frame scored 0.000 and another tied the wrong movie at 0.98. Read a dark
frame's runner-up as *no evidence*, not as evidence for the runner-up.
* **A screen that never changes is not necessarily hung — it may be a menu.** A
90-second screenshot loop that presses nothing will report a perfectly healthy
`DIFFICULTY` menu as a standing hang, and that reading survived in this corpus
for months. Before calling a static screen a hang, *look at it*, and press
something.
* **Never run `ps -ef` in this container.** Each of the three long-lived
processes carries the whole loop prompt as its argv, so the listing is tens of
kilobytes of the prompt you already have. Use `pgrep -x <name>` or
`ps -o pid=,stat= -C <name>`.
* **A file extension is not a format.** `sound.pak` is full of `.slb` banks and
names a `.xgs` project, and neither is an XACT container: the whole 1.08 GB
archive contains zero `XGSF`, `SDBK` or `WBND` magic, and the executable has no
XACT string at all. The names came from the authoring tool and survived into the
shipped data. Check for the magic before committing to a parser for the format
the extension advertises.
* **A log-polling probe must compare a COUNT, not test for presence.** Timing an
operation by waiting for its "done" line in a log gives an instant false
positive if any earlier run left one there. This produced a confident
"39.5 fps" that was really 0.1 s of matching a stale line — and it disagreed
with the true 28.5 fps by enough to have flipped a conclusion. Snapshot
`log.count(marker)` before starting and wait for it to *increase*.
* **This container kills long emulator runs.** Twice in one session the
`xenia_canary` process vanished with **no crash line in its own log** — once
~50 s into a boot, once ~145 s in at 1.15 GB RSS — while the box sat at
~1 GB free with swap exhausted. Budget dynamic experiments to fit in ~2 minutes
of guest time, take the measurement early, and never read a disappearance as a
guest crash without checking the log tail for one.
* **When a frame-match will not resolve, sample denser before doubting the
hypothesis.** Identifying a movie from 5-second screenshots gave contrast-23
frames, a playhead that would not join up, and a runner-up winning one frame —
it looked like weak evidence for the wrong film. The same question sampled every
0.5 s (`ffmpeg x11grab` at 10 fps) answered at **0.999 with a strictly monotone
playhead**. Sparse sampling does not weaken a signal gracefully; it turns it
into noise that looks like a different answer.
* **Check what instrumentation the local build already has before declaring a
question blocked on tooling.** Two Q8 residuals were written off as needing "an
emulator whose audio path can be observed". The emulator in this container
already had `xma_param_probe` — visible in the startup CONFIG DUMP the whole
time — added by this project for exactly that purpose. Read the cvar dump, or
`nm -C <binary> | grep cvars::`, before concluding you cannot measure something.
* **"Strings a function references" is not "arguments a function passes".** A
loose filter over the strings in a caller produced a tidy-looking screen
vocabulary; recovering the actual register argument at each call site removed
half of it, including two names that appeared to be corroborated by independent
measurement. If the claim is about an argument, simulate the registers — the
cheap proxy manufactures agreement.
* **A backward scan for `addi rN, …` misses arguments staged through another
register.** PPC code often builds a pointer in a scratch register and moves it
into place later (`addi r27, r11, X``mr r5, r27`). A backward search for the
destination register alone found 7 of 48 sites and missed the two known-good
ones. Simulate forward over a window instead, tracking `lis`/`addi`/`mr`.
* **Check a measurement against the WHOLE reference set, not the part you were
reading.** Two observed BGM stream sizes were recorded as matching "no bank's
declared waves", which spawned a wrong theory that the decoder gets a window
rather than a whole wave. They match `BGM_103` exactly — the check had covered
only the `BGM_0xx` rows of the census, because that is the block that had been
on screen.
* **`instructions.function` is unpopulated for most rows in `sylpheed.db`.** A
query keyed on it returns *nothing* rather than erroring — a `bctr` search
scoped that way reported "no jump tables" for a function with two known ones.
Scope by `address between <start> and <end_address>` instead, and gate any such
query on a function whose answer you already know.
* **Before testing "state X behaves like this", check that your route actually
reaches state X.** A test of "does Ⓐ work in phase 4 state 0" was run by
pressing Ⓑ, on the strength of a decoded `4 → state 0` edge — but the same basic
block also writes the *phase* field, sending it to phase 2. The test probed the
wrong state and produced a confident refutation of a live hypothesis. When a
transition sets more than one field, read the whole block, not the one store you
were looking for.
* **A default filter can hide a whole screen, and the corpus will record it as
absent.** `screen list`/`render` filter on `is_build`, which needs a `.rat`
child; the developer splash has none, so it never appeared — and a page ended up
stating it "cannot be rendered at all". It renders fine under `--all`. When two
pages disagree about whether something exists, run the tool both ways before
believing either.
* **A logo that appears twice may be two different things.** The boot shows
`SQUARE ENIX` at ~2 s and again at ~10 s; the first is the splash screen, the
second is the intro movie's own opening frame. Reading the capture alone gives
"three logo screens". Cross-check a repeat against what is *supposed* to be
playing at that moment before counting it as its own screen.
* **Do not hard-code an expected element count into a structure scan.** Looking
for a `.rat` record's keyframes, I required 29 strictly-increasing times because
a header word read `30`. The records hold **three** keyframes in exactly the
layout I was testing for, and the over-constrained filter reported the layout as
refuted. Scan for the *shape* (here: `0x??ffffff` fade words at a constant
stride) and let the count fall out.
* **Read the target text before writing a patch pattern for it.** Two iterations
running, a scripted edit to `HANDOFF.md` failed its assertion because the
paragraph had been rewrapped since the pattern was written — and both times the
finding landed in `docs/re/` while the page the port agent reads kept the stale
version. The commit's file count is the tell: if it is one short, an edit was
silently dropped.
* **Two renderers agreeing is not evidence the field is right.** The port's
composite and `sylpheed-cli screen render` matched on `main_menu` to 3/255 —
and *both* omitted two elements the game draws, because both read one field
through one decoder. Agreement between consumers of the same decode measures
the decode's determinism, not its truth. Only a framebuffer capture is an
independent check. (Reported by the port agent, 2026-08-28, after a capture
crop found the missing bracket.)
* **`./target/debug/<bin>` is STALE in this container — `CARGO_TARGET_DIR` is
redirected.** The Dockerfile sets `CARGO_TARGET_DIR=/sylph-home/re/target-container`
so a fresh `cargo build` writes there, while `./target/debug/sylpheed-cli`
remains whatever was built on the host, hours old. A decoder fix was verified
against the stale binary and produced byte-identical "before" and "after"
renders — which reads exactly like "the change had no effect" rather than "you
ran the old code". Run the binary cargo actually wrote, or `cargo run`.
* **A pivot-equality test conflates rounding with real breakage.** Checking
`pivot == texture/2` over `GP_TITLE` flagged 74 % as mismatched, which reads as
a systemic decode failure. Split by magnitude it is 17 exact, 54 off by ≤ 1 px
(odd dimensions rounding), and only **24 off by > 8 px** — and those 24 are the
real finding, concentrated on one element family. Bucket a mismatch by size
before reporting a rate.
* **I have now broken the "read the target text first" rule three times.** Each
time a scripted `HANDOFF.md` edit asserted on a paragraph that had been rewrapped
by an earlier edit in the same session, and each time the finding landed in
`docs/re/` while the port's page kept the stale text. Writing the rule down was
not enough; the working fix is mechanical — `grep`/`sed -n` the target paragraph
in the same command that patches it, and check the commit's file count.
* **"The nearest header after the name" is not a name→header mapping.** Looking
for each sprite's `T8aD` header by searching for its name and taking the next
`T8aD` returned *the same header for every sprite* — all the names live together
in the declaration table near the top of the bundle, so "next" is always the
first one in the file. It failed its own control instantly: different sprites
reported identical dimensions. Match on something the header itself carries
(here width × height against the decoded PNGs) rather than on proximity.
* **`cargo build` passing does not mean `cargo test` compiles.** Adding a field to
`T8adImage` built the library fine in 1.48 s — and broke two *test-only* struct
literals, so `cargo test` failed to compile with exit 101. A green build on the
binary is not a gate for a struct change; run the tests before believing a
data-structure edit is safe.
* **Detach long background work with `setsid`, or a tool-call timeout kills it.**
Three attempts to run the disc-gated test suite in the background died at exit
143 with an empty log — the timeout's SIGTERM reached the whole process group.
`setsid nohup cargo test … &` survives, and the run then completes across later
tool calls. An empty log from a "background" job usually means it was killed,
not that it produced nothing.
* **Validate a recommendation before leaving it as advice.** Having concluded a
question was undecodable from the disc, I named a per-draw GPU capture as the
next route "because it reads the blend state". It does not — checking the
capture's own source showed it records textures and vertex attributes and no
blend registers. A named next step is a claim like any other.
* **Name the element from the draw's own coordinates before building on it.** A
skewed draw in a title capture was called "the swoosh" because the swoosh is the
screen's only diagonal element — an identification by elimination, never checked
against the draw's NDC extent. It was wrong: the quads span the full screen and
beyond, the swoosh is a 234-pixel band. Five iterations of analysis pointed at
the wrong element on the strength of it. Converting the coordinates takes one
line and should come first.
* **A census over a bundle's top-level table is not a census over the bundle.**
The keyframe rotation field read "always zero on this screen" for several
iterations because every scan walked `GP_TITLE` build 4's **declaration
table**, and the rotated elements are **nested leaf records** reached through
an `opt ` link. The bytes were right; the *reach* was wrong, and a negative
stated without its reach reads like a fact about the disc. Say which region a
negative covers, and check whether the thing you are looking for lives outside
it.
* **Do not assume 4-byte alignment when scanning raw bundle bytes.** A nested
`RATC` blob starts wherever the parent's chunk stream leaves it — `ptloop01.rat`
sits at `0xbb5966` — so its 40-byte keyframe blocks are odd-aligned. A scanner
that filtered candidates on `%4 == 0` found **0/3** of its own control blocks
and under-counted the corpus by **16 341** blocks, all of them nested. It cost
nothing to catch, because the control was in the script.
* **`pkill -f <pattern>` matches the shell running it.** `pkill -f kfscan.py`
from a `bash -c` whose command line contains `kfscan.py` kills its own shell:
the tool call returns exit 143/144 and the edit that was queued behind it never
runs. The same trap makes `pgrep -f` self-report — a "still running? yes" that
was the shell seeing itself, on a job that had already finished. Use
`pgrep -x`, or match on a path the current command line does not contain.
(This is the second `ps`/`pgrep` entry in this file; the first is about
`ps -ef` dumping the loop prompt.)
* **Grep a legend and you count the legend.** A sweep for elements with a
rotation reported "1 element" in every build on the disc — the header line
`a=alpha r=rot°` matched the ` r=` pattern. A uniform count across
heterogeneous inputs is the tell. Make the pattern require the *value*
(` r=-?[0-9]+`), and sanity-check that a known-negative build reports zero.
* **A pattern over the archives you happened to check is not a negative.** I
wrote "rotation appears to live only in nested leaf records", correctly scoped
to the three paks I had swept — and the sweep refuted it on the fourth, within
the hour, because the alphabet had not reached `GP_DIALOG` yet. Scoping the
claim was what made it cheap to withdraw, but the better move is to let the
sweep finish before writing the sentence at all: a negative that is still
running is not a finding, it is a prediction.
* **A field that is implemented is not a field that is measured.** The
pivot-anchored scale term had been in `blit` for months and reasoned about in
the handoff, and no capture had ever exercised it, because every element anyone
had looked at was at 100 % scale where the term is identically zero. Look for
the case that makes a term *large* — here 600 % and 800 % scale, worth 450 px —
and check it there. A term you cannot distinguish from zero has not been
verified by any amount of agreement.
* **Printing a series' minimum and reading it as its range.** I summarised a
captured alpha series as "constant α ≈ 33" and built a contradiction on it —
the summary printed `min_alpha` and no maximum, and the series actually ramps
34 → 255 → 33. The tell was there in the same table: the column beside it said
*14 distinct colours*, which a constant series cannot have. When a summary
statistic and a distinct-value count disagree, the summary is wrong.
* **Calibrate on one element, test on another.** Fitting a declared ramp to a
capture has two free parameters (rate and offset) and will "succeed" against
almost anything — my first attempt scored RMS 128/255 and I nearly read the
numbers rather than noticing the search could not reach the ramp at all. The
version that means something: fix the clock from element A's ramp, check that
fix against A's own next landmark, then apply it to element B in the same
frames with **nothing left to tune**. That is what turned "the shapes look
different" into "still at 255 nine frames after it should read 32".
* **Two measurements can both be right and still disagree, when a heuristic sits
between them.** A change to the keyframe time association is favoured 26× by an
animation measurement and rejected by a static-render comparison. Both are
sound: `rest()` picks a pose by *longest dwell*, which is a heuristic layered on
top of the times, so moving the times moves its tie-breaks. The mistake would
be to treat one as refuting the other and drop the loser. Name what each
measurement actually constrains — here *timing* versus *pose selection* — before
deciding they conflict.
* **Before believing a render diff refutes a decode, find out which element moved.**
I reported a 13 % render change as evidence against a keyframe-time hypothesis,
on a brightness argument about language twins. One element accounted for all of
it, and that element is a transient flash with **no resting pose** — so the
difference was our own `rest()` heuristic guessing, and it would have guessed
under either reading. A whole-image statistic (pixels changed, mean luminance,
correlation) tells you *that* something moved, never *what*, and an argument
built on it can be entirely about your own code. Localise the diff to an
element and read its keyframes first.
* **A fallback that only runs when its precondition fails is not a fallback.**
`rest()` picks the longest-dwell keyframe when no plateau exists — but a dwell
gap is time spent *moving between* two poses, and the only case where an
endpoint is genuinely held is when the two poses are equal, which is exactly
the plateau the first path already caught. So the fallback is guaranteed to be
guessing every time it is reached. When a heuristic's justification is the same
condition that routes around it, it has no justification.
* **Siblings adjudicate a rule that a single element cannot.** Deciding what
`rest()` should return for an element with no held pose looked like it needed a
capture we do not have. It did not: the developer splash carries three glows
with identical structure and identical times, differing in one alpha byte, and
any rule that makes one of them behave completely differently from the other
two is producing an artefact. Parallel constructions in the same bundle are a
free control — look for them before concluding a question needs new measurement.
* **Backgrounding with `&` in a compound command loses the working directory.**
`cd X && cmd &` followed by more work in the same call left the shell reset, and
a heredoc two lines later wrote nowhere — while the `echo` after it still
printed "written". A success message after a failed redirect is not a success.
Use `(setsid nohup … &)` and keep the file-writing in its own call.
* **Read the whole function before describing what it does.** I wrote "a
zero-scale pose paints a 1-pixel sliver" into a tool's docstring, from the
`.max(1)` in the size arithmetic. Two lines above it, a guard coerced
`scale == 0` to **100 %**, so the `.max(1)` never saw a zero and the element
rendered at *full size* — a much larger bug than the one I described. I had
read the lines I went looking for and stopped.
* **Never diff a file a background job might still be writing.** A comparison
loop over an output directory reported `GP_TITLE` build 6 as changed; the pixel
diff was then all zeros, because `cmp` had read a half-written PNG. Compare
only after the job signals done, or skip the newest file. A "difference" that
vanishes on re-examination is usually a race, not a flake.
* **A `screen list` BUILD index is not a pak ENTRY index.** `GP_TITLE` happens to
map 1:1 (16 builds, entries 015), which is how the assumption survived;
`GP_DIALOG` does not — its build 0 is **entry 2**. Indexing a pak directly with
a build number silently reads a different bundle and reports a confident wrong
answer: a census control asked for the two rotated elements of `GP_DIALOG`
build 0 and got zero, from a bundle that genuinely has none. `screen list`
prints the mapping; use it rather than assuming identity.
* **"There is no flag" is not "there is no way".** I declared a Japanese-locale
capture impossible in this container because `user_language` is `DECLARE`d and
never `DEFINE`d, so no cvar exists to pass. That was true and not the question.
The language is *persisted*: canary builds `XConfig` over
`<storage_root>/xconfig.settings`, `SetDefaults()` only supplies the value when
the file has none, and the file is writable. One grep further — for where the
setting is **stored** rather than where it is **configured** — turned a
"blocked, needs a human decision" into a two-line edit. When you conclude a
capability is absent, check the persistence layer before writing it up.
* **Locate a binary field from struct landmarks, not from a hard-coded offset.**
`user.language` was found by scanning for `music_volume`'s `0.7f` (a known
default at a known relative offset), then cross-checking `language == 1` and
`country == 103` against the emulator's own `SetDefaults()`. Three agreeing
landmarks, no offset assumed, and the check re-runs every invocation — so the
tool fails loudly if the layout moves instead of silently patching a
neighbouring field.
* **A stale oracle reports the world, not itself.** A 787 s run concluded "the
game never reached the title". It had, repeatedly: `wait_title.sh` was still
sampling the single pixel (625,618) that `is_title.py` had already been written
to replace — a 1280×720 coordinate against a 1279×675 game surface, always
reading the copyright line. The replacement existed *in the same directory*,
with a docstring naming the bug. Before believing a negative from a probe,
check whether something in the toolkit already supersedes it, and run the probe
against a known-positive capture first — `is_title.py` scores 753 on a real
title frame and 327 on the main menu, which takes one command to confirm.
* **Run the control before theorising about the difference.** A Japanese-locale
run failed to reach the interactive title, and I wrote up the open question as
"if English reaches it, the difference is the locale". English does not reach
it either — 75 samples over 734 s, every one a miss. The locale had nothing to
do with it, and two iterations framed the problem around a variable that was
never implicated. The control cost one run and would have cost the same one
iteration earlier.
* **Log every sample, not just the hit.** The first probe printed nothing until
it succeeded, so a 787 s failure was indistinguishable from a probe that never
ran. Printing the oracle's value each tick turns "no title" into "75 samples,
max glyph 0", which is a measurement — and it is what showed the black-screen
and attract-movie phases were being sampled correctly all along.
* **Know when to stop paying for a non-blocking answer.** A capture that would
settle two 🟡 residuals — neither of which blocks the deliverable — absorbed
five iterations. Each one produced a real finding (a broken oracle, a wrong
"impossible", a locale red herring), so none was wasted, but the *question*
never moved. The signal to stop is not "this is hard", it is "the thing this
unblocks is not blocking anything". Write down the recipe you built and leave
it for a session with a reason to spend the time.
* **Audit what a renderer silently SKIPS, not only what it draws.** `compose` has
half a dozen `continue` paths, and a sprite-name miss is one of them — an
element vanishes with no error, which is exactly how the port agent's missing
bracket happened. `screen render` already prints its omissions; reading that
list for the five screens that matter took one command and turned up a
full-screen opaque element dropped on three of them. Any composer with skip
conditions should be asked to name what it skipped before it is trusted.
* **An element's declared name is not necessarily its texture's name.** The
`opt ` link on `pteff05.t32` leads to a `T8aD` registered as `8AX`, so a
lookup by declared name misses. Before concluding a texture is absent, look at
the bytes the link actually points at — the name in the declaration table and
the name the sprite table keys on can differ.
* **A fit whose residual is large for every model is a broken method, not a
close call.** Comparing our composite to a capture pixel-wise gave mean abs
errors of 1014 for gamma, for a linear scale, for everything — and a
non-monotonic transfer curve (render 96127 mapping *brighter* than render
128159). The cause was edge misalignment: at correlation 0.947 a bright pixel
in one image routinely lands on a dark one in the other. Restricting to patches
that are flat in **both** images dropped the residual to 0.21.1. When every
candidate model fits badly, stop choosing between them and look at what the
comparison is actually measuring.
* **Say when a control failed to discriminate, rather than reporting it as a
pass.** The held-out screen for a tone-curve fit was the developer splash,
whose flat regions are pure black — every model scored ≈ 0.00 error there. That
is not corroboration; it is a test with no power, and reporting the 0.00 as
agreement would have dressed an untested claim as a verified one.
* **Time your probe against the thing you are probing for.** Four runs concluded
"the game never reaches the title". `screenshot` costs **10.8 s while xenia is
running** and **0.117 s once it is killed** — 92× — so a two-grab polling loop
samples every ~41 s, against a title screen this corpus documents as lasting a
few seconds. The harness was blinking slower than the event. Before believing a
negative from a polling loop, measure its interval and compare it to the
duration of what you are waiting for; and measure the probe's cost *under the
same load as the run*, because idle timing here was off by two orders of
magnitude.
* **Do not change a display setting and a capture behaviour in the same run.**
`kernel_display_gamma_type = 0` brightens the frame, and `skip_intro.sh`
classifies movie-vs-static on an *absolute* rmse threshold — so the gamma
change biased the very classifier the run depended on. Harness thresholds tuned
on one output configuration are not portable to another.
* **Fixing the instrument is how you test the explanation that blamed it.** I
diagnosed four failed runs as "the probe samples slower than the event",
wrote it up, and withdrew three earlier conclusions on that basis. Building the
faster probe refuted it: at 100× the sampling density, over 420 unbroken
seconds, the event still never occurred. The diagnosis was a real defect that
happened not to be the cause — the most seductive kind, because fixing it feels
like confirmation. A plausible mechanism that explains a failure is a
hypothesis, and the fix is its experiment, not its proof.
* **Ask whether the screen is drawing anything before explaining why a feature of
it is missing.** Four iterations produced four explanations for "the title is
not detected" — a stale oracle, the locale, the sampling rate, a one-shot boot
window — and three of them were refuted in turn. Splitting a raw root grab into
bands then showed the game surface at **0.08 % non-black, mean 0.07**: it was
rendering black. The check costs one command and belongs at the *front* of that
sequence, not after the fourth hypothesis. A detector reporting absence is
ambiguous between "the feature is not there" and "nothing is there".
* **A newly found fault does not retroactively explain older failures.** The
black surface is real and reproducible *in the run where it was measured*.
Earlier runs in the same session had measurable content (mean 33.1, mean 122.8,
non-zero frame-to-frame rmse), so the failure mode changed over the session.
Reaching back to re-explain earlier results with the newest cause would have
been the fourth over-reach in a row.
* **Validating a probe on static test images tests its ANALYSIS, not its
ACQUISITION.** The fast probe's counter was controlled against committed
captures and matched exactly (753 / 327), which proved the numpy expression
right and the frame source untested. The source was the broken half: a
long-lived x11grab stream degrades from 3.98 to 1.60 fps and then freezes,
repeating one stale frame. The check that catches it is to read the same screen
with an **independent grabber at the same moment**`import` said 125.65 where
the stream said 5.21. Do that during a run, not only at design time.
* **A dense negative from one instrument is worth less than one cross-check.**
"2 391 frames, zero hits" reads as overwhelming and collapses to nothing once
the frames may all be the same frame. Sample count is not evidence of coverage
unless the samples are known to be independent.
* **A probe that cross-checks itself turns "no result" into a result.** Three
successive "the title never appears" claims were withdrawn because the
instrument was broken each time. The version that finally held prints its own
reading beside an independent grabber's every 60 s — 9 checks, 8 agreeing to
within 2 % — so the negative arrives with its own evidence that the frames were
live. Building the cross-check into the tool costs a few lines and is what
separates a measurement from a silence.
* **A hedge in a code comment is an unmeasured claim.** `compose` said the
derived paint order "reproduces both measured orders up to ties" — a sentence
that sounds like a result and was neither measured nor kept current: there were
three measured orders by then, not two. Measuring it took one example program
and turned a hedge into a bounded number (exact on 4 of 5 bundles; the 5th off
by 8 pairs, all ties). Grep your own comments for "up to", "roughly",
"essentially" — each one is a claim nobody has checked.
* **Count the cases that can actually bite, not the cases that match the
pattern.** `EXTRAS` has 15 tied pairs in its derived paint order, which reads
alarming. Only **2** of them overlap, and a tie between non-overlapping
elements paints identically either way. Reporting 15 would have overstated the
risk by 7×; the useful number is the one filtered by whether the difference can
reach a pixel.
* **An exhaustive field search needs a positive control, or "found nothing" is
worthless.** Scanning a header for a field that reproduces a measured ordering
returned zero hits — which could equally mean the field is absent or the scan
is broken. Running the same scan against a *known* ordering (declaration order)
returned **64** hits, proving the scan finds ordering fields when they exist.
Only then is the zero a finding. The control costs four extra lines and turns a
silence into a negative with reach.
* **A stale row on the handoff page is worse than a missing one.** The port was
told SE audio was "undecodable from the disc" while the linked doc had already
retracted that and located three cues decoding to PCM. The finding landed in
`docs/re/` and the page the port reads kept the superseded text — the fourth
time in this corpus. Writing the rule down has not worked, so there is now a
tool: `tools/re-capture/handoff_lint.py` flags every HANDOFF line that makes a
strong negative claim and links a doc containing retraction language. It found
this row, and one benign false positive, on its first run.
* **Narrow a risk before reporting it, then narrow it again.** "15 tied pairs on
`EXTRAS`" became 2 (only overlapping ties can paint differently), then 1 (one
of those two is a `loop*` animation the compositor skips), then "consistent
with the capture". Each step is a filter that costs one query and changes the
number by more than an order of magnitude in total. A raw count is almost never
the number a consumer needs.
* **Nothing was checking that the docs' cited evidence exists.** A sweep of every
relative link under `docs/` found **16 broken**, and two of them were the
figures backing the UI layout decode's headline claim — the port's foundation,
unreachable from its own page, because a path in `structures/` was written one
directory too shallow. Eleven were wrong relative depth with the target present;
five name files that do not exist. Evidence that cannot be opened is not
evidence, and a link is exactly the kind of thing no one re-reads.
`tools/re-capture/doc_link_check.py` now does it, and also flags targets that
resolve to a **zero-byte** file — which looks correct in every listing.
* **Repair in bulk only when the counts pair.** The fixer rewrote 11 links; the
checker went from 1 038 resolving / 16 missing to 1 049 / 5. +11 and 11
against 11 edits is the confirmation that the pass did what it said and touched
nothing else. A bulk edit without that arithmetic is a hope.
* **When two candidates carry the same content, compare their DIFFERENCE against
the oracle, not themselves.** `8AX` (1280×720) and `ptbase` (640×360 at 200 %)
are the same artwork, so no comparison of either against a capture can separate
them — every such test had been read as "inconclusive, needs a per-draw
capture". What separates them is `8AX upscale(ptbase)`: the detail only one
of them has. Correlating the capture's residual against *that* answered it
statically, on two screens, with matched controls.
* **A ratio that saturates at 1 under noise is not a discriminator.** A pixel-pair
test cleanly separated upscales (0.000.72) from native (0.98) and put the
capture at 1.01 — apparently decisive. Additive noise raises both terms of the
ratio equally and drives *any* value toward 1, and fitting a noise term showed
both hypotheses reproduce the observed numbers. Before believing a ratio, ask
what it does as noise grows.
* **Read what a cvar DOES before building an experiment around it.** I planned a
run to decide whether a measured gamma was "canary's BT.709 output stage",
by setting `kernel_display_gamma_type = 0` and re-fitting. The cvar is not an
output stage: it is the value a `kStub` **getter returns to the guest**, which
the game uses to build its own ramp, and canary applies *that* ramp in the swap
path. The experiment could never have isolated a stage that does not exist —
and it would have perturbed the capture harness as a side effect. One grep for
the cvar's definition replaced a planned emulator run with a better one.
* **An absence in a log is only evidence if the log would have shown it.** No
gamma lines appear in any run log here — but kernel exports log at Debug and
this harness masks Kernel logging entirely (`log_mask = 13`), so their absence
is guaranteed regardless of what the game did. Check the logging configuration
before reading silence as a result.
* **Check what an experiment actually needs before filing it behind a blocker.**
The gamma question sat parked behind "needs the emulator to reach a menu" for
several iterations. It needed the emulator only to **boot** — video init, and
the `VdGetCurrentDisplayGamma` call, happen in the first seconds, long before
any title screen. A blocker that stops one experiment does not stop every
experiment in the same area, and it is worth re-reading the parked list against
what each item really requires rather than against the area it belongs to.
* **A default value is evidence.** Whether the game writes a gamma ramp looked
like it needed a GPU trace. It mostly did not: canary initialises the ramp
table to **identity** and applies it through a shader that is a pure LUT lookup
with no other transfer. An unwritten ramp is therefore a no-op, and any
non-identity transfer in the output implies a write. Reading what a field holds
when nobody has touched it turns "I cannot observe the write" into "the write
must have happened" — cheaper than instrumenting, though it stays an inference
and should be labelled one.
* **Name the weak joint of an inference in the same breath as the conclusion.**
The chain above assumes our composite reproduces the pre-ramp framebuffer,
which is the one step that could be wrong. Writing that down beside the
conclusion — rather than only the supporting facts — is what lets a later
reader attack it instead of inheriting it.
* **The startup config dump is the config FILE, not the effective flags.** A run
passed `--log_mask=12 --log_level=3` and its dump printed `log_mask = 0,
log_level = 2` — while Kernel Debug logging was demonstrably on, which is how
that run's finding was obtained. So a dump can neither confirm nor refute a
command-line override, and reading one as "my flag was ignored" is a mistake I
nearly made with `trace_gpu_stream`. Verify a flag by its *effect*, not by the
dump. (This does not undo the earlier `user_language` conclusion: absence of a
*name* from the dump still shows the cvar is unregistered.)
* **`kill -9` destroys anything that finalises on shutdown.** Canary starts a GPU
trace at init but only closes it in `GraphicsSystem::Shutdown()`, so the hard
kills this session used routinely could never have produced a trace. Before
concluding a feature is broken, check whether the way you stop the program is
what discards its output.
* **Bound a risky experiment from inside, not by watching it.** A boot-time GPU
trace on a disk at 95 % could have filled it between two tool calls. The runner
carried its own watchdog that killed the emulator the moment the output passed
a 2 GiB cap, so the experiment was safe regardless of how coarsely I polled.
The watchdog never fired, which is the point — it cost nothing and removed the
need to gamble on timing.
* **A cvar existing does not mean the feature is compiled in.** `trace_gpu_stream`
parses, is documented, sets state, and does nothing: the code that opens the
trace sits behind `#if XE_ENABLE_TRACE_WRITER_INSTRUMENTATION == 1`, which
`trace_writer.h` ties to `#ifdef NDEBUG` — off in release. Two runs and two
wrong hypotheses before reading the `#if`. When a switch produces no effect,
follow the code from the switch to the output and look for a compile-time gate
before theorising about runtime causes.
* **Test a compile-time gate against the binary, with a control.** A string that
exists only inside the guarded block (`_stream.xtr`) settles it in one command:
0 occurrences in the release binary, **1** in the debug binary. The debug build
is the control that proves the test can find the string when it is there —
without it, "0 occurrences" is just as consistent with a bad grep.
* **`T8aD` headers sit in RATC child order — use that, not the size, to name
one.** Attributing a sprite header to a sprite name by matching decoded
dimensions fails whenever two sprites share a size, and `GP_TITLE` build 4 has
such a pair (`ptlogo_back2eff` / `ptlogo_back2eff5`, both 1133×280) that differ
in exactly the flag being studied. Header order matches child order — verified
18/18 on that build against the dimensions as an independent check — so
ordering resolves what size cannot.
* **Identical summary statistics are not identical data.** Two same-sized sprites
reported the same %opaque, %clear and mean alpha to one decimal, which reads as
"the bundle stores this texture twice". Pixel-comparing them gives max abs diff
**21**: two different renditions of one image. Three matching summaries over
300 000 pixels is weak evidence of identity; the comparison costs one line.
* **A pattern that is perfect on one screen can be near-chance on the disc.** The
`0x02` bit matched `eff` names 10/10 on `GP_TITLE` build 4, which is exactly
the sample size that makes a rule feel found. Disc-wide it holds 46.8 % of the
time — the build's artists simply named their effect sprites consistently. An
18-element bundle cannot distinguish a format rule from a local naming habit;
before reporting an implication, run it over the corpus, not the example that
suggested it.
* **When an association survives a refuted implication, the counterexamples are
the finding.** The bit is 3.3× enriched for `eff` names and the sprites that
break the rule are `pv_loading_ring0`, `pv_loading_light0``3`,
`px_bunk_line` — rings, glows, lights, thin lines. That the exceptions are all
effect-like artwork *without* the naming convention says more about the field
than the rule it broke did.
* **Report a classifier's lift over its base rate, not its accuracy.** The
`0x02` bit's association with bright-RGB/low-alpha art gives a best single
threshold of 76.5 % accuracy — which sounds like a finding until you notice
that always guessing the majority class scores 64.1 %. The lift is 12 points,
the distributions overlap, and it is a tendency rather than a rule. An accuracy
quoted without its base rate is not interpretable.
* **Park a field after N failed hypotheses, and say what was eliminated.** Four
candidate meanings for one flag bit were each refuted at a cost of roughly an
iteration apiece, and the bit blocks nothing measurable. Stopping is the right
call, but only if the negative space is written down — additive blend, name,
lifetime, premultiplied alpha — so the next attempt starts where this one ended
rather than at the beginning.
* **Nothing was checking that a doc's figures match its committed data.** A
number is written once from a run and then lives in prose that gets edited
around it; the data file beside it is regenerated independently. All 19
headline figures across four censuses do currently agree
(`tools/re-capture/doc_figure_check.py`), which is worth knowing rather than
assuming — but the checker had to be written **numerically**, because the first
version grepped for the doc's formatting (`14 709` with a thin space, `33.7`
rounded from `33.66`) and reported almost every figure as a mismatch. A
consistency check that fails on formatting will train you to ignore it.
* **A detached job you never check can outlive many iterations.** Two
`setsid nohup cargo test … &` runs from earlier iterations were still alive
**four hours** later, one child at 89 % CPU for 3 h 26 m, holding the load
average at 14 on 12 cores. `setsid` was added precisely so a tool-call timeout
could not kill them — which also means nothing kills them. Check that a
backgrounded run actually exited before launching the next one, and prefer
`ps -o etime=` over assuming.
* **Know whether your verification gate can terminate.** `build-reborn test`
includes a disc test that decodes 166 `.xpr` files (1.4 GB) and had not
finished after 3 h 26 m of CPU. Every "green run" from it is partial unless the
suite is stated to have terminated — so report the suite count and elapsed
state rather than the word "green", which is what I should have been doing all
session.
* **"Cannot finish" and "takes an hour" are different claims — measure before
choosing.** I wrote that the verification gate *cannot terminate*, from having
watched it run 3 h 26 m. Timing the work directly: 19 of 166 containers exceed
25 s, `Stage_S02` completes in **144 s**, and one full pass is ~4560 minutes.
Nothing hangs. The 3 h 26 m was that hour of work at a load average of 914,
inflated by my own duplicate runs. A slow thing observed under contention looks
like a stuck thing, and the correction matters: an hour-scale gate can be run
deliberately, a hung one cannot be run at all.
* **Separate the question with evidence from the question tangled with it.** The
keyframe-time reading was held back for several iterations by one objection:
adopting it changes `rest()` on a single element. But `rest()` reaches the times
only through a fallback that is unsound whenever it runs, and the shift does not
fix that fallback either — so the objection was never about the times. Timing
had three discriminating measurements; pose-selection had a heuristic guessing.
When a conclusion is blocked, check whether the blocker is actually evidence
about the same thing.
* **A predicate over adjacent PAIRS silently misclassifies a one-element list.**
"Has a plateau" was implemented as *any two adjacent keyframes share a pose*
which is false for a single-keyframe element, so 1 502 static elements were
counted as having a *guessed* rest pose and the published defect rate was 65 %
too high. The error only surfaced when acting on it: suppressing those elements
dropped the title's correlation by 0.27, because they include the backgrounds.
Whenever a rule quantifies over pairs, ask what it says about a list of one.
* **Acting on a claim is a better test of it than re-reading it.** The
single-keyframe flaw survived a disc-wide census, a write-up and a handoff row.
It died the moment the rule was used to change a rendering, because the result
was visibly worse. If a measurement implies an action, take the action on
something you can score.
* **Score a rule where it can actually differ, or you will measure nothing.**
Three rest-pose rules rendered builds 4, 5 and 6 to *identical* correlations —
not because they agree, but because the code they change is unreachable on
those screens. The signal was on the two splashes, the only builds whose
elements reach the fallback at all. Identical results across variants is a
clue that the variant is not being exercised, not evidence that the choice does
not matter.
* **An argument from symmetry is a prediction, not a refutation.** I killed the
last-keyframe rule because it treats one of three sibling glows differently,
which felt like an artefact. Measured, it is the better rule on both screens
where it applies. Aesthetic expectations about how authored data "should" look
are worth stating as hypotheses and worth nothing as verdicts.
* **A blank render is a correlation of NaN, not a low score — notice which.**
Applying "rest = last keyframe" to every element made both splashes render with
zero variance, so the correlation was undefined rather than poor. Reading the
NaN as "the tool failed" would have hidden the result; it *was* the result, and
the strongest form of it. Check for degenerate output before treating a missing
number as a broken measurement.
* **When a model predicts something and the measurement refuses, the model is
usually incomplete rather than wrong.** The shifted time reading says when each
pose is reached, which looked like it implied the final pose is the resting
one. It does not: it says nothing about whether the group is *played to
completion* while the screen is still up. The exit is the dismissal, so a
displayed screen never reaches its last keyframe. Both claims survive; what was
wrong was the step between them that I supplied.
* **The control is what turns a helpful edit into a finding.** Suppressing the
`_eff` glows raised both splashes from ≈0.96 to ≈0.998 — on its own that reads
as "we over-draw glows". Running the same edit on the three screens that settle
made them **worse** (0.002, 0.092, 0.107), which is what makes the result
mean something specific: the over-draw is confined to transient screens, where
a plateau mid-animation is not evidence the element is on screen at rest. An
edit that improves one set of cases is only interesting once you have shown it
damages the cases where it should.
* **When a model reproduces durations but not positions, the missing piece is an
origin, not a rate.** Playing the splash timeline matched every element's
on-screen *length* to within 2 % while placing the elements in the wrong part
of the run. That pattern names the gap precisely — the rate and the shape are
right, so what is absent is a per-group start — and it pointed straight at the
one undecoded word in the group header. (Which was zero, but the diagnosis was
still what made the next step obvious rather than a search.)
* **Two elements with identical data and different outcomes is the strongest
possible evidence that the decision is elsewhere.** `palogo_anima` and
`palogo_gamearts` carry byte-identical keyframe times; one is drawn for 95
frames and the other not at all. No amount of re-reading the placement data can
explain that, and the pair is worth more than a survey — when a hypothesis says
"the data decides", look for two records that agree and behave differently.
* **A shared resource address does not identify the resource's owner.** The draw
log's `tex[base=…]` looked like it would say whether two animation phases came
from the same bundle. All three splash phases report `0x11C30000` — including
the publisher splash, which is certainly a *different* bundle. The address is a
reused upload slot. The control was free and sitting in the same table; without
it the shared base would have read as proof of a shared bundle.
* **State the mechanism as a separate claim from the observation.** "Declared
elements are not what gets drawn" is measured and holds. "Because one bundle
activates its elements selectively" was an explanation I attached to it, and it
is not established. Bundling the two let the weaker claim inherit the stronger
one's evidence.
* **Ask what the competing hypothesis would REQUIRE on the disc.** Two
explanations for the splash's disjoint phases looked separable only by a
capture I could not take, and the capture-side test failed its control. The
alternative needed a bundle declaring the glows without the logos — a
four-entry enumeration showed none exists, and the question closed statically.
A hypothesis that predicts an artefact can be killed by looking for the
artefact, which is often far cheaper than measuring the behaviour.
* **Check what a "measured" value was measured *from* before reasoning about
its limits.** Two things that never co-occur have no observable relative order
— true, and a real limit on any order read from a *draw capture*. I applied it
to the splash's paint order and withdrew it hours later: that vector is a read
of the runtime **child array**, which has a definite order whether or not its
children are ever drawn together, and the two halves in question also carry
distinct static layer keys. Both the provenance and an independent static field
said the order was real. The general trap is not the co-occurrence rule, it is
applying a source-specific limit to a value from a different source: the word
"measured" covers several kinds of evidence, and the page that recorded it
usually says which — `ui-screen-runtime.md` said "child slots" in as many
words. Read that line before building an argument on top of it.

View File

@@ -35,6 +35,159 @@ neighbourhood, not just the line.
## Screens, classes and RTTI
* "the rotated draw in the title's capture is the `Z` swoosh
(`ptlogo_back2*`)" → **mine, and refuted.** Its quads span y 209…925 and
292…1012 in screen space; the swoosh is a band at y 126…360.
[`ui-title-build-map.md`](ui-title-build-map.md)
* "the keyframe words at `+4`/`+8`/`+12` are always zero" → they are non-zero in
**4.76 %, 4.62 % and 14.50 % of 83 862** blocks disc-wide, reading as
**degrees** (±180, ±90, 120, 360). The original note was a sample artefact.
(Superseded figures: an earlier count of 72 287 blocks missed every nested
record — see the alignment entry below.)
* ~~"those angle fields are where the title's rotated quads come from" → **no** —
every `GP_TITLE` build 4 element has all three at zero.~~ → **that refutation
was itself wrong, and is withdrawn (2026-08-28).** `+12` *is* exactly where
they come from. Build 4's **top-level** elements do all read zero; the rotated
quads belong to its two **nested** leaf records, `ptloop01.rat` (`+12` = 30)
and `ptloop02.rat` (`+12` = 45), which the census never opened. Measured
off the GPU: **+30.26°** and **45.28°**.
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
* "the rotated draw's element cannot be named from the capture" → refuted; its
quads' **edge lengths** name it. 400 × 1076 and 400 × 1444 match `pteff03`
399×180 at 600 % and `pteff03a` 399×180 at 800 % — two different heights, both
landing. [`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
* "a keyframe-block scanner may assume 4-byte alignment" → refuted; it found
**0/3** of its own control blocks and under-counted the corpus by 16 341
blocks. Nested `RATC` blobs start at odd offsets (`0xbb5966`).
* "keyframe rotation lives only in **nested** `.rat` leaf records" → **mine, and
refuted within the hour by my own sweep.** It held for the three archives I had
checked (`GP_TITLE`, `GP_BUNK`, `GP_CHALLENGE`) and failed on the next:
`GP_DIALOG` and `GP_DEBRIEFING_PILOTLOG` rotate **top-level** elements, and
those are the clearest examples on the disc.
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
* "the pivot-anchored scale formula `kf.x pivot·(scale100)/100` is our
renderer's reading, not a measurement" → **now measured.** At the `ptloop`
pair's 600 %/800 % scale it predicts both quad centres at y = 360.0 against a
captured 359.1/360.0, where top-left anchoring predicts 810/990.
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
* "the game passes a pink per-vertex colour for the title swoosh" → **refuted by
draw capture.** Every vertex colour in the capture is `<alpha>FFFFFF`, white RGB.
* "the swoosh discrepancy is undecodable" → **solved**: the game submits it as two
**rotated parallelograms**; `ui_layout::blit` only does axis-aligned rectangles.
[`ui-title-build-map.md`](ui-title-build-map.md)
* "the `--log_ui_draws` per-draw capture reads the guest's blend state" →
**mine, and wrong.** It records primitive type, index count, index-buffer
address, VS/PS ucode hashes, texture bindings and vertex attribute 0 — no
`RB_BLENDCONTROL`. It can test vertex colour as-is; blend state needs a Canary
change. [`ui-title-build-map.md`](ui-title-build-map.md)
* "the plate-free title capture (t ≈ 4.0 s) may be too early to be settled" →
**mine, and refuted.** The swoosh band correlates 0.7342 at t = 4.0 s and
0.7353 at t = 21.5 s — identical to 0.001 over 17.5 s.
* "`T8aD +0x04` bit `0x02` selects an additive blend" → **mine, and refuted.**
Blending those sprites additively worsens every measure against the capture.
[`ui-title-build-map.md`](ui-title-build-map.md)
* "the title logo elements' wrong pivots (off by up to 59 px, authored against the
other language's sprite) explain the swoosh rendering too thick" → **mine, and
refuted.** `blit` sizes from the texture and applies the pivot only as
`pivot·(scale100)/100`; all seven swoosh elements are scale `(100,100)` at every
keyframe, so the term is zero. The mismatch is real but inert here — it would
bite `ptlogo1`/`ptlogo2`, which scale to 150 during the build-in.
[`ui-title-build-map.md`](ui-title-build-map.md)
* "the title screen loops at ≈ 2.2 s" → **mine, and doubly wrong.** The title
*art* is near-static (wordmark sd 0.06); the 2.3 s pulse is the
**`PRESS Ⓐ BUTTON` plate**, which is a *different build* (2, not 4). Localised
by a per-tile amplitude map and decoded in `ptbtn00f.rat`.
[`ui-title-build-map.md`](ui-title-build-map.md)
* ~~"a `.rat` leaf record's keyframes use the build's 40-byte layout" → they do
not~~ — **withdrawn.** They do. The scan that "found nothing" required 29
increasing times; the records hold **three**. Decoded in
[`ui-title-build-map.md`](ui-title-build-map.md).
* "the title's measured ≈ 2.2 s oscillation is the `ptloop01`/`ptloop02`
elements" → **mine, and refuted by decoding them.** Their sweeps run 7.5 s and
9.5 s; a 22 s capture showed 8 peaks, not 3. The period's source is unidentified.
* "the developer splash cannot be rendered by `screen render` at all" → it can,
with **`--all`**. It is only invisible to the *default* listing, which filters on
`is_build`. [`ui-title-build-map.md`](ui-title-build-map.md)
* "the four `GP_TITLE` entries that are not screen builds are unidentified" → they
are the **splash**: 10/13 the `SQUARE ENIX` logo, 11/14 the developer logos.
* ~~"the boot title is phase 2 and the attract title is phase 4 state 0, and only
phase 2 handles Ⓐ" → refuted~~ — **the REFUTATION is withdrawn.** The test
assumed Ⓑ lands in phase 4 state 0; the event-0 block actually sets
**phase = 2** and state = 0 together, so it probed phase 2. The hypothesis is
untested, not dead.
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
* "any title after the first one refuses input" → too broad. The **Ⓑ-returned
title accepts Ⓐ**; only the **attract**-returned title is inert.
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md)
* "`sub_821C6458` is `GamePart_Title`'s state machine" → **mine, imprecise.** It
is the machine for **phase 4** of a five-way outer dispatch at `this+132`;
phase 0 is the splash. The ten states and eighteen edges are phase 4 only.
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
* "`sub_821CC860` is the game's by-name screen factory" → **mine, and wrong.** Its
decoded arguments include `BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`,
`GAMMA_RGB` — it is a **generic name-keyed lookup**, mostly config.
* "`DIFFICULTY` and `EXTRA_MENU` are corroborated screen names" → **mine, and
wrong.** Neither appears in `r5` at any of the 48 call sites; they were strings
merely referenced by the same functions. Only `TUTORIAL_MENU` survives.
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
* "the `{func, func, ptr}` triples at `0x820a3b48` are a GamePart state table" →
**static-initialiser records trailing the `RegisterToFactory` strings.** The
bytes before them are the tail of a diagnostic string and the data column is
zero-filled descriptors.
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
* "the boot sequence is driven by a table the game reads" → it is **not
data-driven**; four search spaces closed, transitions are calls with an id
argument. [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
* "`config.ini` is a GamePart settings table, so the boot order is in it" → its
`[SYSTEM]` section is **empty**; the only populated section is `[LANGUAGE]`.
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
* "the attract loop is `GP_ADVERTISE_DEMO` (GamePart 1)" → 🟡 id 1 has **no
registration site** in the shipped build; the attract is the title replaying
`ADV.wmv`.
* "the 29 id-table names are 29 distinct GameParts" → 24 register, and `3`/`4`
are the **same class** (`GamePart_SaveLoad`).
* "Ⓐ on `NEW GAME` leads to a standing black-screen hang" → it opens
**`DIFFICULTY`**, then **`SELECT DATA`**. What looked like a hang was a menu
waiting for input that nobody pressed; the crash that follows is the already
documented `sub_823070B0` cache throw.
[`menu-navigation-semantics.md`](menu-navigation-semantics.md)
* "tapping Ⓐ during the boot movie breaks the title" → **one** tap skips the
movie cleanly and the title works normally. It is *hammering* (88 presses) that
breaks it. [`movie-binding.md`](movie-binding.md)
* "the attract movie runs ~85 s, so it is not `ADV.wmv` (137 s)" → **mine, and
wrong.** Sampling began 39 s into the movie, so what was timed was its tail.
The attract movie **is** `ADV.wmv`, played in full.
[`movie-binding.md`](movie-binding.md)
* "the boot intro and the attract movie are different videos" → one asset, one
manifest slot (`ADVERTISE_MOVIE`).
* "the new-game intro is unidentified" → `MS00A``S00A.wmv`, decoded from the
movie manifest.
* "`GP_READY_ROOM.pak` holds the Ready Room screen" → its 317 distinct element
names contain **none** of the six visible labels; it is the briefing /
tactical-map content behind the `BRIEFINGS` item.
[`ready-room-probe.md`](ready-room-probe.md)
* "the Ready Room might be 3D with a UI overlay" → it is **2D**; the pak carries
zero 3D containers.
* "element kind `0x3002` is *the* button kind" → title-side only. All 902
`GP_READY_ROOM` bundles have **zero** `0x3002`; that pak uses `0x3000`,
`0x3004`, `0x300c`, `0x3008`. `0x3002` is one member of a `0x3000` family.
* "the transition between menu screens is a cut" → it is a **fade through
black**; a 0.5 Hz screenshot burst simply samples too slowly to see it.
[`screen-transitions.md`](screen-transitions.md)
* "the main menu's initial focus is fixed" → three boots of one script gave
`TUTORIAL`, `TUTORIAL`, `NEW GAME`.
* "the title menus drop d-pad presses shorter than ~0.3 s" → **refuted by my
own data.** The menu **wraps at both ends**; every press registered, and the
"missing" step was the wrap. See [`menu-navigation-semantics.md`](menu-navigation-semantics.md).
* "the main menu opens with `NEW GAME` focused" → it opens on **`TUTORIAL`**,
2/2 boots (🟡 a third recorded run implies `NEW GAME`, so this is reproducible,
not invariant).
* "`GP_TITLE` builds 6/8/9 are three submenus" → **8 is the JAPANESE main
menu**, and 6/9 are the English and Japanese `EXTRAS` submenu. `GP_TITLE`
holds eight screens shipped twice (EN/JP), and exactly one submenu. See
[`ui-title-build-map.md`](ui-title-build-map.md).
* "the `PRESS Ⓐ BUTTON` plate is a state of the title build" → it is **its own
build** (2/3), composited over build 4 and faded in a beat later.
* "the RTTI route can name the anonymous classes" → 1 150 vtables: 1 150
`ANON_`, 0 `rtti_present`, 0 base classes.
* "the sibling vtable methods name the class" → they cannot.
@@ -102,6 +255,46 @@ neighbourhood, not just the line.
* "a set-difference over file names can see reuse" → it cannot; **join per
USER**. Per-pak copies are ×6.
## Audio
* "which bank the menu plays is not on the disc" → the **cue table** cannot say
(all BGM cues are numeric), but the **code** can: `sub_821C5580` plays cue
**1103 = `BGM_103`**, and its two declared waves match the two streams the XMA
probe saw byte-for-byte.
* "the observed BGM stream sizes match no bank's declared waves, so the game hands
the decoder a window" → **mine, and wrong** — I checked only the `BGM_0xx` rows.
They are `BGM_103`'s two waves exactly; the game hands over the whole wave.
* "an individual SE cue's audio cannot be extracted" → **mine, and wrong.**
`--xma_param_probe=true` logs each stream's head bytes; searching them in
`Static.slb` locates the wave exactly. [`menu-audio-cues.md`](menu-audio-cues.md)
* "`Static.slb` has no wave boundaries, so its layout is unknown" → it is a
**packed run** of whole 2 048-byte XMA packets with no delimiters — the two
located cues are contiguous. There is nothing to scan for, by design.
* "`Pj_Silph.xgs` holds the cue→wave index, so parse XACT" → **no XACT container
exists on this disc**: 0 × `XGSF`/`SDBK`/`WBND` in all 1.08 GB of `sound.pak`,
and no `XACT`/`.xgs` string in the executable. The extensions are the authoring
tool's, not the format's. [`menu-audio-cues.md`](menu-audio-cues.md)
* "every sound cue resolves to its own `.slb` bank" → the 322 `SE_*` cues do not;
**0 of 322** are in `FILES`, and `BANK_SE` puts them all in `Static.slb`.
* "`Static.slb` can be split into waves like any other bank" → it holds **0
`RIFF`, 0 `seek`, 0 `WAVE`** across all 8 353 472 readable bytes.
[`menu-audio-cues.md`](menu-audio-cues.md)
* "`BGM_001.slb` is three sub-waves (10 KB + 4.47 MB + 4.67 MB)" → the 10 KB is
the **bank header**; a bank is **two** waves.
* "a music bank's two waves might be intro + loop, two variations, or two halves"
→ they are **two stems of one performance, played together** — equal duration
in 32/32 banks, and sample-synchronous.
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md)
* "`BGM_106``BGM_109` break the two-wave rule" → they are the leading-region
straddle; realigned across entry boundaries they obey it.
* "the cue table names which BGM belongs to which screen" → all 32 BGM cues are
numeric (`BGM_001``BGM_109`).
## Units, weapons, effects and assets
* "`Generic` (394) is the unit datasheet" → refuted.
@@ -183,3 +376,232 @@ neighbourhood, not just the line.
* "every IDXD string value is ASCII" → 6 non-ASCII values of 99 328.
* "`文字列` is a dev placeholder" → they are Shift-JIS **type words**.
* "the splash `_eff` glows hold a constant α ≈ 33, contradicting their declared
255 plateau" → **mine, and refuted within the iteration.** They ramp 34 → 255
in exact steps of 34. I had printed the series' minimum and read it as its
range. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
* "the declared keyframe timeline reproduces the captured splash" → **refuted for
multi-keyframe elements.** `palogo_gamearts` is still at `a=255` nine frames
after its declared `a=32`, and its declared 80-frame fade-in is never drawn.
The `_eff` glows do reproduce, exactly — so this is about the group timeline,
not about the interpolation law. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
* "the `_eff` elements' agreement is the whole case for the keyframe-time shift,
so it stays a shape argument" → superseded. The **hold duration** is
calibration-free and decides it: observed 83 frames of full alpha against a
predicted **2.0** as decoded and **80.0** shifted. The shift is nonetheless
**not adopted** — it moves `GP_TITLE` build 7 by 13 % of pixels, away from its
verified English twin's brightness. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
* "the `GP_TITLE` build 7 render difference is evidence against the keyframe-time
shift" → **mine, and withdrawn.** It is one element, `ptlogo_eff3.t32`, a
transient bloom with no resting pose; `rest()`'s dwell fallback returns a
different endpoint of the same movement under each reading. The brightness
comparison measured our heuristic, not the decode.
[`structures/ui-resting-pose.md`](structures/ui-resting-pose.md)
* "`rest()`'s longest-dwell fallback picks the pose the element rests at" →
**refuted structurally.** A dwell gap is time spent interpolating *between*
poses; an endpoint is only held when the two poses are equal, which is a
plateau, which the earlier path already returned for. Every element that
reaches the fallback has a guessed rest pose.
* "`rest()` for a plateau-less element should be the **last keyframe**" → **mine,
and refuted.** The developer splash's three sibling glows are structurally
identical and differ by one byte (`a=212` vs `a=255` at `t=45`); that rule
makes `palogo_anima_eff` alone invisible while `gamearts_eff` and `seta_eff`
stay lit. Capture box-mean ratios (0.717 / 0.723 / **0.772**) go the other way
too. [`structures/ui-resting-pose.md`](structures/ui-resting-pose.md)
* "a keyframe `scale` of 0 means *unset*, so render at 100 %" → **refuted by a
disc-wide control.** 2 166 elements have a zero-scale keyframe and **not one is
zero on every keyframe**, while 1 762 grow back out of zero (`ptlogo_eff3.t32`
runs 0 % → 200 %). Zero means collapsed; the renderer now draws nothing.
[`structures/ui-rat-layout.md`](structures/ui-rat-layout.md)
* "a Japanese-locale capture is impossible in this container, because canary has
no `user_language` cvar" → **mine, and refuted the next iteration.** The cvar
really is absent, but the language is persisted in
`<storage_root>/xconfig.settings` (`user.language`, BE u32 at file offset
`0x912`, located from three struct landmarks) and that file is writable. The
capture is still not *taken* — a Japanese run never reached the title in 787 s
— but it needs a longer run, not a rebuilt emulator.
[`tools/re-capture/set_console_language.py`](../../tools/re-capture/set_console_language.py)
* "the Japanese-locale run never reached the title in 787 s" → **the measurement
was broken, not the run.** `wait_title.sh` carried the superseded single-pixel
oracle. Re-run with `is_title.py`: the game still did not present the
interactive title, but that is now a measured statement (zero green-glyph
pixels, correlation ≤ 0.22 to either build-7 render) rather than an artefact.
* "the Japanese-locale run fails to reach the interactive title *because of the
locale*" → **refuted by the English control.** 75 samples over 734 s with the
same flags and oracle, every one glyph = 0. Neither locale presents the
interactive title without a pad press.
* "neither locale reaches the interactive title without a pad press" /
"the game sat in the attract loop for 604 s" → **withdrawn as causes.** Both
rest on runs whose polling loop sampled every ~41 s, because `screenshot`
costs 10.8 s while the emulator runs (0.117 s idle, 92×). A title lasting a few
seconds would be missed. The observations stand; the conclusions drawn from
them do not. [`capture-harness-status.md`](capture-harness-status.md)
* "the boot harness fails because its polling loop samples every ~41 s, slower
than the title screen lasts" → **mine, and refuted by my own fix.** The
sampling defect was real (3.98 s → 0.29 s per sample, 13.7×, control-verified
at 753/327), but a probe running at 3.99 fps for **420 continuous seconds —
1 674 samples — still saw zero green-Ⓐ pixels.** Sampling rate was not the
cause. [`capture-harness-status.md`](capture-harness-status.md)
* ~~"neither locale reaches the interactive title without a pad press"~~ →
withdrawn last iteration for want of evidence, now **reinstated as a
measurement**: 1 674 dense samples over 420 s, English, zero glyph frames.
⚠️ Reach: a mid-run window only; it says nothing about the boot title.
* "the PRESS Ⓐ plate appears only in the **boot** title window, which mid-run
sampling could never catch" → **mine, and refuted.** The fast probe was
attached at t=0: **2 391 frames over 600 s at 3.98 fps from launch**, max glyph
0. The plate did not appear at any point in the first ten minutes.
[`capture-harness-status.md`](capture-harness-status.md)
* "2 391 frames over 600 s from t=0, max glyph 0, therefore the title never
appears in the first ten minutes" → **withdrawn: the instrument stalls.** A
single long-lived x11grab stream degrades 3.98 → 1.60 fps and then freezes,
repeating one stale frame; cross-checked, it read surface mean 5.21 where
`import` read 125.65 at the same moment. A dense negative from a frozen stream
is not a negative. [`capture-harness-status.md`](capture-harness-status.md)
* "the `T8aD` layer key fully determines a screen's paint order" → **refuted, and
the remainder is undecodable.** Elements sharing a key are tied; on the title
the game paints the five tied `ptlogo_back2eff` glows `1,2,5,3,4` while the
declaration table, the RATC child order and **every** field in the `T8aD`
header (exhaustive 0x000x7f, u8/u16/u32, both directions — 0 matches against
64 for the declaration-order control) all give `1,2,3,4,5`.
[`ui-paint-order-derived-check.md`](structures/ui-paint-order-derived-check.md)
* "SE audio is undecodable from the disc — no XACT container exists anywhere"
(as it stood on the **handoff page**) → **stale**: `menu-audio-cues.md` had
already retracted it and located three cues in `Static.slb` that decode to PCM.
The retraction never reached the row the port agent reads. Handoff row fixed;
`tools/re-capture/handoff_lint.py` now checks for this class.
* "which of `8AX` and `ptbase` the game draws needs a per-draw capture recording
texture base addresses" → **mine, and refuted — it is settled statically.** The
two carry the same art at two resolutions, so neither compares usefully against
a capture; their *difference* does. Correlating the capture's
departure-from-upscale against the 8AX-only detail gives +0.0475 (main menu)
and +0.0634 (title), both 68 % of ceiling against matched controls of ≤0.0095.
The game draws the full-res `8AX`.
[`ui-8ax-fullres-background.md`](structures/ui-8ax-fullres-background.md)
* "the pixel-pair ratio shows the capture is native, not an upscale" → **mine,
and withdrawn as evidence.** Upscales give 0.000.72, native 0.98, capture 1.01
— but additive noise pushes any such ratio toward 1, and both "native + noise"
and "bilinear upscale + noise" fit the observed values. The conclusion happens
to be right; this test does not establish it.
* "the render-vs-capture gamma may be canary's own BT.709 output transform, since
`kernel_display_gamma_type = 2`" → **mine, and refuted from the source.** That
cvar is the value a `kStub` getter (`VdGetCurrentDisplayGamma`) hands the
**guest**; the game builds its own ramp from it and canary applies the guest's
`DC_LUT` ramp in the swap path. No emulator-side gamma post-process exists to
subtract. 🟡 Whether this game installs a ramp at all is still unestablished.
[`ui-render-tone-curve.md`](structures/ui-render-tone-curve.md)
* "the GPU trace produced nothing because either the CLI flag did not reach the
cvar or `BeginTracing()` failed silently" → **both wrong.** The trace writer is
**compiled out**: `trace_writer.h` gates it on `#ifdef NDEBUG`, so a release
build has no writer at all. Confirmed with a control — the format string
`_stream.xtr` appears **once** in the Debug binary and **zero** times in the
Release binary `run-canary` actually uses.
[`capture-harness-status.md`](capture-harness-status.md)
* "the `T8aD` `+0x04` bit `0x02` means the sprite's name contains `eff`" →
**refuted, now on evidence.** `ptlogo_back2eff` is an `eff` name with the bit
clear; the attribution is confirmed by header-order pairing (18/18 on build 4)
rather than by a size match, which cannot separate it from the same-sized
`ptlogo_back2eff5`. All 10 bit-set sprites *are* `eff` names, so the
implication runs one way only.
* "the bit `0x02` marks a transient element" → **refuted.** `pteff03` and
`pteff03a` carry the bit and run to `t=250`, ramping to `a=255` and holding.
[`ui-paint-order-key.md`](structures/ui-paint-order-key.md)
* "bit `0x02` set ⇒ the sprite's name contains `eff`" (the one-way reading that
survived the biconditional's refutation) → **mine, and refuted disc-wide the
next iteration.** True 10/10 on `GP_TITLE` build 4; over 14 709 sprites it
fails on **2 657 of 4 995** bit-set ones. `P(eff|set) = 0.468` against
`P(eff|clear) = 0.144` — an association, not an implication.
[`ui-paint-order-key.md`](structures/ui-paint-order-key.md)
* "`T8aD +0x04` bit `0x02` selects premultiplied alpha" → **refuted.**
Premultiplied requires `RGB ≤ A` everywhere; over 170 decoded textures the
flagged group violates it on a median **52.5 %** of pixels against **30.2 %**
unflagged — both far from premultiplied, and the flagged group *further*.
[`ui-paint-order-key.md`](structures/ui-paint-order-key.md)
* "`build-reborn test` cannot terminate" → **mine, and too strong; corrected the
next iteration.** It is heavy, not hung: 19 of 166 `.xpr` containers exceed
25 s, `Stage_S02` completes in **144 s** with `rc = 0`, and one full pass is
~4560 minutes. The 3 h 26 m observed was that work at a load average of 914,
inflated by my own two duplicate runs.
[`test-suite-runtime.md`](test-suite-runtime.md)
* "the case for the keyframe-time shift rests on a single element" → **no longer
true.** Three elements across two screens discriminate and all favour it:
`palogo_gamearts` and `palogo_seta` hold full alpha 83 frames, `palogo_sqex`
≥77, against 68 predicted by the current reading and 80102 by the shifted
one. The `_eff` glows fit both and argue against neither.
[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
* "an element with no held pose should be drawn as NOTHING rather than at a
guessed endpoint" → **mine, and refuted.** Suppressing every plateau-less
element and re-correlating against the live captures: title +0.9500 → +0.6839,
main menu +0.9460 → +0.9037, `EXTRAS` +0.9440 → +0.9094. Worse on all three.
* "`rest()` guesses for 24.57 % of elements (3 807 of 15 493)" → **mine, and
overstated by 65 %.** The plateau test marks a **single-keyframe** element as
plateau-less because it has no adjacent pair — but its one pose is
unambiguously its rest. 1 502 of the 3 807 are those; the genuinely ambiguous
population is **2 305 (14.88 %)**.
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
* "`rest()` for a plateau-less element should be the last keyframe → refuted by
the sibling argument" → **that refutation is itself refuted, this time by
measurement.** Rendering under the rule and correlating against the live
captures: publisher splash +0.9600 → **+0.9982**, developer splash +0.9643 →
**+0.9758**. Making `palogo_anima_eff` invisible *improves* the match; the
sibling symmetry was my expectation, not evidence.
* "the port's exposure to the rest-guessing defect is 14 elements" → **two.** The
fallback needs an element to be plateau-less **and** multi-keyframe; title,
main menu and `EXTRAS` reach it **zero** times, the two splashes once each.
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
* "the shifted time reading implies rest = the last keyframe, so the plateau rule
can be dropped" → **mine, and refuted by measurement.** Applying it to every
element collapses all five screens (title 0.9500→0.6819, main menu
0.9460→0.6416, `EXTRAS` 0.9440→0.5745) and renders both splashes **blank**. A
group is entry → hold → exit and the exit is the screen's *dismissal*: a
displayed screen sits at the hold, not at its final pose.
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
* "`rest_plateau` renders elements the game has already finished with" (as a
general claim) → **narrowed by its control.** It holds on **transient** screens
only: suppressing the finished glows takes the two splashes from 0.9604/0.9659
to **0.9982/0.9980**, while the same edit costs the title 0.002, the main menu
**0.092** and `EXTRAS` **0.107**. A plateau mid-animation is evidence the
element is held at that point in the timeline, not that it is on screen once the
screen has settled — and where a screen does settle, the rule is right.
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
* "the group header's undecoded lead-in word carries a per-element start offset"
**refuted immediately.** It is `0x00000000` for all seven elements of the
developer splash — glows and logos alike — while those two families are
observed to run sequentially (frames 94115 and 116211) despite declaring
overlapping times. [`ui-group-start-time.md`](structures/ui-group-start-time.md)
* "the splash elements share one clock origin" → **refuted.** Fitting a single
origin needs f₀ ≈ 93.5 for `palogo_gamearts_eff` and f₀ ≈ 103 for
`palogo_gamearts`, ~19 units apart, and aligning one throws the other off by
~9 frames at both ends. Durations match (97.8 %, 98.5 %); starts do not.
* "the glows and logos might overlap and my size-grouping merged them" → **tested
and refuted.** Across all 235 captured frames, **zero** contain both a glow and
a logo; f110115 draw two glows and f116 onward two logos, with no transition
frame. The sequencing is real.
* "a bundle's declared elements are what the screen shows" → **refuted.** Entry 11
declares three logo/glow pairs and only two are ever drawn — `palogo_anima`
gets 0 frames against `palogo_gamearts`'s 95, from byte-identical keyframe
times. (Reach: within the capture's frames 1214.)
[`ui-group-start-time.md`](structures/ui-group-start-time.md)
* "the glow and logo phases are one bundle with elements selectively activated"
**mine, and withdrawn as unestablished.** The alternative — two compositions
shown in sequence — fits equally. The texture-base test fails its control: the
publisher splash is a different bundle and shares the base `0x11C30000`, so
that address is a reused upload slot, not a bundle identity. What survives is
that declared elements ≠ drawn elements.
[`ui-group-start-time.md`](structures/ui-group-start-time.md)
* "two compositions shown in sequence" (as the alternative to selective
activation) → **refuted statically.** It requires a bundle declaring the glows
without the logos; no such bundle exists. Only four `GP_TITLE` entries carry
`palogo` elements, and both developer entries declare **all six** logos and
glows — so whichever was active, a subset of its elements was drawn at a time.
Selective activation is reinstated on evidence.
[`ui-group-start-time.md`](structures/ui-group-start-time.md)
* ~~"the splash's `measured_paint_order` records a front-to-back depth order" →
mis-typed; between its glow and logo halves it records only the temporal order
they were seen in."~~ → **that refutation is itself refuted (same day).** The
vector is a read of the runtime **child array** (`ui-screen-runtime.md`:
"paint order (child slots)"), not of the draw capture, so co-occurrence does
not bear on it; and the halves carry *distinct* T8aD layer keys
(`0xa100` < `0xa110`, `paint_order_audit`: 0 same-key ties), so the file orders
them regardless. The no-overlap measurement was correct; the inference from it
was not. What survives: a capture of this screen can only cross-check the order
*within* each half. [`ui-prm-primitives.md`](structures/ui-prm-primitives.md)

View File

@@ -0,0 +1,583 @@
# What the game reads at boot — `config.ini`, and which GameParts actually exist
**Status:**`CONFIRMED` and **decoded** for the language selection. ❔ a bounded
**negative** for the boot *order*. 🟡 for what the registration strings imply
about the attract loop.
Contributes to [MISSION Q6](../port/MISSION.md), and firms up Q4's ids.
## ✅ `config.ini` — the disc's only config, and it selects the language
`config.ini` sits at the **disc root**, is 400 bytes, and is the **only** `.ini`,
`.cfg` or `.txt` anywhere on the disc. Its own header comment (Shift-JIS) names
it:
> `アプリケーション/ゲームパート初期設定テーブル`
> — *"Application / GamePart initial settings table"*
>
> `SYSTEM セクションには、ゲーム及び各ゲームパートで共通にアクセスする情報を記述する`
> — *"the SYSTEM section describes information accessed in common by the game and
> each game part"*
```ini
[SYSTEM]
[LANGUAGE]
= eng ; default
#0x01 = eng ; XC_LANGUAGE_ENGLISH
#0x02 = jpn ; XC_LANGUAGE_JAPANESE
#0x03 = deu ; XC_LANGUAGE_GERMAN
#0x04 = fra ; XC_LANGUAGE_FRENCH
#0x05 = esp ; XC_LANGUAGE_SPANISH
#0x06 = ita ; XC_LANGUAGE_ITALIAN
```
**This is the mechanism behind the EN/JP screen pairs.** The console's
`XC_LANGUAGE_*` setting selects a three-letter code, and that code is what picks
`GP_TITLE`'s English or Japanese build ([`ui-title-build-map.md`](ui-title-build-map.md))
and the `<lang>.pak` families. Default is `eng`, keyed by the empty line — an
unlisted language falls back to English. ✅ decoded. The executable does read the
file: the string `config.ini` is at `.rdata 0x82062b44`.
## ❔ But the boot ORDER is not in it — and this is the whole search space
`[SYSTEM]` — the section the file's own comment says holds what the game and every
game part share — is **empty**. So the file the game itself calls the *GamePart
initial settings table* says nothing about which part runs first or what follows
what.
**Reach of the negative:** this is the only config file on the disc (one `find`
over the whole extract). The boot order is therefore not in disc-side
configuration at all; it is in code, or in a table inside the executable that has
not been located.
## 🟡 `GP_ADVERTISE_DEMO` is never registered — the attract loop is not its own part
The executable carries one diagnostic string per GamePart registration site
(`silph::GamePartTask::RegisterToFactory<N, class silph::GamePart_X>::RegisterToFactory is failed!`)
— the same evidence the corpus used to pin the id table
([`challenge-mission-gate.md`](challenge-mission-gate.md) §3). Extracting all of
them gives **24 of the 29 ids bound to a C++ class**; full list in
[`data/gamepart-class-ids.txt`](data/gamepart-class-ids.txt).
**The five ids with no registration site are `1`, `2`, `16`, `18`, `28`** — in the
id table's naming, `GP_ADVERTISE_DEMO`, `GP_SELECT_STORAGE`, `GP_DEMO`,
`GP_SELECTOR`, `GP_TEST`.
That `GP_ADVERTISE_DEMO` (1) is among them matters for Q6, and it agrees with
what was measured: the attract loop is **the title screen replaying `ADV.wmv`**
([`movie-binding.md`](movie-binding.md)), not a transition into a separate
advertise part. The id table names a part the shipped build never registers.
🟡 **not ✅**, because this is an argument from a *diagnostic string*: no error
message for id 1 implies no registration site for id 1. That is how the corpus
already reads these strings, and it is sound, but it is not the code.
### Two bonuses for Q4
* **`3` and `4` are the same class** — `GamePart_SaveLoad` is registered twice.
The id table's separate `GP_LOAD` / `GP_SAVE` names are two ids on one part.
* The ids behind the menu buttons now match the executable's own **class** names,
not just a list of table names: `GamePart_Options` = 8, `GamePart_Tutorial` =
25, `GamePart_Extras` = 5, `GamePart_MissionSelect` = 7,
`GamePart_MovieTheater` = 6, `GamePart_Title` = 0. Still a **name match**
screen title ↔ class name — but anchored one level closer to the code.
## What is still missing for Q6
The *transitions*. Nothing found so far says "title, then movie, then title" — the
manifest gives the boot-side **assets** in play order
([`movie-binding.md`](movie-binding.md)), `config.ini` gives the **language**, and
the registry gives **which parts exist**. What decides to advance is in
`GamePart_Title`'s own code, and reading it is a static PPC job that has not been
started.
## ❔ The transitions are code, not data — the reach of that negative
Q6's remaining half asked what the game *reads* to decide the boot order. The
answer is: **nothing. It is not data-driven.** Four independent places were
checked, and the sequence is in none of them.
| looked in | result |
|---|---|
| **disc configuration** | `config.ini` is the only config file on the disc, its `[SYSTEM]` section is empty (above) |
| **the movie manifest** | carries the boot-side *assets* in play order, and no transitions — [`movie-binding.md`](movie-binding.md) |
| **a persistent part-id field** | already refuted: the requested GamePart id exists **only as a stack argument in flight**, with no literal store anywhere — [`challenge-mission-gate.md`](challenge-mission-gate.md) §5 |
| **the id table's attract entry** | the string `GP_ADVERTISE_DEMO` at `0x820a1fe8` has **zero xrefs of any kind**; nothing in the code reads it |
So a transition is a **call with an id argument**, chosen by code. There is no
table to read and nothing to poke.
**Where that code is, as far as it was traced.** The `RegisterToFactory<0, class
silph::GamePart_Title>` diagnostic string at `0x820a3d60` is referenced from
exactly one place, `sub_8280E148` — the registration site — which also takes the
address of **`sub_821C7D98`** (`addi`), the position a factory template puts its
creator. 🟡 That identification is by position and convention, not proven;
`sub_821C7D98` itself has **0 `.rdata` references**, consistent with a small
`new`+ctor thunk rather than the state machine. The substantial function in the
same class neighbourhood is `sub_821C6458` (4 460 bytes, has EH, 15 `.rdata`
refs), and **reading it has not been attempted**.
### What this means for the port
The boot sequence is **authored**, not transcribed — and that is fine, because the
sequence itself is measured end to end:
```
developer splash (a RATC screen, not a video)
→ ADV.wmv
→ title + PRESS Ⓐ ──idle ~810 s──> fade to black → ADV.wmv in full → title
→ Ⓐ → main menu
```
with the fade-through-black timings in [`screen-transitions.md`](screen-transitions.md)
and Ⓑ from the main menu returning to the title.
## 🟡 `sub_821C6458` read — the title's states are NAMED in the executable
The page above left this function as the named next step: "the substantial
function in `GamePart_Title`'s neighbourhood, and **reading it has not been
attempted**". It has now been looked at — not disassembled line by line, but
characterised, which is enough to sharpen Q6.
**It is the title part's screen-state function.** 4 460 bytes, has EH, and called
from **exactly one** place — `sub_821C7850`, which sits in the same neighbourhood
as the creator the registration site points at (`sub_821C7D98`). It makes 33
distinct calls.
**What it names.** Its `.rdata` string references are:
| string | at |
|---|---|
| **`TITLE_SCREEN`** | `0x820a3d3c` |
| **`TITLE_MENU`** | `0x820a3d30` |
| **`LOADING`** | `0x820a214c` |
| `BASE_INFO` (×2) | `0x820a20ec` |
Those are the states measured off the running game, in the game's own words: the
title carrying `PRESS Ⓐ BUTTON`, the five-button menu, and a loading state. And
`BASE_INFO` is this corpus's own marker for a **screen-config lookup** rather than
a table read ([`REFUTED.md`](REFUTED.md), "`BASE_INFO` discriminates
screen-config from table-read, 9/9 vs 10/10").
### The sharper negative
So the title part **does ask for configuration keyed by `TITLE_SCREEN` and
`TITLE_MENU`** — and [`config.ini`](#-configini--the-discs-only-config-and-it-selects-the-language),
the disc's only config file, contains **no such sections**. Its only sections are
an empty `[SYSTEM]` and `[LANGUAGE]`. The lookups find nothing, and the values are
whatever the code defaults to.
That is a better answer than "the order is not in config": the game *asks the
question*, the shipped disc *does not answer it*, and the defaults live in code.
For the port it means the state **names** are transcribable even though their
contents are not.
🟡 **Not ✅.** This is a characterisation from string references and call counts,
not a read of the control flow. Two self-references (`0x821c6498`, `0x821c6b7c`)
inside the function look like jump tables — a switch, which is what a state
machine compiles to — but that was **not confirmed**, and nothing here shows which
state leads to which.
### 🔴 Refuted on the way, because it looked like a find
The words at `0x820a3b48`… resolve as neat `{func, func, ptr}` triples and read
convincingly as a state/handler table. **They are not.** The bytes immediately
before them are the tail of
`…SaveLoad>::RegisterToFactory is failed!`, and the `0x8210c1xx` targets are
zero-filled descriptors — static-initialiser records trailing the registration
strings, not a dispatch table. A plausible-looking array of function pointers next
to relevant strings is not evidence of anything until its neighbours are read.
## 🟡 The title's transition is a screen lookup BY NAME — `sub_821CC860`
Reading the code around each of the three state names shows the **same four
instructions** at all three sites:
```
lwz r29, 20(r30) ; an object off the part
lwz r3, 4(...) ; ...
bl 0x822F2328
lis r11, 0x820A
li r6, 0
lwz r4, 24(r30)
addi r5, r11, 15676 ; "TITLE_SCREEN" (15664 = "TITLE_MENU",
lwz r3, 88(r30) ; 8524 = "LOADING")
bl 0x821CC860 ; <- lookup(this+88, this+24, NAME, 0)
mr r4, r3
bl 0x82187B78 ; <- install the result
```
So a title-side transition is **`sub_821CC860(…, "<NAME>", 0)` followed by
`sub_82187B78(result)`** — a *string-keyed* screen lookup, then an install. Not a
numeric id, not a table index. That also fits `GP_ADVERTISE_DEMO` having zero
xrefs: at this level the screen graph is keyed by **name**, not by GamePart id.
### The candidate name vocabulary
`sub_821CC860` is called from **28 distinct functions**. Collecting the
upper-case identifier strings those callers reference gives 35 names, and they
split into two obvious families:
* **screen/state names** — `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`,
**`DIFFICULTY`**, `EXTRA_MENU`, `TUTORIAL_MENU`, `STANDARD_MENU`, `DEBRIEFING`,
`CHALLENGE`, `MISSIONS`, `LIVE_BOARD`, `LOCAL_BOARD`, `EXTRA_MENU`;
* **config keys** — `TEXT_FONT`, `TEXT_HEIGHT`, `LINE_SPACE`, `GAMMA_RGB`,
`GAMMA_WB`, `TEXT_SPEED_PER_LETTER`, `EXIT_VALUE`, `INPUT_DIR`,
`MENU_ENABLE_SKIP`/`_DISABLE_SKIP`, `PAUSE_SE`, `JINGLE`, …
plus `BASE_INFO` in **19 of the 28** callers, which is this corpus's existing
marker for a screen-config function.
**Three of the screen names are independently corroborated by measurement**:
`DIFFICULTY` is exactly the screen `NEW GAME` opens, `EXTRA_MENU` matches the
`EXTRAS` submenu and `TUTORIAL_MENU` the lesson list — all three measured off the
running game in [`menu-navigation-semantics.md`](menu-navigation-semantics.md)
before this function was ever looked at.
⚠️ **Why this is 🟡 and not ✅.** The 35 strings are what those callers
*reference*, **not** proven arguments to `sub_821CC860` — the list plainly mixes
screen names with config keys, so it is a **candidate vocabulary**, not a decoded
one. Confirming it means checking, per call site, which string actually lands in
`r5`. That was not done.
### What is still unread
Which state leads to which. The three lookups sit in different branches of one
function and at least one (`TITLE_MENU`) is guarded by a `cmplwi`/`bne`, but the
branch structure was not traced, so the *order* still comes from measurement, not
from the code.
## ✅ The argument is now decoded — and it refutes my own corroboration
The section above listed a **candidate** vocabulary from "upper-case strings the
callers reference", flagged 🟡 because those are not proven arguments, and named
the check: *confirm per call site which string actually lands in `r5`.* That check
has been run, and it fired.
**Method.** Forward register simulation over the 100 instructions before each of
the **48** call sites of `sub_821CC860`, tracking `lis` / `addi` / `subi` / `mr`.
Full table in [`data/name-lookup-callsites.txt`](data/name-lookup-callsites.txt).
**Gated on a control, and the first version failed it.** A backward scan for
`addi r5, …` recovered only 7/48 and **missed both title sites I had read by
eye** — because there the name goes into `r27` first and reaches `r5` via a later
`mr`. The rewritten forward simulation reproduces all three known sites
(`TITLE_SCREEN`, `TITLE_MENU`, `LOADING`) and then recovers **46 of 48**.
### 🔴 `DIFFICULTY` and `EXTRA_MENU` are *not* arguments
Last section I wrote that three names were "independently corroborated by
measurement": `DIFFICULTY`, `EXTRA_MENU`, `TUTORIAL_MENU`. **Only
`TUTORIAL_MENU` survives.** `DIFFICULTY` and `EXTRA_MENU` never appear in `r5` at
any of the 48 sites — they are strings that merely live in the same functions.
The corroboration I claimed was an artifact of the loose filter, and the 🟡 I put
on it is exactly what caught it.
### The 28 names that ARE passed
```
BASE BASE(x3) BG(x3) BLACK(x2) BUTTON DEBRIEFING DETAIL EXTRA FADE(x3)
FILE(x5) GAME GAMMA_RGB GAMMA_TITLE INFO KEY LIVE_BOARD(x2) LOADING(x2)
LOCAL_BOARD(x2) MENU(x2) MENU_DISABLE_SKIP MESSAGE NEW_ITEM(x2) PAD SOUND
TITLE(x2) TITLE_MENU TITLE_SCREEN(x2) TUTORIAL_MENU WINDOW
```
### 🔴 So `sub_821CC860` is not a screen factory
The previous section called it one. The real argument list says otherwise:
`BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`, `WINDOW`, `GAMMA_RGB`,
`MENU_DISABLE_SKIP` are **not screens**. It is a **generic name-keyed lookup**
a named-entry getter used for config and resources throughout the executable, of
which the title part happens to call it with its three state names.
That weakens nothing about the title finding itself — `sub_821C6458` really does
transition by calling this with `TITLE_SCREEN` / `TITLE_MENU` / `LOADING` — but it
removes the inference that the 28 names are a screen vocabulary. They are a
**lookup-key vocabulary**, mostly config.
## ✅ The title's state machine — decoded
The last open piece of Q6 was *which state leads to which*. It is a plain
`switch` and it is now read.
```
821c6474 lwz r11, 136(r30) ; state = this+0x88
821c6478 cmplwi cr6, r11, 0x9 ; ten states, 0..9
821c647c bgt cr6, <default>
821c6480 lis/addi r12, 0x821C6498 ; jump table
821c648c lwzx r0, r12, r0
821c6494 bctr
```
⚠️ The table at `0x821C6498` disassembles as ten `lwz r16, N(r28)` instructions.
**It is data.** That is the "self-reference" this page flagged two sections ago as
*looking* like a jump table — it is one, and the disassembler was decoding its
words as code.
**Three of the ten states install a named screen**: **0 → `TITLE_SCREEN`**,
**2 → `TITLE_MENU`**, **8 → `LOADING`**.
**Eighteen transitions**, every one a literal `li rX, N ; stw rX, 136(r30)`:
```
0 → 1, 2 2 → 4 4 → 0, 5, 8×4 6 → 7, 9, 2 8 → 2
1 → 2, 2 3 → 4 5 → 6 7 → 9 9 → (none)
```
Full table with addresses in
[`data/title-state-machine.txt`](data/title-state-machine.txt).
### 🟡 It lines up with what was measured — read as corroboration, not proof
The boot reaches the title (state 0) and Ⓐ opens the main menu (state 2); the
graph has `0 → 2` directly and `0 → 1 → 2`. Ⓑ at the main menu returns to the
title, and `4 → 0` is the **only** edge back to state 0, reached from `2 → 4`.
Entering a submenu goes through `LOADING` and comes back, and `4 → 8` (four
separate sites) then `8 → 2` is exactly that shape.
Those readings are **mine, matching a graph to observed behaviour** — the
conditions on the edges are not decoded, so nothing here proves which input picks
which branch.
### What is still not decoded
* **the condition on each edge** — which input or event selects it;
* **states 1, 3, 5, 6, 7, 9** install no named screen, so what they do is unknown;
* **state 3 is never a destination** in this function, so something outside sets
it.
## ✅ The edge conditions, for the state that has them — an EVENT CODE
The previous section left "the condition on each edge" open. For the state that
matters it is now read.
**`sub_821C6458`'s third argument is an event code.** State 4 — reached from
`2 → 4`, i.e. immediately after the main menu is installed — is the
**input-waiting state**, and it dispatches on that argument:
```
821c6b5c cmplwi cr6, r27, 0x19 ; 26 events, 0..25
821c6b60 bgt cr6, <default>
821c6b68 addi r12, r12, 27516 ; second jump table, at 0x821c6b7c
821c6b78 bctr
```
**Six of the 26 are handled**; the other twenty fall through and change nothing.
| event | → state | |
|---|---|---|
| **0** | **0** | `TITLE_SCREEN` — back to the title |
| 3 | 8 | `LOADING` |
| 5 | 8 | `LOADING` |
| 8 | 8 | `LOADING` |
| 10 | 5 | — |
| 25 | 8 | `LOADING` |
So last section's edges `4 → 0, 5, 8×4` are each now attributed to a specific
event, and the shape of the state graph is complete for the input state.
### 🟡 A correspondence worth noticing, and not more than that
The main menu has five items, Ⓑ returns to the title, and this table has **one
event to state 0, four to `LOADING`, one elsewhere**. It is tempting to read that
as *Ⓑ = event 0, four items load an external archive, `EXTRAS` stays inside
`GP_TITLE`* — which would match everything measured.
**That is a count-match, not a decode.** The event numbers are not named, nothing
here shows event 3 is a particular menu row, and state 5 installs no named screen,
so the `EXTRAS` half of the story has no support at all. Recorded as an
observation so the next person sees the shape; it is **not** a button→event map.
### What is still not decoded
* **what the event numbers mean** — button id, menu-item index, or message id;
* **conditions on edges out of the other states** (`0 → 1` vs `0 → 2`, `6 → 7/9/2`);
* states 1, 3, 5, 6, 7, 9, which install no named screen.
## 🔴 Correcting my own framing: `sub_821C6458` is ONE PHASE, not the whole part
Chasing where the event code comes from turned up the level above, and it revises
what the previous three sections called "the title part's state machine".
`sub_821C6458` has exactly one caller, `sub_821C7850`, and that caller is itself a
dispatcher — on a **second, outer state field**:
```
821c786c lwz r11, 16(r30) ; only runs at all when this+16 == 3
821c7874 bne cr6, <exit>
821c787c lwz r11, 132(r30) ; phase = this+0x84
821c7880 cmplwi cr6, r11, 0x4 ; FIVE phases, 0..4
821c789c bctr ; table at 0x821C78A0
```
| phase | handler | |
|---|---|---|
| 0 | `sub_821C5690` | **the splash** — independently identified as the splash mechanics in the `iterate3E` notes, before any of this |
| 1 | inline at `0x821c790c` | |
| 2 | `sub_821C5818` | |
| 3 | `sub_821C5EC0` | |
| **4** | **`sub_821C6458`** | the title/menu machine — `TITLE_SCREEN` / `TITLE_MENU` / `LOADING` |
So `GamePart_Title` has **two nested state fields**: a phase at `this+132` (five
values) selecting which sub-machine runs, and the state at `this+136` (ten values)
*inside phase 4*. Everything the previous sections decoded — the ten states, the
eighteen edges, the event dispatch — is **phase 4 only**. Phases 03 are
untouched, and one of them is the developer splash.
That phase 0 lands on the function the corpus had already fingered as the splash,
from a completely different direction, is the useful check here.
### And the event code is forwarded, not created
All five phase handlers are called as `(this, r29, r28)` with the *same*
arguments, and `r28` is `sub_821C7850`'s own third argument, passed through
untouched. So the event vocabulary is defined **at least one level above** this
function. Finding what `3`, `5`, `8`, `10`, `25` mean means going up again, and
that was not done.
## Going up one more level — three of four callers pass a constant event
`sub_821C7850` has **four** direct callers. Recovering the `r5` argument at each:
| caller | event passed |
|---|---|
| `sub_821C7CB8` | **0** (→ back to the title) |
| `sub_821C7BA0` | **0** |
| `sub_821C47A0` | **5** (→ `LOADING`) |
| `sub_821C5580` | **not constant**`lwz r5, 4(r27)`, read out of a structure |
So the vocabulary is still not enumerable from here: the interesting caller reads
its event from a field. ❔ What `3`, `8`, `10` and `25` mean remains open.
**`sub_821C5580` is also where the outer gate is set:**
```
821c5640 li r11, 3
821c5644 stw r11, 16(r28) ; this+16 = 3 — the exact value sub_821C7850 tests
821c5650 lwz r5, 4(r27) ; event from a struct field
821c5658 bl 0x821C7850
```
That answers the "what does `this+16 == 3` gate on" question from the previous
section: this function arms it.
### 🎁 And it names the title's music
The same function, a few instructions earlier:
```
821c560c li r5, 1103
821c5610 li r4, 4
821c561c lwz r3, 0(r29)
821c5620 bl 0x8217ACF8 ; a sound-play call
```
**1103 is a BGM cue id**`BGM_103`. That closes an open residual from Q8/Q10
("which bank is the menu's music is not on the disc"), and it checks out three
ways; see [`structures/bgm-two-stems.md`](structures/bgm-two-stems.md).
## The other phases, characterised — and phase 0 confirms the splash twice over
Same treatment for the phase handlers that were still unread. Strings each one
references, and whether it carries a jump table of its own:
| phase | handler | size | strings referenced | own switch |
|---|---|---|---|---|
| 0 | `sub_821C5690` | 380 | **`LOGO`** | none |
| 2 | `sub_821C5818` | 1 576 | `BASE_INFO`, **`BUTTON`**, **`TITLE_SCREEN`** | none |
| 3 | `sub_821C5EC0` | 1 220 | *(none)* | one, `bctr` at `0x821c5ef8` |
| 4 | `sub_821C6458` | 4 460 | `BASE_INFO`, `LOADING`, `TITLE_MENU`, `TITLE_SCREEN` | two (known) |
**Phase 0 references `LOGO`** — a second, independent confirmation that it is the
developer splash. The `iterate3E` notes reached `sub_821C5690` from the guest side
and named the splash's `LOGO` items; this reaches the same function from the
registration site and finds the same string.
**Phase 2 draws the title *with* the `PRESS Ⓐ BUTTON` plate** — it references
`TITLE_SCREEN` and `BUTTON`, and [`ui-title-build-map.md`](ui-title-build-map.md)
established from the archive that the plate is its own build (2/3), composited
over the title art.
### 🟡 A hypothesis for a puzzle this corpus has had open for months
**The title is installed from two different places**: phase 2, and phase 4's
state 0. Same screen, different code.
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) has long
recorded, and never explained, that *the title which ends the boot accepts Ⓐ while
the title the attract loop returns to accepts nothing* — with the giveaway that a
draw capture in each state is identical, 13 quads at the same rects. Two code
paths installing the same screen is exactly the shape that would produce that.
**Candidate:** the boot title is **phase 2**; the attract-returned title is
**phase 4, state 0**. Only phase 2 wires up the Ⓐ handling.
⚠️ **Tested, and dead.** Ⓑ from the main menu is the decoded edge `4 → 0`, so the
Ⓑ-returned title *is* phase 4 state 0 — and **Ⓐ works there**, opening the main
menu, with Ⓐ on the boot title as the control in the same run. Phase 4 state 0
handles Ⓐ, so the two-code-paths explanation is refuted. See
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md), which the
test did narrow usefully: only the **attract**-returned title is inert, not every
title after the first.
### ⚠️ One query in this section failed its own control
Counting `stw rX, 136(r30)` per phase returned **0 for phase 4**, which is known
to have 18 — the operand text is `r11, 136(r30)` and the pattern did not allow the
space. The per-phase store counts were discarded. The `bctr` half *does* pass its
control (phase 4 shows both known tables) and is reported above.
## ✅ The phase transitions — and they invalidate yesterday's test
Nine stores to the phase field `this+132` across the `GamePart_Title`
neighbourhood, attributed to the handler each lives in (control: the same query
shape finds 30 stores to `this+136`, ≥ the 18 known in phase 4):
| store | sets phase | lives in |
|---|---|---|
| `0x821c4fbc` | 2 | outside the handlers (entry/setup) |
| `0x821c4fe0`, `0x821c4fec` | 4 | outside the handlers |
| `0x821c5010` | *(not an immediate)* | outside the handlers |
| `0x821c592c` | **0** | phase 2 |
| `0x821c5b00` | **3** | phase 2 |
| `0x821c6370` | **4** | phase 3 |
| **`0x821c6e04`** | **2** | **phase 4, in the event-0 block** |
| `0x821c7950` | 2 | the dispatcher |
```
entry → 2 2 → 0 (splash) 2 → 3 3 → 4 4 → 2 (event 0)
```
### 🔴 Withdrawing the refutation from the previous iteration
The event-0 block sets **both** fields, one instruction apart:
```
821c6df8 li r11, 2
821c6e00 stw r28, 136(r30) ; state = 0
821c6e04 stw r11, 132(r30) ; phase = 2
```
So **Ⓑ from the menu lands in phase 2**, not phase 4 state 0. The previous section
tested "Ⓐ on the Ⓑ-returned title" believing it was probing phase 4 state 0 —
**it was probing phase 2**, the same phase as the boot title.
The test therefore says nothing about phase 4 state 0, and the refutation it
produced is **withdrawn**. Worse for me and better for the idea: Ⓐ working there
is exactly what the hypothesis predicts, since phase 2 is the phase that
references `BUTTON`.
**Status of the hypothesis: untested, and now consistent with two observations**
(Ⓐ works on the boot title and on the Ⓑ-returned title, both phase 2). What it
still needs is the **attract**-returned title's phase — the one thing no test so
far has read.

View File

@@ -643,3 +643,58 @@ Two lessons, both cheap:
The evidence recovered cleanly once the interference stopped — the interrupted
batch's own run 3 had already reached the menu with the same 40 calls / 6
`ResolvePath` signature.
## 2026-08-28 — a candidate explanation, from the static side
This page's oldest open question is **why the attract-returned title accepts no
input while the boot title does**, given that a draw capture in each state is
identical (13 quads, same rects).
`GamePart_Title` turns out to dispatch on an outer **phase** field at `this+132`,
and **the title screen is installed from two different phases** — phase 2
(`sub_821C5818`, which references `TITLE_SCREEN` *and* `BUTTON`) and phase 4's
state 0 (`sub_821C6458`). Same screen, different code. See
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md).
**Candidate:** the boot title is phase 2; the attract-returned title is phase 4
state 0; only one of them wires up Ⓐ.
### ⚠️ Tested the same day, and the TEST was invalid — see the withdrawal below
There was a cheaper test than reading `this+132`: the state graph says Ⓑ from the
main menu goes `4 → 0`, so **the Ⓑ-returned title *is* phase 4 state 0**. If phase
4 state 0 were the inert one, Ⓐ would not work there.
**It works.** Measured:
| | |
|---|---|
| *control* — Ⓐ on the boot title | → main menu ✅ |
| Ⓐ on the **Ⓑ-returned** title | → **main menu** ✅ |
**🔴 Withdrawn.** Reading the event-0 block in full shows it sets **two** fields,
one instruction apart: `stw r28, 136(r30)` (state = 0) *and* `stw r11, 132(r30)`
with `r11 = 2`**phase = 2**. So Ⓑ lands in **phase 2**, the same phase as the
boot title, and this test never probed phase 4 state 0 at all.
The refutation is withdrawn. Ⓐ working on the Ⓑ-returned title is exactly what
the hypothesis predicts — phase 2 is the phase that references `BUTTON`.
### ✅ What the test DID establish — the puzzle is narrower than this page says
(This part stands: it is a measurement, independent of which phase the returned
title turns out to be.)
This page frames the problem as *the boot title accepts Ⓐ, a later title does
not*. That is too broad. **The Ⓑ-returned title accepts Ⓐ perfectly well** — a
case nobody had tried. Only the **attract-returned** title is inert.
So whatever makes the title stop responding is tied to the attract cycle
specifically — the movie playing and coming back — not to "the title has been
shown before". That is a much smaller haystack than the one this page has been
pointing at.
⚠️ Also reproduced in passing: 4 s after Ⓑ the plate is **absent** (169 plate
pixels), and it fades in after. A test that samples too early will read the
returned title as plate-less, which this corpus has already been caught by once.

View File

@@ -0,0 +1,364 @@
# 🔴 Why the boot harness stopped reaching the title — `screenshot` costs 10.8 s
**Status:****diagnosed, with a control.** Four consecutive runs on
2026-08-28/29 failed to reach the interactive title, across two locales, two
launch paths and two display-gamma settings. The cause is none of those.
## The measurement
| condition | one `screenshot` call |
|---|---|
| while Xenia Canary is running | **10.8 s** |
| immediately after killing it | **0.117 s** |
**92×.** The 1-minute load average at the slow measurement was 1.80, so this is
contention with the emulator (both go through the same X server), not general
system load.
## Why that breaks the harness
`skip_intro.sh` takes **two** grabs 0.6 s apart per iteration, plus an
`is_title.py` numpy load. Its actual sample timestamps in the last run:
```
57, 95, 177, 218, 238, 298, 333, 380, 426, 471, 515 → intervals
38, 82, 41, 20, 30, 30, 35, 47, 46, 45, 44, 43, 21, 19 median 41 s
```
A **41-second** sampling interval against a title screen that the corpus already
documents as lasting *"a few seconds"* before auto-returning to the attract loop
(`wait_title.sh`'s own header). The harness is not seeing a stuck game; it is
blinking slower than the thing it is looking for.
That is also why runs at 16:4318:05 the same day succeeded and later ones did
not — nothing about the game changed.
## 🔴 What this retracts
Three earlier conclusions were built on these runs and are **withdrawn as
causes**, though the observations stand:
* *"the Japanese-locale run never reaches the interactive title"* — it may well
have appeared, unsampled.
* *"neither locale reaches the interactive title without a pad press"* — the
English control shared the same defect, so it controlled for locale but not for
the sampling rate.
* *"the game sat in the attract loop for 604 s"* — what was observed is that
**every one of ~15 samples** landed on movie content, which at a 41 s interval
is a much weaker statement than it reads as.
## ⚠️ A confound I introduced
Setting `kernel_display_gamma_type = 0` makes the frame substantially brighter
(a mid-attract frame measured mean **122.8** against **52.5** and **82.8** on
comparable phases at type 2). `skip_intro.sh` classifies movie-vs-static on an
**absolute** rmse threshold of 1500 between two grabs, so a brighter output
inflates that difference and biases every frame toward "movie". **The capture
harness's tuning is coupled to the display settings** — changing gamma and
capture behaviour in one run confounds both.
## 🔴 The fix works — and it REFUTES the diagnosis above
Built and measured (`tools/re-capture/fast_title_probe.py`): one long-lived
`ffmpeg` x11grab stream, raw RGB frames, glyph counted in numpy. No per-sample
process startup, no PNG encode, no `convert -crop`.
| probe | seconds per sample, emulator running |
|---|---|
| the wrapper `screenshot` | **3.98** |
| `import -window root` → PPM | 1.20 |
| **long-lived x11grab stream** | **0.29** |
**13.7× faster**, and the counter is control-verified against the committed
frames — it returns **753** on `live-title-press-a.png` and **327** on
`live-main-menu.png`, byte-identical to `is_title.py`.
Then it was pointed at a running game:
```
332 frames in 85.3 s = 3.89 fps; max glyph 0
1674 frames in 420.0 s = 3.99 fps; max glyph 0
```
**1 674 consecutive samples over seven unbroken minutes, four per second, and the
interactive title never appeared.** So the sampling rate was a real defect and
*not* the cause. The hypothesis on this page — that the harness was blinking
slower than the event — is **mine, and refuted by my own fix**.
## What that restores
Last iteration I withdrew three conclusions on the strength of that hypothesis.
The withdrawal was right at the time (15 samples at 41 s intervals cannot support
them) and is now **superseded by better evidence**: dense sampling says the
interactive title genuinely does not appear in a mid-run window. Reinstated as a
**measurement**, with its reach:
* ✅ over **420 continuous seconds**, English, `gamma_type = 2`, ~13 minutes into
a run with no pad input, **zero** frames carried the green Ⓐ glyph.
* ⚠️ Reach: this covers a **mid-run** window only. It says nothing about the
first minutes of boot.
## 🟡 The leading hypothesis, not confirmed
The corpus already suspects the answer. `title_states_capture.sh` exists to test
*"whether the interactive one draws `ptbtn00` (the PRESS Ⓐ plate) and the other
does not"* — i.e. the title appears **twice**: once at the end of the boot
sequence, and again from the attract loop, and only the first may carry the
plate. If so, the plate's window is early and one-shot, and no amount of
mid-run sampling will ever find it.
That is consistent with everything measured, and it is **not confirmed**. The
test is to start the fast probe *before* the boot title — from t=0 rather than
attaching to a run already in progress.
## The original fix note, kept
Make the probe cheap enough to sample faster than the title window: grab a small
region rather than the full surface, drop the ImageMagick `convert` round trip,
or keep the glyph test in one long-lived process instead of re-importing numpy
per sample. None of that is done — this page is the diagnosis, and it is what
every remaining emulator-side question is waiting on.
---
## 🔴 The boot-window hypothesis is refuted too — and the surface is BLACK
**2026-08-29, third explanation and third refutation.** The named experiment was
to attach the fast probe at **t = 0** so the boot title could not be missed.
Done, on the default config, English:
```
2391 frames in 600.4 s = 3.98 fps; max glyph 0; hits 0
```
Ten minutes, sampled four times a second **from launch**, and the green Ⓐ glyph
never appeared. So "the plate only shows in an early boot window I keep missing"
is **mine, and refuted**.
Then the thing I should have checked first. Splitting the raw root grab by band:
| band | non-black | mean |
|---|---|---|
| y 044 (the GTK menu bar) | **100 %** | 210.5 |
| **y 45719 (the game surface)** | **0.08 %** | **0.07** |
**The game is rendering black**, reproducibly across back-to-back samples, while
the guest is alive and polling input (`XamInputGetKeystrokeEx` past 1 201 calls)
and `MEM-WATCH` keeps reporting. The crop and every pixel oracle were correct;
there was nothing on the surface to detect.
### ⚠️ What this does and does not explain
**It does not retroactively explain the earlier failures**, and saying so would
be the fourth over-reach in a row. Those runs had *content*: run 2 sampled frames
at mean 33.1 (warm), run 3's classifier measured real frame-to-frame rmse, and
the `gamma_type = 0` run measured mean 122.8. A black surface is **this run's**
symptom.
So the honest reading is that the failure mode **changed** over the session, and
the black screen is a new and worse one.
### 🟡 Hypothesis for the regression, untested
Canary's shader/pipeline cache at `~/.local/share/Xenia/cache` is **47 MB** and
was last written **23:49 on 2026-08-28** — during the failed runs. This session
has `kill -9`'d the emulator repeatedly, which can leave a partially written
cache. A corrupt pipeline cache is a plausible route to a guest that runs while
nothing reaches the screen.
**The test is one line and one run:** move `cache*` aside and boot again. Not
done — it needs a fresh 10-minute run, and this iteration had spent its emulator
budget proving the negative above.
---
## 🔴 The fast probe STALLS — and that invalidates its own dense negatives
**2026-08-29.** The instrument built last iteration is unfit for long runs, found
by cross-checking it against an independent grabber *while both watched the same
screen*.
A single long-lived `ffmpeg` x11grab stream degrades and then freezes:
```
862 frames in 540.1 s = 1.60 fps (it starts at 3.98 fps)
t=450s surface mean 5.21 nonblack 10.1%
t=480s surface mean 5.21 nonblack 10.1%
t=510s surface mean 5.21 nonblack 10.1%
t=540s surface mean 5.21 nonblack 10.1%
```
Four consecutive 30-second marks reporting an identical value. At that same
moment an `import` grab of the same display read **surface mean 125.65**, and a
*freshly started* ffmpeg stream read **122.43** — agreeing with `import` to 3 %.
So the acquisition, not the analysis, was broken: the stream was replaying a
stale frame while the screen was 24× brighter.
🔴 **Therefore the previous section's headline is withdrawn.** "2 391 frames over
600 s from t=0, max glyph 0" cannot distinguish *the title never appeared* from
*the stream froze early and repeated one frame 2 391 times*. Its 3.98 fps was
measured over the first 20 s, before the degradation.
**Fixed** in `tools/re-capture/fast_title_probe.py`: the stream is torn down and
restarted every 30 s. Startup costs ~0.3 s, cheap against the title's window, and
it buys a guarantee the frames are live.
## ✅ Clearing the shader cache restored rendering
The cache hypothesis from the previous section was tested: `cache`, `cache0`,
`cache1` and `cache_host` moved aside (to `/tmp/xenia-cache-aside`, not deleted),
then a fresh boot.
The surface **renders again**, confirmed with the independent grabber:
`import` reads surface mean **54.8** and **68.6** on successive samples with 100 %
non-black warm content, against **0.07** and 0.08 % non-black in the black run.
The probe's own summary agrees where it can be trusted — **773 of 862 frames had
> 2 % non-black**.
🟡 **Supported, not proven.** One run each side, and the emulator was `kill -9`'d
many times before the black run, so a corrupt pipeline cache is plausible but a
single A/B does not exclude run-to-run variation. The old caches are kept in
`/tmp/xenia-cache-aside` if anyone wants to reproduce the failure.
**Still no title.** Even rendering, `max glyph 0` — but that number now comes
from a stalling probe, so it establishes nothing either way.
---
## ✅ A negative I can finally stand behind — measured with a self-validating probe
**2026-08-29.** Three earlier "the title never appears" claims came from
instruments that were later found broken (a stale pixel oracle, a 41 s sampling
interval, a freezing stream). This run used
[`tools/re-capture/title_probe_xchecked.py`](../../tools/re-capture/title_probe_xchecked.py),
which restarts its stream every 30 s **and cross-checks itself against an
independent `import` grab every 60 s**, printing both numbers.
```
1851 frames in 560.2 s = 3.30 fps
cross-checks 9, disagreements 1
max glyph 0
```
| t | stream | import | |
|---|---|---|---|
| 62 s | 6.05 | 0.07 | disagree — a fade, the boot logos are mid-transition |
| 123 s | 7.40 | 7.49 | agree |
| 183 s | 8.18 | 8.29 | agree |
| 243 s | 0.23 | 0.10 | agree |
| 311 s | 89.68 | 89.51 | agree |
| 371 s | 80.97 | 81.58 | agree |
| 426 s | 117.43 | 117.72 | agree |
| 487 s | 77.71 | 76.25 | agree |
| 546 s | 70.43 | 70.55 | agree |
Eight of nine agree to within 2 %, the fps held at 3.30 (no collapse to 1.60),
and the surface plainly moved through dark and bright phases. **The frames were
live and the negative is real:**
> ✅ **Measured — over 560 continuous seconds from launch, sampled 3.3 times a
> second by a cross-validated probe, the interactive title's green Ⓐ plate never
> appears, while the game renders throughout.**
The final frame correlates **0.0145** with our title render, **0.0047** with the
main menu and **0.0102** with `EXTRAS` — it is attract-movie content, not a UI
screen at all.
## ❔ Why, still unknown
`live-title-press-a.png` is committed and carries 753 glyph pixels, so the title
*was* reachable from this container on 2026-08-28. Nothing found so far explains
the change. Clearing the shader cache fixed the *black surface* but not this.
## Where this leaves the emulator-side questions
The two open items that need a running menu — the gamma control
([tone curve](structures/ui-render-tone-curve.md)) and separating `8AX` from
`ptbase` ([8AX](structures/ui-8ax-fullres-background.md)) — remain blocked, now
on a well-characterised and instrument-verified failure rather than on a
suspicion. Neither blocks the five menu screens. **Returning to static work**;
the probe is committed for whoever picks this up.
---
## ❔ The GPU trace route: attempted, produced nothing, characterised
**2026-08-29.** To turn the gamma-ramp *inference* into an observation, canary's
`trace_gpu_stream` should work — it records gamma ramps as their own command type
(`kGammaRamp`, index 11 in `TraceCommandType`). Two bounded runs produced **no
trace file at all**: nothing under the prefix, no `.xtr` anywhere, no
`scratch/gpu/`.
Bounded deliberately: the container's disk is at **95 % (50 GiB free)** and a
boot-time trace of all GPU packets includes video decode, so the runner carried a
watchdog killing the emulator the moment the trace passed a 2 GiB cap. It never
fired — there was nothing to cap. Disk was unchanged at 95 % throughout.
What the attempt did establish:
* `BeginTracing()` is called at GPU init when the cvar is set
(`graphics_system.cc:237`), but `EndTracing()` runs only from
`GraphicsSystem::Shutdown()`. **A `kill -9` — which this session has used
routinely — can never finalise a trace.** The second run was therefore stopped
with `SIGTERM` and exited cleanly. Still no file, so that is not the whole
story.
* ~~Two candidates remain and were **not** separated: the CLI flag not reaching
the cvar, or `BeginTracing()` failing silently.~~
### ✅ Explained — and it was neither candidate
**The trace writer is compiled out of the build in use.** Following the code:
`BeginTracing()` only sets `trace_state_ = kStreaming` — *"Streaming starts on
the next primary buffer execute"* — and the file is opened later, in
`ExecutePrimaryBuffer`, inside
```cpp
#if XE_ENABLE_TRACE_WRITER_INSTRUMENTATION == 1
```
which `trace_writer.h` defines as:
```cpp
#ifdef NDEBUG
#define XE_ENABLE_TRACE_WRITER_INSTRUMENTATION 0 // release
#else
#define XE_ENABLE_TRACE_WRITER_INSTRUMENTATION 1 // debug
#endif
```
Confirmed in the binaries themselves, with a control. The format string
`"{:08X}_stream.xtr"` exists only inside that guard:
| binary | `_stream.xtr` occurrences |
|---|---|
| `build/bin/Linux/Release/xenia_canary` | **0** |
| `build/bin/Linux/Debug/xenia_canary` | **1** |
| `/sylph-home/re/canary-build/.../Release/xenia_canary`**the one `run-canary` uses** | **0** |
So `trace_gpu_stream` is a **no-op in this container's emulator**: the cvar
parses, `BeginTracing` runs, and nothing can ever open a file. Neither the CLI
flag nor `BeginTracing` was at fault, and neither was the `kill -9` — though that
would have destroyed the trace too, had one existed.
🟡 **The route exists but is not cheap.** A Debug build with the writer compiled
in is present at `build/bin/Linux/Debug/xenia_canary` (253 MB against Release's
18 MB). Running it means a much slower boot and a trace of every GPU packet on a
disk at 95 %. Recorded as available rather than attempted — the thing it would
confirm (the `DC_LUT` write) is already a well-supported inference, so the cost
is out of proportion to the gain.
## ⚠️ The config dump in a log is the FILE, not the effective command line
Nearly a wrong conclusion here. The dump printed at startup showed
`trace_gpu_stream = false` after I had passed `--trace_gpu_stream=true`, which
reads as "the flag was ignored". It is not evidence either way:
| | passed on the CLI | shown in the dump | actual behaviour |
|---|---|---|---|
| gamma run | `--log_mask=12 --log_level=3` | `log_mask = 0`, `log_level = 2` | **Kernel Debug logging demonstrably ON** |
The gamma run's flags plainly took effect — that run is where
`VdGetCurrentDisplayGamma` was captured — while its dump showed the file's
values. So the dump reflects the config file and cannot confirm or refute a
command-line override.

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

View File

@@ -0,0 +1,43 @@
# every draw in SUBMISSION ORDER, undeduplicated, frames 1..3
# tex dimensions identify the sprite; base is the guest address
0 prim=8 indices=3 vs=0x0A6D1DD7767FDF27 ps=0x2E372EA28CC404B7
vb=0x15036638 stride=28 attrs=[57@0 38@12 ] fmt0=57 v: [-0.50,-0.50,z=0.00000] [1279.50,-0.50,z=0.00000] [1279.50,719.50,z=0.00000]
1 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BA60000 1280x768 fmt=6]
vb=0x15036690 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
2 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
vb=0x150366F0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [0.70,1.58,z=0.00000,col=C3FFFFFF] [1.24,1.02,z=0.00000,col=C3FFFFFF] [0.40,-1.57,z=0.00000,col=C3FFFFFF] [-0.14,-1.02,z=0.00000,col=C3FFFFFF] [-1.29,1.02,z=0.00000,col=B6FFFFFF] [-0.85,1.81,z=0.00000,col=B6FFFFFF] [0.75,-1.02,z=0.00000,col=B6FFFFFF] [0.31,-1.81,z=0.00000,col=B6FFFFFF]
3 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B690000 1280x768 fmt=6]
vb=0x150367B0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
4 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
vb=0x15036810 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.90,0.68,z=0.00000,col=FFFFFFFF] [0.87,0.68,z=0.00000,col=FFFFFFFF] [0.87,-0.10,z=0.00000,col=FFFFFFFF] [-0.90,-0.10,z=0.00000,col=FFFFFFFF] [-0.89,0.65,z=0.00000,col=FFFFFFFF] [0.86,0.65,z=0.00000,col=FFFFFFFF] [0.86,-0.08,z=0.00000,col=FFFFFFFF] [-0.89,-0.08,z=0.00000,col=FFFFFFFF]
5 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
vb=0x150368D0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.71,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.15,z=0.00000,col=FFFFFFFF] [-0.71,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.10,z=0.00000,col=FFFFFFFF] [0.76,-0.10,z=0.00000,col=FFFFFFFF]
6 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BE30000 1280x768 fmt=6]
vb=0x15036990 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.79,0.15,z=0.00000,col=FFFFFFFF] [0.76,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.14,z=0.00000,col=FFFFFFFF] [-0.79,-0.14,z=0.00000,col=FFFFFFFF]
7 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
vb=0x150369F0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.67,z=0.00000,col=FFFFFFFF] [-0.40,-0.67,z=0.00000,col=FFFFFFFF]
8 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
vb=0x15036AB0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.42,-0.49,z=0.00000,col=31FFFFFF] [0.42,-0.49,z=0.00000,col=31FFFFFF] [0.42,-0.70,z=0.00000,col=31FFFFFF] [-0.42,-0.70,z=0.00000,col=31FFFFFF]
9 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
10 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
--- frame 3 ---
11 prim=8 indices=3 vs=0x0A6D1DD7767FDF27 ps=0x2E372EA28CC404B7
vb=0x15076C38 stride=28 attrs=[57@0 38@12 ] fmt0=57 v: [-0.50,-0.50,z=0.00000] [1279.50,-0.50,z=0.00000] [1279.50,719.50,z=0.00000]
12 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BA60000 1280x768 fmt=6]
vb=0x15076C90 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
13 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
vb=0x15076CF0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [0.73,1.58,z=0.00000,col=C5FFFFFF] [1.27,1.02,z=0.00000,col=C5FFFFFF] [0.43,-1.57,z=0.00000,col=C5FFFFFF] [-0.11,-1.02,z=0.00000,col=C5FFFFFF] [-1.32,1.02,z=0.00000,col=B8FFFFFF] [-0.88,1.81,z=0.00000,col=B8FFFFFF] [0.71,-1.02,z=0.00000,col=B8FFFFFF] [0.27,-1.81,z=0.00000,col=B8FFFFFF]
14 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B690000 1280x768 fmt=6]
vb=0x15076DB0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
15 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
vb=0x15076E10 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.90,0.68,z=0.00000,col=FFFFFFFF] [0.87,0.68,z=0.00000,col=FFFFFFFF] [0.87,-0.10,z=0.00000,col=FFFFFFFF] [-0.90,-0.10,z=0.00000,col=FFFFFFFF] [-0.89,0.65,z=0.00000,col=FFFFFFFF] [0.86,0.65,z=0.00000,col=FFFFFFFF] [0.86,-0.08,z=0.00000,col=FFFFFFFF] [-0.89,-0.08,z=0.00000,col=FFFFFFFF]
16 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
vb=0x15076ED0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.71,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.15,z=0.00000,col=FFFFFFFF] [-0.71,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.10,z=0.00000,col=FFFFFFFF] [0.76,-0.10,z=0.00000,col=FFFFFFFF]
17 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BE30000 1280x768 fmt=6]
vb=0x15076F90 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.79,0.15,z=0.00000,col=FFFFFFFF] [0.76,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.14,z=0.00000,col=FFFFFFFF] [-0.79,-0.14,z=0.00000,col=FFFFFFFF]
18 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
vb=0x15076FF0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.67,z=0.00000,col=FFFFFFFF] [-0.40,-0.67,z=0.00000,col=FFFFFFFF]
19 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
vb=0x150770B0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.42,-0.49,z=0.00000,col=1EFFFFFF] [0.42,-0.49,z=0.00000,col=1EFFFFFF] [0.42,-0.70,z=0.00000,col=1EFFFFFF] [-0.42,-0.70,z=0.00000,col=1EFFFFFF]
20 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
21 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -0,0 +1,421 @@
frame,seconds,mean_luminance
0,0.0000,26.045
1,0.0333,26.054
2,0.0667,26.051
3,0.1000,26.054
4,0.1333,26.049
5,0.1667,26.054
6,0.2000,26.052
7,0.2333,26.053
8,0.2667,26.046
9,0.3000,26.053
10,0.3333,26.049
11,0.3667,26.052
12,0.4000,26.047
13,0.4333,26.050
14,0.4667,26.048
15,0.5000,26.049
16,0.5333,26.047
17,0.5667,26.049
18,0.6000,26.048
19,0.6333,26.048
20,0.6667,26.045
21,0.7000,26.048
22,0.7333,26.049
23,0.7667,26.048
24,0.8000,26.046
25,0.8333,26.048
26,0.8667,26.048
27,0.9000,26.049
28,0.9333,26.047
29,0.9667,26.048
30,1.0000,26.048
31,1.0333,26.048
32,1.0667,26.047
33,1.1000,26.048
34,1.1333,26.047
35,1.1667,26.047
36,1.2000,26.046
37,1.2333,26.046
38,1.2667,26.046
39,1.3000,26.047
40,1.3333,26.046
41,1.3667,26.047
42,1.4000,26.047
43,1.4333,26.048
44,1.4667,26.047
45,1.5000,26.049
46,1.5333,26.050
47,1.5667,26.052
48,1.6000,26.051
49,1.6333,26.052
50,1.6667,26.052
51,1.7000,26.052
52,1.7333,26.052
53,1.7667,26.055
54,1.8000,26.056
55,1.8333,26.056
56,1.8667,26.055
57,1.9000,26.058
58,1.9333,26.059
59,1.9667,26.059
60,2.0000,26.057
61,2.0333,26.062
62,2.0667,26.022
63,2.1000,22.032
64,2.1333,21.744
65,2.1667,17.909
66,2.2000,13.996
67,2.2333,13.966
68,2.2667,11.113
69,2.3000,8.072
70,2.3333,7.862
71,2.3667,6.270
72,2.4000,3.375
73,2.4333,1.771
74,2.4667,0.020
75,2.5000,0.020
76,2.5333,0.020
77,2.5667,0.020
78,2.6000,0.020
79,2.6333,0.020
80,2.6667,0.020
81,2.7000,5.985
82,2.7333,5.991
83,2.7667,5.990
84,2.8000,5.991
85,2.8333,5.986
86,2.8667,7.432
87,2.9000,9.300
88,2.9333,9.283
89,2.9667,9.484
90,3.0000,11.294
91,3.0333,11.263
92,3.0667,11.606
93,3.1000,12.608
94,3.1333,12.807
95,3.1667,12.790
96,3.2000,14.517
97,3.2333,18.497
98,3.2667,26.935
99,3.3000,27.008
100,3.3333,27.010
101,3.3667,27.039
102,3.4000,27.036
103,3.4333,27.083
104,3.4667,27.239
105,3.5000,27.294
106,3.5333,27.293
107,3.5667,27.294
108,3.6000,27.289
109,3.6333,27.291
110,3.6667,27.290
111,3.7000,27.291
112,3.7333,27.289
113,3.7667,27.292
114,3.8000,27.291
115,3.8333,27.292
116,3.8667,27.291
117,3.9000,27.294
118,3.9333,27.291
119,3.9667,27.293
120,4.0000,27.290
121,4.0333,27.292
122,4.0667,27.291
123,4.1000,27.292
124,4.1333,27.290
125,4.1667,27.293
126,4.2000,27.293
127,4.2333,27.293
128,4.2667,27.292
129,4.3000,27.297
130,4.3333,27.296
131,4.3667,27.297
132,4.4000,27.296
133,4.4333,27.299
134,4.4667,27.300
135,4.5000,27.302
136,4.5333,27.301
137,4.5667,27.304
138,4.6000,27.304
139,4.6333,27.304
140,4.6667,27.302
141,4.7000,27.306
142,4.7333,27.308
143,4.7667,27.309
144,4.8000,27.309
145,4.8333,27.312
146,4.8667,27.311
147,4.9000,27.311
148,4.9333,27.310
149,4.9667,27.317
150,5.0000,27.319
151,5.0333,27.321
152,5.0667,27.322
153,5.1000,27.327
154,5.1333,27.327
155,5.1667,27.328
156,5.2000,27.327
157,5.2333,27.333
158,5.2667,27.334
159,5.3000,27.335
160,5.3333,27.334
161,5.3667,27.344
162,5.4000,27.347
163,5.4333,27.350
164,5.4667,27.351
165,5.5000,27.359
166,5.5333,27.359
167,5.5667,27.360
168,5.6000,27.358
169,5.6333,27.365
170,5.6667,27.366
171,5.7000,27.369
172,5.7333,27.367
173,5.7667,27.383
174,5.8000,27.386
175,5.8333,27.387
176,5.8667,27.386
177,5.9000,27.399
178,5.9333,27.400
179,5.9667,27.402
180,6.0000,27.400
181,6.0333,27.413
182,6.0667,27.414
183,6.1000,27.415
184,6.1333,27.413
185,6.1667,27.432
186,6.2000,27.434
187,6.2333,27.438
188,6.2667,27.436
189,6.3000,27.449
190,6.3333,27.448
191,6.3667,27.451
192,6.4000,27.449
193,6.4333,27.466
194,6.4667,27.471
195,6.5000,27.472
196,6.5333,27.470
197,6.5667,27.480
198,6.6000,27.478
199,6.6333,27.480
200,6.6667,27.476
201,6.7000,27.509
202,6.7333,27.510
203,6.7667,27.516
204,6.8000,27.514
205,6.8333,27.519
206,6.8667,27.519
207,6.9000,27.518
208,6.9333,27.516
209,6.9667,27.538
210,7.0000,27.537
211,7.0333,27.539
212,7.0667,27.538
213,7.1000,27.558
214,7.1333,27.556
215,7.1667,27.558
216,7.2000,27.554
217,7.2333,27.577
218,7.2667,27.576
219,7.3000,27.577
220,7.3333,27.572
221,7.3667,27.596
222,7.4000,27.598
223,7.4333,27.603
224,7.4667,27.597
225,7.5000,27.610
226,7.5333,27.611
227,7.5667,27.613
228,7.6000,27.607
229,7.6333,27.621
230,7.6667,27.621
231,7.7000,27.622
232,7.7333,27.617
233,7.7667,27.624
234,7.8000,27.626
235,7.8333,27.630
236,7.8667,27.622
237,7.9000,27.640
238,7.9333,27.637
239,7.9667,27.639
240,8.0000,27.633
241,8.0333,27.642
242,8.0667,27.639
243,8.1000,27.642
244,8.1333,27.637
245,8.1667,27.658
246,8.2000,27.653
247,8.2333,27.655
248,8.2667,27.649
249,8.3000,27.645
250,8.3333,27.648
251,8.3667,26.704
252,8.4000,23.887
253,8.4333,23.723
254,8.4667,16.471
255,8.5000,13.119
256,8.5333,13.102
257,8.5667,12.118
258,8.6000,10.955
259,8.6333,10.956
260,8.6667,5.365
261,8.7000,3.422
262,8.7333,3.416
263,8.7667,2.820
264,8.8000,0.020
265,8.8333,0.020
266,8.8667,0.020
267,8.9000,0.020
268,8.9333,0.020
269,8.9667,3.267
270,9.0000,5.012
271,9.0333,5.449
272,9.0667,7.339
273,9.1000,9.208
274,9.1333,10.415
275,9.1667,10.418
276,9.2000,10.462
277,9.2333,10.459
278,9.2667,10.569
279,9.3000,10.701
280,9.3333,10.868
281,9.3667,12.924
282,9.4000,14.688
283,9.4333,14.756
284,9.4667,17.184
285,9.5000,19.441
286,9.5333,19.438
287,9.5667,19.492
288,9.6000,19.492
289,9.6333,19.492
290,9.6667,19.508
291,9.7000,23.405
292,9.7333,23.417
293,9.7667,23.414
294,9.8000,23.416
295,9.8333,23.409
296,9.8667,23.467
297,9.9000,24.927
298,9.9333,24.929
299,9.9667,24.926
300,10.0000,25.788
301,10.0333,25.785
302,10.0667,25.789
303,10.1000,25.893
304,10.1333,25.908
305,10.1667,25.905
306,10.2000,25.905
307,10.2333,25.976
308,10.2667,25.986
309,10.3000,25.980
310,10.3333,25.981
311,10.3667,25.976
312,10.4000,25.984
313,10.4333,26.020
314,10.4667,26.022
315,10.5000,26.016
316,10.5333,26.021
317,10.5667,26.018
318,10.6000,26.020
319,10.6333,26.016
320,10.6667,26.021
321,10.7000,26.021
322,10.7333,26.021
323,10.7667,26.019
324,10.8000,26.022
325,10.8333,26.022
326,10.8667,26.022
327,10.9000,26.020
328,10.9333,26.025
329,10.9667,26.025
330,11.0000,26.025
331,11.0333,26.023
332,11.0667,26.029
333,11.1000,26.027
334,11.1333,26.028
335,11.1667,26.024
336,11.2000,26.024
337,11.2333,26.024
338,11.2667,26.024
339,11.3000,26.029
340,11.3333,26.029
341,11.3667,26.027
342,11.4000,26.029
343,11.4333,26.024
344,11.4667,26.027
345,11.5000,26.027
346,11.5333,26.027
347,11.5667,26.025
348,11.6000,26.031
349,11.6333,26.030
350,11.6667,26.033
351,11.7000,26.028
352,11.7333,26.032
353,11.7667,26.032
354,11.8000,26.032
355,11.8333,26.029
356,11.8667,26.034
357,11.9000,26.039
358,11.9333,26.041
359,11.9667,26.041
360,12.0000,26.040
361,12.0333,26.046
362,12.0667,26.047
363,12.1000,26.046
364,12.1333,26.050
365,12.1667,26.051
366,12.2000,26.051
367,12.2333,26.051
368,12.2667,26.051
369,12.3000,26.051
370,12.3333,26.051
371,12.3667,26.051
372,12.4000,26.050
373,12.4333,26.055
374,12.4667,26.056
375,12.5000,26.056
376,12.5333,26.055
377,12.5667,26.056
378,12.6000,26.056
379,12.6333,26.056
380,12.6667,26.055
381,12.7000,26.056
382,12.7333,26.056
383,12.7667,26.056
384,12.8000,26.055
385,12.8333,26.055
386,12.8667,26.056
387,12.9000,26.056
388,12.9333,26.056
389,12.9667,26.056
390,13.0000,26.055
391,13.0333,26.071
392,13.0667,26.072
393,13.1000,26.074
394,13.1333,26.082
395,13.1667,26.083
396,13.2000,26.083
397,13.2333,26.083
398,13.2667,26.083
399,13.3000,26.110
400,13.3333,26.111
401,13.3667,26.114
402,13.4000,26.113
403,13.4333,26.118
404,13.4667,26.119
405,13.5000,26.121
406,13.5333,26.119
407,13.5667,26.140
408,13.6000,26.140
409,13.6333,26.144
410,13.6667,26.142
411,13.7000,26.155
412,13.7333,26.157
413,13.7667,26.158
414,13.8000,26.157
415,13.8333,26.165
416,13.8667,26.165
417,13.9000,26.168
418,13.9333,26.165
419,13.9667,26.165
1 frame seconds mean_luminance
2 0 0.0000 26.045
3 1 0.0333 26.054
4 2 0.0667 26.051
5 3 0.1000 26.054
6 4 0.1333 26.049
7 5 0.1667 26.054
8 6 0.2000 26.052
9 7 0.2333 26.053
10 8 0.2667 26.046
11 9 0.3000 26.053
12 10 0.3333 26.049
13 11 0.3667 26.052
14 12 0.4000 26.047
15 13 0.4333 26.050
16 14 0.4667 26.048
17 15 0.5000 26.049
18 16 0.5333 26.047
19 17 0.5667 26.049
20 18 0.6000 26.048
21 19 0.6333 26.048
22 20 0.6667 26.045
23 21 0.7000 26.048
24 22 0.7333 26.049
25 23 0.7667 26.048
26 24 0.8000 26.046
27 25 0.8333 26.048
28 26 0.8667 26.048
29 27 0.9000 26.049
30 28 0.9333 26.047
31 29 0.9667 26.048
32 30 1.0000 26.048
33 31 1.0333 26.048
34 32 1.0667 26.047
35 33 1.1000 26.048
36 34 1.1333 26.047
37 35 1.1667 26.047
38 36 1.2000 26.046
39 37 1.2333 26.046
40 38 1.2667 26.046
41 39 1.3000 26.047
42 40 1.3333 26.046
43 41 1.3667 26.047
44 42 1.4000 26.047
45 43 1.4333 26.048
46 44 1.4667 26.047
47 45 1.5000 26.049
48 46 1.5333 26.050
49 47 1.5667 26.052
50 48 1.6000 26.051
51 49 1.6333 26.052
52 50 1.6667 26.052
53 51 1.7000 26.052
54 52 1.7333 26.052
55 53 1.7667 26.055
56 54 1.8000 26.056
57 55 1.8333 26.056
58 56 1.8667 26.055
59 57 1.9000 26.058
60 58 1.9333 26.059
61 59 1.9667 26.059
62 60 2.0000 26.057
63 61 2.0333 26.062
64 62 2.0667 26.022
65 63 2.1000 22.032
66 64 2.1333 21.744
67 65 2.1667 17.909
68 66 2.2000 13.996
69 67 2.2333 13.966
70 68 2.2667 11.113
71 69 2.3000 8.072
72 70 2.3333 7.862
73 71 2.3667 6.270
74 72 2.4000 3.375
75 73 2.4333 1.771
76 74 2.4667 0.020
77 75 2.5000 0.020
78 76 2.5333 0.020
79 77 2.5667 0.020
80 78 2.6000 0.020
81 79 2.6333 0.020
82 80 2.6667 0.020
83 81 2.7000 5.985
84 82 2.7333 5.991
85 83 2.7667 5.990
86 84 2.8000 5.991
87 85 2.8333 5.986
88 86 2.8667 7.432
89 87 2.9000 9.300
90 88 2.9333 9.283
91 89 2.9667 9.484
92 90 3.0000 11.294
93 91 3.0333 11.263
94 92 3.0667 11.606
95 93 3.1000 12.608
96 94 3.1333 12.807
97 95 3.1667 12.790
98 96 3.2000 14.517
99 97 3.2333 18.497
100 98 3.2667 26.935
101 99 3.3000 27.008
102 100 3.3333 27.010
103 101 3.3667 27.039
104 102 3.4000 27.036
105 103 3.4333 27.083
106 104 3.4667 27.239
107 105 3.5000 27.294
108 106 3.5333 27.293
109 107 3.5667 27.294
110 108 3.6000 27.289
111 109 3.6333 27.291
112 110 3.6667 27.290
113 111 3.7000 27.291
114 112 3.7333 27.289
115 113 3.7667 27.292
116 114 3.8000 27.291
117 115 3.8333 27.292
118 116 3.8667 27.291
119 117 3.9000 27.294
120 118 3.9333 27.291
121 119 3.9667 27.293
122 120 4.0000 27.290
123 121 4.0333 27.292
124 122 4.0667 27.291
125 123 4.1000 27.292
126 124 4.1333 27.290
127 125 4.1667 27.293
128 126 4.2000 27.293
129 127 4.2333 27.293
130 128 4.2667 27.292
131 129 4.3000 27.297
132 130 4.3333 27.296
133 131 4.3667 27.297
134 132 4.4000 27.296
135 133 4.4333 27.299
136 134 4.4667 27.300
137 135 4.5000 27.302
138 136 4.5333 27.301
139 137 4.5667 27.304
140 138 4.6000 27.304
141 139 4.6333 27.304
142 140 4.6667 27.302
143 141 4.7000 27.306
144 142 4.7333 27.308
145 143 4.7667 27.309
146 144 4.8000 27.309
147 145 4.8333 27.312
148 146 4.8667 27.311
149 147 4.9000 27.311
150 148 4.9333 27.310
151 149 4.9667 27.317
152 150 5.0000 27.319
153 151 5.0333 27.321
154 152 5.0667 27.322
155 153 5.1000 27.327
156 154 5.1333 27.327
157 155 5.1667 27.328
158 156 5.2000 27.327
159 157 5.2333 27.333
160 158 5.2667 27.334
161 159 5.3000 27.335
162 160 5.3333 27.334
163 161 5.3667 27.344
164 162 5.4000 27.347
165 163 5.4333 27.350
166 164 5.4667 27.351
167 165 5.5000 27.359
168 166 5.5333 27.359
169 167 5.5667 27.360
170 168 5.6000 27.358
171 169 5.6333 27.365
172 170 5.6667 27.366
173 171 5.7000 27.369
174 172 5.7333 27.367
175 173 5.7667 27.383
176 174 5.8000 27.386
177 175 5.8333 27.387
178 176 5.8667 27.386
179 177 5.9000 27.399
180 178 5.9333 27.400
181 179 5.9667 27.402
182 180 6.0000 27.400
183 181 6.0333 27.413
184 182 6.0667 27.414
185 183 6.1000 27.415
186 184 6.1333 27.413
187 185 6.1667 27.432
188 186 6.2000 27.434
189 187 6.2333 27.438
190 188 6.2667 27.436
191 189 6.3000 27.449
192 190 6.3333 27.448
193 191 6.3667 27.451
194 192 6.4000 27.449
195 193 6.4333 27.466
196 194 6.4667 27.471
197 195 6.5000 27.472
198 196 6.5333 27.470
199 197 6.5667 27.480
200 198 6.6000 27.478
201 199 6.6333 27.480
202 200 6.6667 27.476
203 201 6.7000 27.509
204 202 6.7333 27.510
205 203 6.7667 27.516
206 204 6.8000 27.514
207 205 6.8333 27.519
208 206 6.8667 27.519
209 207 6.9000 27.518
210 208 6.9333 27.516
211 209 6.9667 27.538
212 210 7.0000 27.537
213 211 7.0333 27.539
214 212 7.0667 27.538
215 213 7.1000 27.558
216 214 7.1333 27.556
217 215 7.1667 27.558
218 216 7.2000 27.554
219 217 7.2333 27.577
220 218 7.2667 27.576
221 219 7.3000 27.577
222 220 7.3333 27.572
223 221 7.3667 27.596
224 222 7.4000 27.598
225 223 7.4333 27.603
226 224 7.4667 27.597
227 225 7.5000 27.610
228 226 7.5333 27.611
229 227 7.5667 27.613
230 228 7.6000 27.607
231 229 7.6333 27.621
232 230 7.6667 27.621
233 231 7.7000 27.622
234 232 7.7333 27.617
235 233 7.7667 27.624
236 234 7.8000 27.626
237 235 7.8333 27.630
238 236 7.8667 27.622
239 237 7.9000 27.640
240 238 7.9333 27.637
241 239 7.9667 27.639
242 240 8.0000 27.633
243 241 8.0333 27.642
244 242 8.0667 27.639
245 243 8.1000 27.642
246 244 8.1333 27.637
247 245 8.1667 27.658
248 246 8.2000 27.653
249 247 8.2333 27.655
250 248 8.2667 27.649
251 249 8.3000 27.645
252 250 8.3333 27.648
253 251 8.3667 26.704
254 252 8.4000 23.887
255 253 8.4333 23.723
256 254 8.4667 16.471
257 255 8.5000 13.119
258 256 8.5333 13.102
259 257 8.5667 12.118
260 258 8.6000 10.955
261 259 8.6333 10.956
262 260 8.6667 5.365
263 261 8.7000 3.422
264 262 8.7333 3.416
265 263 8.7667 2.820
266 264 8.8000 0.020
267 265 8.8333 0.020
268 266 8.8667 0.020
269 267 8.9000 0.020
270 268 8.9333 0.020
271 269 8.9667 3.267
272 270 9.0000 5.012
273 271 9.0333 5.449
274 272 9.0667 7.339
275 273 9.1000 9.208
276 274 9.1333 10.415
277 275 9.1667 10.418
278 276 9.2000 10.462
279 277 9.2333 10.459
280 278 9.2667 10.569
281 279 9.3000 10.701
282 280 9.3333 10.868
283 281 9.3667 12.924
284 282 9.4000 14.688
285 283 9.4333 14.756
286 284 9.4667 17.184
287 285 9.5000 19.441
288 286 9.5333 19.438
289 287 9.5667 19.492
290 288 9.6000 19.492
291 289 9.6333 19.492
292 290 9.6667 19.508
293 291 9.7000 23.405
294 292 9.7333 23.417
295 293 9.7667 23.414
296 294 9.8000 23.416
297 295 9.8333 23.409
298 296 9.8667 23.467
299 297 9.9000 24.927
300 298 9.9333 24.929
301 299 9.9667 24.926
302 300 10.0000 25.788
303 301 10.0333 25.785
304 302 10.0667 25.789
305 303 10.1000 25.893
306 304 10.1333 25.908
307 305 10.1667 25.905
308 306 10.2000 25.905
309 307 10.2333 25.976
310 308 10.2667 25.986
311 309 10.3000 25.980
312 310 10.3333 25.981
313 311 10.3667 25.976
314 312 10.4000 25.984
315 313 10.4333 26.020
316 314 10.4667 26.022
317 315 10.5000 26.016
318 316 10.5333 26.021
319 317 10.5667 26.018
320 318 10.6000 26.020
321 319 10.6333 26.016
322 320 10.6667 26.021
323 321 10.7000 26.021
324 322 10.7333 26.021
325 323 10.7667 26.019
326 324 10.8000 26.022
327 325 10.8333 26.022
328 326 10.8667 26.022
329 327 10.9000 26.020
330 328 10.9333 26.025
331 329 10.9667 26.025
332 330 11.0000 26.025
333 331 11.0333 26.023
334 332 11.0667 26.029
335 333 11.1000 26.027
336 334 11.1333 26.028
337 335 11.1667 26.024
338 336 11.2000 26.024
339 337 11.2333 26.024
340 338 11.2667 26.024
341 339 11.3000 26.029
342 340 11.3333 26.029
343 341 11.3667 26.027
344 342 11.4000 26.029
345 343 11.4333 26.024
346 344 11.4667 26.027
347 345 11.5000 26.027
348 346 11.5333 26.027
349 347 11.5667 26.025
350 348 11.6000 26.031
351 349 11.6333 26.030
352 350 11.6667 26.033
353 351 11.7000 26.028
354 352 11.7333 26.032
355 353 11.7667 26.032
356 354 11.8000 26.032
357 355 11.8333 26.029
358 356 11.8667 26.034
359 357 11.9000 26.039
360 358 11.9333 26.041
361 359 11.9667 26.041
362 360 12.0000 26.040
363 361 12.0333 26.046
364 362 12.0667 26.047
365 363 12.1000 26.046
366 364 12.1333 26.050
367 365 12.1667 26.051
368 366 12.2000 26.051
369 367 12.2333 26.051
370 368 12.2667 26.051
371 369 12.3000 26.051
372 370 12.3333 26.051
373 371 12.3667 26.051
374 372 12.4000 26.050
375 373 12.4333 26.055
376 374 12.4667 26.056
377 375 12.5000 26.056
378 376 12.5333 26.055
379 377 12.5667 26.056
380 378 12.6000 26.056
381 379 12.6333 26.056
382 380 12.6667 26.055
383 381 12.7000 26.056
384 382 12.7333 26.056
385 383 12.7667 26.056
386 384 12.8000 26.055
387 385 12.8333 26.055
388 386 12.8667 26.056
389 387 12.9000 26.056
390 388 12.9333 26.056
391 389 12.9667 26.056
392 390 13.0000 26.055
393 391 13.0333 26.071
394 392 13.0667 26.072
395 393 13.1000 26.074
396 394 13.1333 26.082
397 395 13.1667 26.083
398 396 13.2000 26.083
399 397 13.2333 26.083
400 398 13.2667 26.083
401 399 13.3000 26.110
402 400 13.3333 26.111
403 401 13.3667 26.114
404 402 13.4000 26.113
405 403 13.4333 26.118
406 404 13.4667 26.119
407 405 13.5000 26.121
408 406 13.5333 26.119
409 407 13.5667 26.140
410 408 13.6000 26.140
411 409 13.6333 26.144
412 410 13.6667 26.142
413 411 13.7000 26.155
414 412 13.7333 26.157
415 413 13.7667 26.158
416 414 13.8000 26.157
417 415 13.8333 26.165
418 416 13.8667 26.165
419 417 13.9000 26.168
420 418 13.9333 26.165
421 419 13.9667 26.165

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

View File

@@ -0,0 +1,33 @@
Frame-signature match of captured frames against five candidate movies.
32x18 normalised grayscale, movie frames cropped to the 675/720 the game
surface shows, 1 fps sampling, best correlation over the whole movie.
CONTROL - frames captured during the BOOT movie, known to be ADV.wmv:
p2.png -> ADV @116s 0.644 (runner-up S13A 0.438)
p3.png -> ADV @121s 0.994 (runner-up S03A 0.650)
p4.png -> S13A @ 35s 0.984 (runner-up ADV 0.926)
p5.png -> ADV @129s 0.933 (runner-up S13A 0.809)
p6.png -> ADV @134s 0.992 (runner-up S13A 0.499)
p7.png -> S15B @ 88s 0.000 (runner-up S13A 0.000)
p8.png -> S13A @ 65s 0.576 (runner-up ADV 0.534)
TEST - frames captured during the ATTRACT loop, 5 s apart:
attract01.png -> ADV @ 39s 0.990 (runner-up S13A 0.487)
attract02.png -> ADV @ 45s 0.961 (runner-up S13A 0.411)
attract03.png -> S03A @ 37s 0.731 (runner-up ADV 0.422)
attract04.png -> ADV @ 56s 0.955 (runner-up S03A 0.532)
attract05.png -> ADV @ 62s 0.934 (runner-up S15B 0.245)
attract06.png -> S15B @ 65s 0.885 (runner-up ADV 0.743)
attract07.png -> ADV @ 75s 0.961 (runner-up S03A 0.355)
attract08.png -> ADV @ 80s 0.938 (runner-up S03A 0.753)
attract09.png -> ADV @ 85s 0.974 (runner-up S03A 0.484)
attract10.png -> ADV @ 92s 0.892 (runner-up S13A 0.754)
attract11.png -> S03A @ 24s 0.606 (runner-up ADV 0.588)
attract12.png -> ADV @102s 0.994 (runner-up S03A 0.374)
attract13.png -> ADV @108s 0.977 (runner-up S13A 0.496)
attract14.png -> ADV @113s 0.836 (runner-up S06B 0.464)
attract15.png -> ADV @119s 0.958 (runner-up S15B 0.424)
attract16.png -> S13A @ 32s 0.996 (runner-up ADV 0.980)
attract17.png -> ADV @131s 0.865 (runner-up S13A 0.769)
attract18.png -> ADV @137s 0.993 (runner-up S13A 0.502)
attract19.png -> S13A @ 65s 0.551 (runner-up ADV 0.534)

View File

@@ -0,0 +1,35 @@
bank waves per-wave (bytes, bytes/s, Hz, seconds)
BGM_001.slb 2 4466688 25697 48000 173.82s | 4673536 26887 48000 173.82s EQUAL-LENGTH
BGM_002.slb 2 4382720 26376 48000 166.16s | 4667392 28090 48000 166.16s EQUAL-LENGTH
BGM_003.slb 2 3555328 26201 48000 135.69s | 3414016 25159 48000 135.70s EQUAL-LENGTH
BGM_004.slb 2 6311936 32874 48000 192.00s | 5787648 30143 48000 192.01s EQUAL-LENGTH
BGM_005.slb 2 4005888 28013 48000 143.00s | 3917824 27397 48000 143.00s EQUAL-LENGTH
BGM_006.slb 2 3946496 26377 48000 149.62s | 3915776 26172 48000 149.62s EQUAL-LENGTH
BGM_007.slb 2 7565312 29269 48000 258.48s | 8099840 31337 48000 258.48s EQUAL-LENGTH
BGM_008.slb 2 3258368 29947 48000 108.80s | 3397632 31227 48000 108.80s EQUAL-LENGTH
BGM_009.slb 2 3819520 25636 48000 148.99s | 3614720 24262 48000 148.99s EQUAL-LENGTH
BGM_010.slb 2 4190208 28457 48000 147.25s | 4100096 27845 48000 147.25s EQUAL-LENGTH
BGM_011.slb 2 4222976 25232 48000 167.37s | 4311040 25758 48000 167.37s EQUAL-LENGTH
BGM_012.slb 2 5562368 31605 48000 176.00s | 5404672 30709 48000 176.00s EQUAL-LENGTH
BGM_013.slb 2 6539264 29904 48000 218.68s | 6457344 29530 48000 218.67s EQUAL-LENGTH
BGM_014.slb 2 6656000 31438 48000 211.72s | 6948864 32821 48000 211.72s EQUAL-LENGTH
BGM_015.slb 2 8017920 29230 48000 274.30s | 7974912 29074 48000 274.30s EQUAL-LENGTH
BGM_016.slb 2 3549184 28341 48000 125.23s | 2775040 22159 48000 125.23s EQUAL-LENGTH
BGM_017.slb 2 5353472 30690 48000 174.44s | 5062656 29023 48000 174.44s EQUAL-LENGTH
BGM_018.slb 2 3620864 26878 48000 134.71s | 2920448 21679 48000 134.71s EQUAL-LENGTH
BGM_019.slb 2 6834176 30665 48000 222.87s | 6713344 30123 48000 222.86s EQUAL-LENGTH
BGM_020.slb 2 4804608 29592 44100 162.36s | 4675584 28798 44100 162.36s EQUAL-LENGTH
BGM_021.slb 2 7780352 28034 44100 277.53s | 7694336 27724 44100 277.53s EQUAL-LENGTH
BGM_022.slb 2 5545984 28006 44100 198.03s | 5867520 29630 44100 198.03s EQUAL-LENGTH
BGM_023.slb 2 4962304 28001 44100 177.22s | 5044224 28463 44100 177.22s EQUAL-LENGTH
BGM_101.slb 2 4800512 27481 48000 174.68s | 2635776 15089 48000 174.68s EQUAL-LENGTH
BGM_102.slb 2 1150976 30703 48000 37.49s | 1269760 33872 48000 37.49s EQUAL-LENGTH
BGM_103.slb 2 3876864 44181 48000 87.75s | 3930112 44788 48000 87.75s EQUAL-LENGTH
BGM_104.slb 2 2672640 31503 48000 84.84s | 2674688 31527 48000 84.84s EQUAL-LENGTH
BGM_105.slb 2 3581952 35879 48000 99.83s | 3850240 38566 48000 99.84s EQUAL-LENGTH
BGM_106.slb 2 4098048 45389 48000 90.29s | 2383872 36575 48000 65.18s
BGM_107.slb 2 2418688 37109 48000 65.18s | 3696640 28676 48000 128.91s
BGM_108.slb 2 3696640 28676 48000 128.91s | 2887680 35432 48000 81.50s
BGM_109.slb 4 2932736 35985 48000 81.50s | 112640 13377 48000 8.42s | 225280 14228 48000 15.83s | 235520 14795 48000 15.92s
32 BGM banks; 28 are exactly two waves of equal duration

View File

@@ -0,0 +1,10 @@
# tools/re-capture/doc_link_check.py -- 2026-08-29, after the repair pass
1050 link(s) resolve
5 MISSING target(s):
docs/re/autopilot-knowledge-sources.md -> ../../MEMORY.md
docs/re/challenge-mission-gate.md -> ../../../xenia-canary-native/src/xenia/hid/file/file_input_driver.h
docs/re/entities-live-roster.md -> ../../MEMORY.md
docs/re/mission-freeze-resume-spin.md -> canary-build-verified-env-confound.md
docs/re/stage-drift-is-navigation-not-save.md -> structures/weapon-datasheet-runtime.md

View File

@@ -0,0 +1,7 @@
matched 170 decoded textures to a T8aD flag word
bit SET n= 61 mean %(RGB>A) 55.52 median 52.52
bit clear n=109 mean %(RGB>A) 33.66 median 30.17
premultiplied would require ~0% for the flagged group -> REFUTED
best single-threshold accuracy: 76.5% at %(RGB>A) > 83.5
(base rate, always-guess-majority: 64.1%)

View File

@@ -0,0 +1,24 @@
# tools/re-capture/eff_bit_census.py over dat/GP_*.pak -- 2026-08-29
sprites with a resolvable preceding name: 14709
bit SET & name has 'eff' : 2338
bit SET & name lacks 'eff': 2657 <-- counterexamples to 'set => eff'
bit clear & name has 'eff' : 1399
bit clear & name lacks 'eff': 8315
P(name has 'eff' | bit set) = 0.468
P(name has 'eff' | bit clear) = 0.144
counterexamples (bit set, no 'eff'):
('GP_BUNK.pak', 'px_top_extra.t32', '00008832', '220x126')
('GP_BUNK.pak', 'px_bunk_line.t32', '00008832', '663x27')
('GP_BUNK.pak', 'px_top_extra.t32', '00008832', '220x126')
('GP_BUNK.pak', 'px_bunk_line.t32', '00008832', '663x27')
('GP_BUNK.pak', 'pv_loading_line.t32', '00000832', '181x101')
('GP_BUNK.pak', 'pv_loading_ring0.t32', '00000832', '142x142')
('GP_BUNK.pak', 'pv_loading_light0.t32', '00000832', '29x25')
('GP_BUNK.pak', 'pv_loading_light1.t32', '00000832', '30x29')
('GP_BUNK.pak', 'pv_loading_light2.t32', '00000832', '29x30')
('GP_BUNK.pak', 'pv_loading_light3.t32', '00000832', '25x29')
('GP_BUNK.pak', 'pv_loading_ring1.t32', '00000832', '72x72')
('GP_BUNK.pak', 'pv_loading_line.t32', '00000832', '181x101')

View File

@@ -0,0 +1,2 @@
main menu: corr +0.0475 controls +0.0032 (shift) -0.0075 (flip) ceiling 0.070 -> 68% of it
title: corr +0.0634 controls +0.0095 (shift) +0.0086 (flip) ceiling 0.094 -> 68% of it

View File

@@ -0,0 +1,29 @@
GamePart ids bound to a C++ class by the executable's own registration strings
(24 strings, 24 distinct ids)
0 GamePart_Title
3 GamePart_SaveLoad
4 GamePart_SaveLoad
5 GamePart_Extras
6 GamePart_MovieTheater
7 GamePart_MissionSelect
8 GamePart_Options
9 GamePart_Movie
10 GamePart_Bunk
11 GamePart_ReadyRoom
12 GamePart_Hangar
13 GamePart_Arsenal
14 GamePart_PilotLog
15 GamePart_System
17 GamePart_MainGame
19 GamePart_PauseMenu
20 GamePart_StageClear
21 GamePart_MissionLog
22 GamePart_GameOver
23 GamePart_Debriefing
24 GamePart_Dialog
25 GamePart_Tutorial
26 GamePart_ChallengeMission
27 GamePart_Leaderboard
ids 0..28 with NO registration string: [1, 2, 16, 18, 28]

View File

@@ -0,0 +1,9 @@
# canary --log_mask=12 --log_level=3 (Kernel logging ON), 2026-08-29
# control: the same log carries 359 VdRetrainEDRAM and 358 VdGetSystemCommandBuffer lines,
# so a missing call would have been visible.
F 820006F4 8284E27C 1B9 ( 441) VdGetCurrentDisplayGamma
F 8200070C 8284E2BC 1BA ( 442) VdGetCurrentDisplayInformation
F 82000710 8284E2CC 1D3 ( 467) VdSetDisplayMode
d> F8000008 VdGetSystemCommandBuffer(701CF830, 701CF804)
d> F8000008 VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))

View File

@@ -0,0 +1,51 @@
# tools/re-capture/kf_rotation_census.py over dat/GP_*.pak — 2026-08-28
# CONTROL must read 3/3; see the tool docstring for why.
CONTROL ptloop01: 3/3 blocks found, +12 = {30} (want 30)
CONTROL ptloop02: 3/3 blocks found, +12 = {-45} (want -45)
blocks scanned disc-wide: 83862
+4: non-zero in 3990 ( 4.76 %)
+8: non-zero in 3878 ( 4.62 %)
+12: non-zero in 12164 (14.50 %)
value histogram (non-zero), top 25:
+4 = 180 x3880
+8 = 180 x3102
+12 = 90 x1824
+12 = -90 x1176
+12 = 360 x1173
+12 = 120 x492
+12 = 180 x474
+12 = -58 x402
+12 = 53 x396
+12 = -125 x378
+12 = -120 x366
+12 = -66 x294
+12 = 114 x240
+8 = 90 x201
+12 = 129 x200
+12 = -43 x199
+12 = 115 x186
+12 = -360 x180
+8 = -180 x156
+12 = 58 x156
+8 = 178 x144
+12 = -33 x138
+12 = 124 x138
+12 = -133 x126
+12 = 130 x126
examples:
+12 ('GP_BUNK', '117ca14f', '0x73961b', 360)
+12 ('GP_BUNK', '117ca14f', '0x7398ac', 360)
+12 ('GP_BUNK', '117ca14f', '0x739a93', 360)
+12 ('GP_BUNK', '117ca14f', '0x739d24', 360)
+4 ('GP_BUNK', '117ca14f', '0x73ae5d', -180)
+4 ('GP_BUNK', '117ca14f', '0x73ae85', -180)
+4 ('GP_BUNK', '1ff0bcb0', '0x733d68', -180)
+4 ('GP_BUNK', '1ff0bcb0', '0x733d90', -180)
+8 ('GP_DEBRIEFING_PILOTLOG', '18ab1d66', '0x6f8', 90)
+8 ('GP_DEBRIEFING_PILOTLOG', '18ab1d66', '0x770', 90)
+8 ('GP_DEBRIEFING_PILOTLOG', '1c8a0129', '0x6f8', 90)
+8 ('GP_DEBRIEFING_PILOTLOG', '1c8a0129', '0x770', 90)

View File

@@ -0,0 +1,57 @@
The r5 (name) argument at every call site of sub_821CC860.
Recovered by forward register simulation over the 100 instructions before
each call, tracking lis/addi/subi/mr. Gated on three sites read by eye:
0x821c6524 TITLE_SCREEN, 0x821c67c8 TITLE_MENU, 0x821c73c8 LOADING -- all three
reproduce, so the remaining rows are trusted.
call site caller r5 name
0x82185f14 sub_82185E80 0x820a2160 EXTRA
0x821860b4 sub_82186050 0x820a214c LOADING
0x82186350 sub_82186270 0x820a2140 NEW_ITEM
0x821863f8 sub_82186270 0x820a2140 NEW_ITEM
0x8218c094 sub_8218BFE0 0x820a2384 MENU
0x8219403c sub_82193648 - (not recovered)
0x82196b44 sub_82196A50 0x820a2168 FILE
0x82198540 sub_82198440 0x820a2570 BASE
0x821988e4 sub_82198808 0x820a270c MESSAGE
0x82198a5c sub_82198808 0x820a28ec MENU_DISABLE_SKIP
0x8219c3b4 sub_8219C328 0x820a2acc INFO
0x821a0b4c sub_821A0AD0 0x820a255c FADE
0x821a0b98 sub_821A0AD0 0x820a2ae8 BG
0x821a0bdc sub_821A0AD0 0x820a2adc DEBRIEFING
0x821a1028 sub_821A0F80 0x820a2ab4 LOCAL_BOARD
0x821a113c sub_821A0F80 0x820a255c FADE
0x821a1188 sub_821A0F80 0x820a2ae8 BG
0x821a129c sub_821A0F80 0x820a2554 TITLE
0x821a12e8 sub_821A0F80 0x820a2ad4 BLACK
0x821a13b4 sub_821A0F80 0x820a2ab4 LOCAL_BOARD
0x821a1a6c sub_821A19B0 0x820a2ac0 LIVE_BOARD
0x821a1bd4 sub_821A19B0 0x820a255c FADE
0x821a1c20 sub_821A19B0 0x820a2ae8 BG
0x821a1d34 sub_821A19B0 0x820a2554 TITLE
0x821a1d80 sub_821A19B0 0x820a2ad4 BLACK
0x821a1e4c sub_821A19B0 0x820a2ac0 LIVE_BOARD
0x821a2b48 sub_821A2A80 0x820a2a74 DETAIL
0x821b368c sub_821B34E8 0x820a2570 BASE
0x821b3780 sub_821B34E8 0x820a32ac WINDOW
0x821b9210 sub_821B9118 0x820a2168 FILE
0x821bc60c sub_821BC568 - (not recovered)
0x821bca58 sub_821BC978 0x820a2384 MENU
0x821be188 sub_821BDF88 0x820a2570 BASE
0x821be1ec sub_821BDF88 0x820a38ac TUTORIAL_MENU
0x821c1d30 sub_821C1B58 0x820a2168 FILE
0x821c2f04 sub_821C2EA0 0x820a2168 FILE
0x821c5c88 sub_821C5818 0x820a3d3c TITLE_SCREEN
0x821c5d70 sub_821C5818 0x820a339c BUTTON
0x821c6524 sub_821C6458 0x820a3d3c TITLE_SCREEN
0x821c67c8 sub_821C6458 0x820a3d30 TITLE_MENU
0x821c73c8 sub_821C6458 0x820a214c LOADING
0x821c9488 sub_821C93C8 0x820a2168 FILE
0x822b2e04 sub_822B2D80 0x820ab24c KEY
0x822b581c sub_822B5790 0x820ab2a4 GAMMA_TITLE
0x822b5914 sub_822B5790 0x820ab298 GAMMA_RGB
0x822b71ac sub_822B7128 0x820a99dc GAME
0x822b8700 sub_822B8670 0x820a3124 PAD
0x822b9ebc sub_822B9E38 0x820a1784 SOUND
46/48 recovered.

View File

@@ -0,0 +1,23 @@
# How much of each recorded paint order its draw capture actually cross-checked.
# 2026-08-29, tools/re-capture/order_crosscheck_reach.py
#
# The three measured_paint_order vectors are reads of the runtime CHILD ARRAY
# (ui-screen-runtime.md: "paint order (child slots)"). These captures are the
# cross-check, not the source. Quads = indices/4: a single draw can carry
# several (the menu's draw 9 has indices=24 = 6 quads), so counting draws
# under-reads what is on screen.
== docs/re/captures/title-screen-draw-order.log
frame 1: 8 draws, 12 quads, 5 textures
frame 2: 8 draws, 12 quads, 5 textures
frame 3: 8 draws, 12 quads, 5 textures
textures in EVERY captured frame: 5 of 5
== docs/re/captures/title-draw-order-with-alpha.log
frame 1: 8 draws, 12 quads, 5 textures
frame 2: 8 draws, 12 quads, 5 textures
textures in EVERY captured frame: 5 of 5
== docs/re/captures/main-menu-draw-order.log
frame 0: 9 draws, 16 quads, 5 textures
frame 3: 7 draws, 9 quads, 4 textures
textures in EVERY captured frame: 4 of 5
absent from some frame: ['11C30000']

View File

@@ -0,0 +1,35 @@
# cargo run -p sylpheed-formats --example paint_order_audit -- dat/GP_TITLE.pak
# 2026-08-29
1682 | let decl = &decls[0];
290 | let mut flush = |base: u32,
entry 0 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
entry 1 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
entry 2 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 3 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 4 title 24 elements
derived == measured : NO
inverted pairs : 8 (of which same-layer-key ties: 8)
measured: [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8]
derived : [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, 22, 23, 21, 8]
keys : [32928, 32928, 32928, 32928, 32928, 32928, 32832, 32928, 4294967295, 32768, 4294967295, 32784, 32784, 4294967295, 32899, 32899, 32899, 32899, 32899, 32898, 32897, 33024, 32936, 32937]
entry 5 main menu 16 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 6 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING
entry 7 (no measured order) 30 elements, 37 tied pairs, 16 of them OVERLAPPING
entry 8 main menu 16 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 9 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING
entry 10 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 11 splash 7 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 12 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING
entry 13 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 14 splash 7 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 15 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING
5 build(s) with a measured order were checked

View File

@@ -0,0 +1,25 @@
CONTROL GP_TITLE build 7: fallback elements [8, 13, 14] (want [8, 13, 14])
index 8 is 'ptlogo_eff3.t32' (want ptlogo_eff3.t32)
elements with a keyframe group, disc-wide: 15493
no plateau -> rest pose is GUESSED: 3807 (24.57 %)
most common guessed elements:
1002 pb_name_eff.t32
90 gbp301i.t32
84 pb_warp_eff.t32
78 pbb_destroyer.t32
70 gbp303i.t32
60 pb_w_line.t32
54 pbmwindow_eff.t32
54 pbfriendly.t32
32 pzeff00.prm
32 pzeff02.t32
32 px_mission_area.rat
30 pbr_fighter.t32
--- the 3807 plateau-less elements, by what each candidate rule returns
current rule (longest dwell) returns an INVISIBLE pose : 1711 ( 44.9 %)
current rule returns a ZERO-SCALE (degenerate) pose : 195 ( 5.1 %)
'rest = last keyframe' returns an INVISIBLE pose : 1618 ( 42.5 %)
the two rules agree : 1911 ( 50.2 %)

View File

@@ -0,0 +1,16 @@
# tools/re-capture/plateauless_endstate.py over dat/GP_*.pak -- 2026-08-29
plateau-less, multi-keyframe, ending VISIBLE: 687
alpha never decreases (a fade/slide IN that stops): 472 (68.7 %)
ends at its maximum alpha but dips on the way : 203 (29.5 %)
ends visible but NOT at maximum alpha : 12 (1.7 %)
examples of the monotone kind (alpha sequence, then scale pairs):
GP_BUNK.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
GP_BUNK.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
GP_MAIN_GAME_D2D.pak pghud_speed_cut.t32 [0, 255] [(100, 100), (100, 100)]
GP_MAIN_GAME_D2D.pak pgmsg_restart_sub.rat [255, 255] [(106, 106), (100, 100)]

View File

@@ -0,0 +1,8 @@
# tools/re-capture/rest_rule_blast_radius.py over dat/GP_*.pak -- 2026-08-29
genuinely ambiguous elements (plateau-less, 2+ keyframes): 2305
the two rules AGREE on : 409 (17.7 %)
they DIFFER on : 1896 (82.3 %)
rule dwell : returns an INVISIBLE pose 1711 ( 74.2 %), zero-scale 195 ( 8.5 %)
rule last : returns an INVISIBLE pose 1618 ( 70.2 %), zero-scale 43 ( 1.9 %)

View File

@@ -0,0 +1,7 @@
# `sylpheed-cli screen info --geometry`, rows with a non-zero rotation (r=).
# GP_DIALOG builds 0 and 6; GP_DEBRIEFING_PILOTLOG build 10. 2026-08-28
4 pceff03.t32 247x281 38x38 NO 8: 630,348 100%,100% a=0 r=90 12: 550,273 100%,100% a=128 r=30 14: 510,235 100%,100% a=192 r=10 16: 490,217 100%,100% a=224 r=3 52: 470,198 100%,100% a=255 58: 470,198 100%,100% a=255 -: 470,198 100%,100% a=0
5 pceff04.t32 257x311 478x586 NO 8: 413,77 100%,100% a=0 r=90 12: 483,167 100%,100% a=128 r=30 14: 518,212 100%,100% a=192 r=10 16: 536,234 100%,100% a=224 r=3 52: 553,247 100%,100% a=255 58: 553,247 100%,100% a=255 -: 553,247 100%,100% a=0
1 pzeff02.t32 529x519 528x524 NO 197: 376,58 0%,0% a=0 217: 376,58 112%,112% a=255 r=43 232: 376,58 145%,145% a=64 r=61 237: 376,58 173%,173% a=32 r=75 -: 376,58 200%,200% a=0 r=90
5 pjeff24a.t32 382x140 610x622 NO -: 335,49 210%,210% a=53 r=90

View File

@@ -0,0 +1,32 @@
CONTROL GP_TITLE build 4 (nested rotations only): 0 top-level rotated (want 0)
CONTROL GP_DIALOG build 0: 2 top-level rotated (want 2)
[4] pceff03.t32 r = [90, 30, 10, 3, 0, 0, 0]
[5] pceff04.t32 r = [90, 30, 10, 3, 0, 0, 0]
top-level elements with a keyframe group, disc-wide: 15493
carrying a non-zero rotation: 2152 (13.89 %)
by archive:
1868 GP_READY_ROOM.pak
40 GP_DIALOG.pak
37 GP_MAIN_GAME_D2D.pak
37 GP_MAIN_GAME_E2D.pak
37 GP_MAIN_GAME_F2D.pak
37 GP_MAIN_GAME_I2D.pak
37 GP_MAIN_GAME_J2D.pak
37 GP_MAIN_GAME_S2D.pak
10 GP_GAMEOVER.pak
8 GP_DEBRIEFING_PILOTLOG.pak
4 GP_TITLE.pak
most common rotated element names:
444 pbb_destroyer.t32
402 pbr_destroyer.t32
276 pbr_fighter.t32
174 pbb_fighter.t32
168 pbb_aircraft.t32
84 pb_warp_eff.t32
48 pbtab.t32
35 gbp012i.t32
32 pzeff02.t32
30 gbp001i.t32

View File

@@ -0,0 +1,39 @@
SE cue waves located inside Static.slb by playing them.
Method: run Canary with --xma_param_probe=true, drive the main menu, and read the
per-stream XMA-PARAM lines. Each line carries the stream's first 32 bytes; those
bytes are then searched for in Static.slb's 8 353 472 readable bytes.
event packets bytes channels/rate offset in Static.slb
------------------------- ------- ------ -------------- --------------------
d-pad move (cursor) 4 8192 mono 48000 Hz 0x1ec0
B / back (cancel) 2 4096 mono 48000 Hz 0x0ec0
A / confirm (title->menu) 6 12288 mono 48000 Hz 0x5d6c0
Each head matched at exactly ONE offset. The first two are contiguous:
0x0ec0 + 4096 = 0x1ec0.
Simultaneously at the main menu, two STEREO 48 kHz streams were decoding:
1893 packets / 3 876 864 B and 1919 packets / 3 930 112 B. See
structures/bgm-two-stems.md -- this is the runtime observation that the two
stems of a music bank play at the same time.
DECODED 2026-08-28 with tools/re-capture/slb_extract_wave.py + ffmpeg's xma1:
event offset packets decoded rms peak envelope shape
---------------- -------- ------- --------- ----- ----- ------------------------
d-pad move 0x1ec0 4 0.533 s 2085 29813 sharp attack, monotonic decay
B / back 0x0ec0 2 0.344 s 2985 16973 attack, peak in frame 2
(pre-input cue) 0x5d6c0 6 1.016 s 4327 32767 peaks in frame 3, long decay
CONTROL: the same synthesized RIFF wrapper applied to BGM_001.slb's first wave
(offset 14336, stereo) decodes to 173.808875 s -- identical to the duration
obtained from that bank's own on-disc RIFF header. The wrapper is correct.
SECOND RUN 2026-08-28 -- independent boot, same probe:
* d-pad move and B reproduced with IDENTICAL head bytes and sizes (2 of 2 runs).
* The 12288-byte wave fires on the A that advances title -> main menu, together
with the two stereo BGM stems (the menu's music starting). Counting from the
title rather than the menu is what attributed it.
* Left and Right fired NO new stream -- no distinct invalid cue. (Dedup caveat:
a press replaying an already-heard wave would also log nothing.)

326
docs/re/data/se-ui-cues.txt Normal file
View File

@@ -0,0 +1,326 @@
Every SE cue on the disc, from tables.pak SOUNDS. 322 cues.
BANK_SE names the single bank they all live in: Static.slb
0 of the 322 has its own entry in FILES (5 135 bank paths).
1 SE_UI_START
2 SE_UI_CURSOR
3 SE_UI_DECIDE
4 SE_UI_CANSEL
5 SE_UI_IMPOSI
6 SE_UI_WAIT
7 SE_UI_NEXT
8 SE_UI_SUB_WIN_OPN
9 SE_UI_SUB_WIN_CLS
10 SE_UI_ALART_WIN
11 SE_UI_PAUSE
12 SE_UI_SPLASH_IN
13 SE_UI_SPLASH_OUT
14 SE_UI_LOAD_CMP
15 SE_TUT_BOX_CLEAR
16 SE_TUT_UNIT_IN
17 SE_UI_WEAPON_PLAN
18 SE_UI_WEAPON_CREATE
21 SE_HUD_MIS_LOCK
22 SE_HUD_LOCK_ALART
23 SE_HUD_WP_EMPTY
24 SE_HUD_WP_CHARGED
25 SE_HUD_DMG_ALARM
26 SE_HUD_OH_ALART
27 SE_HUD_OVER_HEAT
28 SE_HUD_CALL
29 SE_HUD_EMERG_CALL
30 SE_HUD_MES_END
31 SE_HUD_MES_SHAT
32 SE_HUD_MIS_SEARCH
33 SE_HUD_INVALID
34 SE_HUD_MW_CHG
35 SE_HUD_TRG_CHG
36 SE_HUD_MOD_CHG
37 SE_HUD_RADIO_MENU
38 SE_HUD_RADIO_ORDER
39 SE_HUD_RADIO_CANSEL
40 SE_HUD_TM_USE
41 SE_HUD_TM_INP
42 SE_HUD_TM_COM
43 SE_HUD_TM_OK
44 SE_HUD_TM_ERROR
45 SE_HUD_RADER_MARKER
46 SE_HUD_GUIDE
47 SE_HUD_SUB_TARGET
48 SE_HUD_SP_CHARGE
49 SE_HUD_SP_LV_UP
50 SE_BR_PASS
51 SE_BR_SHAT
52 SE_BR_PROGRESS
53 SE_BR_DECO_1
54 SE_BR_FRAME
55 SE_BR_DECO_2
56 SE_BR_HELP
57 SE_BR_DISP
58 SE_BR_COWIN
59 SE_BR_WAIT
60 SE_BR_NEXT
61 SE_BR_FRIENDRY
62 SE_BR_ENEMY
63 SE_BR_MESS
64 SE_BR_POINT
65 SE_BR_MARK
66 SE_BR_OUT
67 SE_BR_MAIN_OP
68 SE_BR_MAIN_CLS
69 SE_BR_JUMP_OUT
70 SE_BR_JUMP_IN
71 SE_BR_PROMETEUS
72 SE_BR_PLANET_BROKEN
73 SE_HUD_SP_RETURN
74 SE_HUD_SP_ATACK
75 SE_HUD_SP_SLOW
76 SE_HUD_AAM_SITE_OPN
77 SE_HUD_AAM_SITE_CLS
78 SE_HUD_ASM_SITE_OPN
79 SE_HUD_ASM_SITE_CLS
81 SE_UI_MISSION_START
82 SE_UI_MISSION_UPDATE
83 SE_UI_MISSION_END
101 SE_SW_nbe01_1
102 SE_SW_nbe02_1
103 SE_SW_nbe03_1
104 SE_SW_nla01_1
105 SE_SW_nvu01_1
106 SE_SW_nvu02_1
107 SE_SW_nvu03_1
108 SE_SW_nvu04_1
109 SE_SW_nsh01_1
110 SE_SW_nsh02_1
111 SE_SW_nsh03_1
112 SE_SW_nsp01_1
113 SE_SW_nsp02_1
114 SE_SW_nvu01_2
115 SE_SW_nvu01_3
116 SE_SW_nvu02_2
117 SE_SW_nvu02_3
118 SE_SW_nvu02_4
119 SE_SW_nvu03_2
120 SE_SW_nvu03_3
121 SE_SW_nvu04_2
122 SE_SW_nla02_1
123 SE_SW_SPIT_1
201 SE_MW_wvu01_1
202 SE_MW_wvu02_1
203 SE_MW_wvu03_1
204 SE_MW_wvu04_1
205 SE_MW_wbe01_1
206 SE_MW_wbe02_1
207 SE_MW_whb01_1
208 SE_MW_whb02_1
209 SE_MW_wla01_1
210 SE_MW_wla02_1
211 SE_MW_wla03_1
212 SE_MW_wla04_1
213 SE_MW_wAA01_1
214 SE_MW_wAA02_1
215 SE_MW_wAA03_1
216 SE_MW_wAA04_1
217 SE_MW_wAA04_2
218 SE_MW_wAA05_1
219 SE_MW_wAA07_1
220 SE_MW_wAA0x_2
221 SE_MW_wro01_1
222 SE_MW_wro02_1
223 SE_MW_wro03_1
224 SE_MW_wro04_1
225 SE_MW_wAS01_1
226 SE_MW_wAS02_1
227 SE_MW_wAS03_1
228 SE_MW_wAS04_1
229 SE_MW_wAS05_1
230 SE_MW_wAS0x_2
231 SE_MW_wAS0x_3
232 SE_MW_wto01_1
233 SE_MW_wto01_2
234 SE_MW_wto02_1
235 SE_MW_wto02_2
236 SE_MW_wto03_1
237 SE_MW_wto03_2
238 SE_MW_wto04_1
239 SE_MW_wto04_2
240 SE_MW_wbo01_1
241 SE_MW_wbo02_1
242 SE_MW_wbo03_1
243 SE_MW_wbo04_1
244 SE_MW_wbo05_1
245 SE_MW_wbo06_1
246 SE_MW_wbo07_1
247 SE_MW_wbo08_1
248 SE_MW_wbo09_1
249 SE_MW_wKE01_1
250 SE_MW_wKE02_1
251 SE_MW_wKE03_1
252 SE_MW_wgu01_1
253 SE_MW_wgu02_1
254 SE_MW_wgu03_1
255 SE_MW_wgu04_1
256 SE_MW_wgu05_1
257 SE_MW_wgu05_2
258 SE_MW_wgu06_1
259 SE_MW_wgu07_1
260 SE_MW_wsp01_1
261 SE_MW_wsp01_2
262 SE_MW_wsp01_3
263 SE_MW_L3GP_1
264 SE_MW_THOR_1
301 SE_SPE_mcm01_1
302 SE_SPE_mcm02_1
304 SE_SPE_mpr01_1
351 SE_SWP_BEAM_1
352 SE_SWP_RAILGUN_1
353 SE_SWP_PCANNON_1
354 SE_SWP_LASER_1
355 SE_SWP_BVULCAN_1
356 SE_SWP_VULCAN_1
357 SE_SWP_AAM_1
358 SE_SWP_ASM_1
359 SE_SWP_BEAM_2
401 SE_EXT_OVER_HEAT
402 SE_EXT_OH_RECOVER
403 SE_EXT_EMPTY
404 SE_EXT_EJECT_H
405 SE_EXT_EJECT_M
406 SE_EXT_EJECT_L
407 SE_EXT_EJECT_FAE
408 SE_EXT_MGT_OPN
409 SE_EXT_MGT_CLS
410 SE_EXT_PHOTON_CHG
411 SE_EXT_GRAVITY_CHG
412 SE_EXT_RECHARGE
413 SE_EXT_RECHG_CMP
414 SE_EXT_DOUBLER
415 SE_EXT_SPIT_CHG
501 SE_DS_ENGINE
502 SE_DS_POWER_UP
503 SE_DS_AFTER_BURNER
504 SE_DS_REVERSE
505 SE_DS_COCKPIT
506 SE_DS_POWER_DOWN
507 SE_DS_AB_END
508 SE_DS_SIDE
509 SE_DS_ROLL
511 SE_ENG_ERAN
512 SE_ENG_ERAN_Q
513 SE_ENG_VINDI
514 SE_ENG_DELTA
515 SE_ENG_UNE
516 SE_ENG_ADAN
517 SE_ENG_SUPPLY
518 SE_ENG_ATK_S
519 SE_ENG_ATK_L
551 SE_ENG_ACRO
552 SE_ENG_JUN
553 SE_ENG_KUCHI
554 SE_ENG_OOGATA
555 SE_ENG_YUSOU
556 SE_ENG_BS
557 SE_ENG_AAF
558 SE_ENG_ASF
559 SE_ENG_CARRIER
560 SE_ENG_MISSILE
561 SE_ENG_SIDE_S
562 SE_ENG_SIDE_L
601 SE_EXP_CRAFT
602 SE_EXP_EX_CRAFT
603 SE_EXP_ATK
604 SE_EXP_OUT
605 SE_EXP_BS_PART_L
606 SE_EXP_BS_PART_M
607 SE_EXP_BS_PART_S
608 SE_EXP_BS_BREAK_LL
609 SE_EXP_BS_BREAK_L
610 SE_EXP_BS_BREAK_M
611 SE_EXP_BS_BREAK_S
612 SE_EXP_BS_BREAK_SS
613 SE_EXP_BS_OMEN
614 SE_EXP_BS_BOMB
615 SE_EXP_MISSILE
616 SE_EXP_TCAF_CRUISER
617 SE_EXP_MINE
651 SE_IMP_BM_DMG
652 SE_IMP_BM
653 SE_IMP_LS_DMG
654 SE_IMP_LS
655 SE_IMP_VULCAN
656 SE_IMP_SHOTGUN
657 SE_IMP_FIRE
658 SE_IMP_MISSILE
659 SE_IMP_ROCKET
660 SE_IMP_SMOKE
661 SE_IMP_TORPEDO
662 SE_IMP_BOMB
663 SE_IMP_KINETIC
681 SE_IMP_BULLET
683 SE_IMP_BS_BEAM
684 SE_IMP_BS_RGUN
685 SE_IMP_BS_PCANNON
686 SE_IMP_BS_LASER
687 SE_IMP_BS_BVULCAN
688 SE_IMP_BS_VULCAN
689 SE_IMP_BS_AAM
690 SE_IMP_BS_ASM
691 SE_IMP_GRV
701 SE_SHIELD_DOWN
702 SE_SHIELD_RECHARGE
703 SE_SHIELD_DOWN_BS
751 SE_BOSS_CRY
752 SE_BOSS_CLOSE
753 SE_BOSS_OPEN
754 SE_BOSS_STUMBLE
755 SE_BOSS_MOMENTS
756 SE_BOSS_ATK_A
758 SE_BOSS_ATK_C
759 SE_BOSS_LASER
760 SE_BOSS_HOMING
761 SE_BOSS_CHARGE
762 SE_BOSS_CORE_CHARGE
763 SE_BOSS_SPREAD
764 SE_CORE_COLLAPSE
781 SE_PROME_AMB_1
782 SE_PROME_AMB_2
783 SE_PROME_AMB_3
784 SE_PROME_FIRE
785 SE_ENV_MINE
786 SE_HNG_ARM_MV
787 SE_HNG_MOUNT
788 SE_HNG_UN_MOUNT
789 SE_HNG_ARM_UP
790 SE_HNG_ARM_DWN
791 SE_HNG_MOUNT_2
792 SE_HNG_UN_MOUNT_2
793 SE_HNG_MOUNT_3
794 SE_HNG_UN_MOUNT_3
795 SE_HNG_MOUNT_4
796 SE_HNG_UN_MOUNT_4
807 SE_ETC_JMP_OP
808 SE_ETC_JMP_IN
809 SE_ETC_JMP_OUT
810 SE_ENV_BS_M
811 SE_ENV_BS_S
812 SE_ENV_BS_L
813 SE_ENV_BS_LL
814 SE_ENV_CARRIER
815 SE_ETC_HP_UNE
816 SE_ETC_HP_ADAN
817 SE_ETC_HP_ACRO
818 SE_ENV_WIND
819 SE_ENV_WIND_TURN
820 SE_ETC_TURRET_L
821 SE_ETC_TURRET_M
822 SE_ETC_TURRET_S
823 SE_PASS_DS
824 SE_PASS_ADAN
825 SE_PASS_ERAN
826 SE_PASS_PLUS
827 SE_PASS_TYPEQ
828 SE_PASS_VINDI
829 SE_ETC_COLLISION
830 SE_ETC_SHIELD_DMG
900 SE_LFE_BOMB
901 SE_LFE_ENG

View File

@@ -0,0 +1,22 @@
# .xpr files whose `mesh info` decode exceeds 25 s (debug build), of 166 scanned
# 2026-08-29. Stage_S02 measured at 144 s to completion, rc=0 -- heavy, not hung.
Hangar.xpr
Stage_S02.xpr
Stage_S03.xpr
Stage_S04.xpr
Stage_S06.xpr
Stage_S07.xpr
Stage_S08.xpr
Stage_S09.xpr
Stage_S11.xpr
Stage_S12.xpr
Stage_S13.xpr
Stage_S15.xpr
Stage_S24.xpr
Stage_S25.xpr
Stage_S26.xpr
Stage_S27.xpr
Stage_S28.xpr
Stage_S29.xpr
ptc_pack.xpr

View File

@@ -0,0 +1,11 @@
# Measured splash phase timeline -- from docs/re/captures/ui-timing/splash-build-draws.log
# 2026-08-29. Frames are VdSwap counts; 1 frame = 1/30 s (2 units/frame, 1 unit = 1/60 s).
#
# phase frames n seconds start (rel. capture)
publisher: SQUARE ENIX logo 1-90 90 3.00+ 0.00 <- floor: already at a=255 on f1
(gap, nothing drawn) 91-93 3 0.10 3.00
developer: both glows 94-115 22 0.73 3.10
developer: both logos 116-211 96 3.20 3.83
#
# palogo_anima / palogo_anima_eff: 0 draws in the whole 214-frame window.
# The glow->logo switch is a single frame boundary (f115 -> f116), no overlap.

View File

@@ -0,0 +1,20 @@
CONTROL — the eff glow's fade-in must be linear at a constant step
alphas [34, 68, 102, 136, 170, 204, 238] steps [34, 34, 34, 34, 34, 34]
=> 2.000 units/frame; t(f) = 2*f - 171
check: declared hold ends t=45 -> predicted frame 108.0; observed last full-alpha frame 107
LOGO — palogo_gamearts, checked against that calibration
observed: first drawn frame 116 at alpha 255; full alpha through 198; fade-out 199..211
declared a= 0 at t= 15 -> frame 93.0 observed alpha NOT DRAWN
declared a= 0 at t= 30 -> frame 100.5 observed alpha NOT DRAWN
declared a=255 at t=190 -> frame 180.5 observed alpha 255
declared a=255 at t=194 -> frame 182.5 observed alpha 255
declared a=232 at t=206 -> frame 188.5 observed alpha 255
declared a= 32 at t=210 -> frame 190.5 observed alpha 255
SHAPE, independent of any calibration:
declared fade-out spans t=194..210 (16 units); of that,
12/16 units drop only 23/255 of the alpha (a near-flat leg),
4/16 units drop 200/255 (a cliff).
captured fade-out: [255, 254, 243, 237, 215, 181, 148, 131, 98, 65, 48, 23, 15, 7]
per-frame drops: [1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8] -> no near-flat leg

View File

@@ -0,0 +1,97 @@
GamePart_Title has TWO nested state fields.
OUTER -- sub_821C7850, the part's dispatcher
821c786c lwz r11, 16(r30) ; only runs when this+16 == 3
821c7870 cmpwi cr6, r11, 3
821c7874 bne cr6, <exit>
821c787c lwz r11, 132(r30) ; phase = this+0x84
821c7880 cmplwi cr6, r11, 0x4 ; five phases, 0..4
821c7888 lis/addi r12, 0x821C78A0 ; jump table
821c789c bctr
phase 0 -> sub_821C5690 (the splash -- independently identified as the
splash mechanics in the iterate3E notes)
phase 1 -> inline block at 0x821c790c
phase 2 -> sub_821C5818
phase 3 -> sub_821C5EC0
phase 4 -> sub_821C6458 (the title/menu machine, below)
All four call sites forward the SAME three arguments: (this, r29, r28),
where r28 is this function's own third argument -- an EVENT CODE passed
through unchanged from ITS caller.
INNER -- sub_821C6458, the title/menu machine (phase 4 only)
DISPATCH
821c6474 lwz r11, 136(r30) ; state = this+0x88
821c6478 cmplwi cr6, r11, 0x9 ; 10 states, 0..9
821c647c bgt cr6, 0x821C75B8 ; out of range -> default
821c6480 lis r12, 0x821C
821c6484 addi r12, r12, 25752 ; jump table at 0x821C6498
821c6488 slwi r0, r11, 2
821c648c lwzx r0, r12, r0
821c6490 mtctr r0
821c6494 bctr
NOTE: a disassembler decodes 0x821C6498..0x821C64BC as `lwz r16, N(r28)`
instructions. They are the jump TABLE's ten words, not code.
STATES
case 0 -> 0x821c64c0 installs "TITLE_SCREEN"
case 1 -> 0x821c65d0
case 2 -> 0x821c66e4 installs "TITLE_MENU"
case 3 -> 0x821c6b18
case 4 -> 0x821c6b5c nested switch, table at 0x821c6b7c (live: 0,3,5,8)
case 5 -> 0x821c6fd4
case 6 -> 0x821c7028
case 7 -> 0x821c72dc
case 8 -> 0x821c733c installs "LOADING"
case 9 -> 0x821c7558
TRANSITIONS -- every `li rX,N ; stw rX,136(r30)` in the function
from to at
0 1 0x821c6598
0 2 0x821c65c8
1 2 0x821c66a4
1 2 0x821c66dc
2 4 0x821c6afc
3 4 0x821c6b54
4 0 0x821c6e00
4 5 0x821c6e70
4 8 0x821c6ed0
4 8 0x821c6f20
4 8 0x821c6f7c
4 8 0x821c6fcc
5 6 0x821c7020
6 7 0x821c725c
6 9 0x821c72a8
6 2 0x821c72d4
7 9 0x821c7334
8 2 0x821c7548
(state 9 stores nothing -- terminal within this function)
THE CONDITION ON STATE 4's EDGES -- decoded
sub_821C6458(this, ?, r5) takes an EVENT CODE in its third argument.
State 4 -- the input-waiting state -- dispatches on it:
821c6b5c cmplwi cr6, r27, 0x19 ; 26 events, 0..25
821c6b60 bgt cr6, <default>
821c6b68 addi r12, r12, 27516 ; table at 0x821c6b7c
821c6b78 bctr
Only 6 of the 26 are handled; the other 20 fall through with no state change.
event 0 -> block 0x821c6be4 -> state 0 (TITLE_SCREEN -- back to the title)
event 3 -> block 0x821c6e78 -> state 8 (LOADING)
event 5 -> block 0x821c6f84 -> state 8 (LOADING)
event 8 -> block 0x821c6f34 -> state 8 (LOADING)
event 10 -> block 0x821c6e28 -> state 5
event 25 -> block 0x821c6ed8 -> state 8 (LOADING)
WHAT IS NOT DECODED
* what the event NUMBERS mean -- they are forwarded unchanged from
sub_821C7850's caller, so the vocabulary is defined at least one level up -- button id, menu-item id or message id;
* the conditions on edges out of states other than 4;
* what states 1, 3, 5, 6, 7 and 9 do (they install no named screen);
* state 3 is never a destination here, so something outside this function
sets it.

View File

@@ -0,0 +1,24 @@
# tools/re-capture/zero_scale_census.py -- 2026-08-28
elements with a keyframe group: 15493
at least one keyframe at scale 0: 2166
EVERY keyframe at scale 0 (would vanish): 0
zero is a transient the element grows out of: 1762
rest poses with scale 0%%: 195
... of which alpha > 0, so blit paints a 1-pixel sliver: 126
... alpha == 0, harmless: 69
by archive:
126 GP_READY_ROOM.pak
examples (pak, entry, element idx, name, sx, sy, alpha):
('GP_READY_ROOM.pak', 0, 2, 'pbacropolis.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 0, 6, 'pbFriendly.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 0, 10, 'pbFriendly.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 0, 14, 'pbFriendly.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 7, 2, 'pbacropolis.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 7, 6, 'pbFriendly.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 7, 10, 'pbFriendly.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 7, 14, 'pbFriendly.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 74, 2, 'pbr_enemy.t32', 0, 100, 255)
('GP_READY_ROOM.pak', 76, 2, 'pbr_enemy.t32', 0, 100, 255)

50
docs/re/doc-link-audit.md Normal file
View File

@@ -0,0 +1,50 @@
# ✅ Do the docs' cited artifacts exist? — 11 broken links repaired
**Status:** ✅ checked mechanically and fixed, with the count verified both ways.
The brief's rule is *"commit the reference data beside the finding, so the port
can be built without a disc in the loop"*. An answer whose evidence is not
reachable cannot be used. Nothing had ever checked that.
[`tools/re-capture/doc_link_check.py`](../../tools/re-capture/doc_link_check.py)
walks every markdown file under `docs/`, resolves each relative link, and reports
targets that do not exist — and, separately, targets that exist but are **empty**,
which is the sneakier failure since a zero-byte file looks fine in any listing.
| | before | after |
|---|---|---|
| links resolving | 1 038 | **1 049** |
| missing targets | **16** | 5 |
| empty targets | 0 | 0 |
**+11 resolving, 11 missing — the two numbers pair exactly**, which is the check
that the repair did what it claimed and nothing else.
## 🔴 Two of them were the evidence for the UI decode itself
`structures/ui-rat-layout.md` is the layout decode the port is built on. Its two
figures — the ones backing *"the tutorial PAUSE menu rebuilds pixel-accurately
from its sprites"* and *"the same method reproduces the main menu"* — were
written as `captures/ui-layout/…` from a file in `structures/`, one directory
too shallow. **The headline evidence for the decode was unreachable from its own
document.**
## What was wrong, and what still is
Eleven links had the **wrong relative depth** while their targets existed — a
missing or surplus `../`, or a missing `structures/`. Those are repaired; each
was rewritten only when exactly one candidate path resolved, so nothing was
guessed.
**Five remain genuinely absent** and are left alone rather than invented:
| doc | target |
|---|---|
| `autopilot-knowledge-sources.md`, `entities-live-roster.md` | `../../MEMORY.md` — outside the repo |
| `challenge-mission-gate.md` | a header in the separate `xenia-canary-native` tree |
| `stage-drift-is-navigation-not-save.md` | `structures/weapon-datasheet-runtime.md` — never written |
| `mission-freeze-resume-spin.md` | `canary-build-verified-env-confound.md` — never written |
None is port-relevant: they are mission, entity and emulator-side documents. Two
name documents that do not exist, which is a different problem from a bad path
and is not something to paper over with a link fix.

View File

@@ -79,7 +79,7 @@ looks stuck on the main menu while it is in fact three screens further on.
## Still open
* The **world-unit measurement** ([collisionset](collisionset.md)) — positions
* The **world-unit measurement** ([collisionset](structures/collisionset.md)) — positions
in world units against the HUD's own distance readout. This run reached the
mission but the flight HUD was not yet up (green 0.03 %, vs 1.31.5 % in
flight), so no distance readout was available to compare against.

View File

@@ -0,0 +1,63 @@
# ✅ The five screens, end to end — what each one is actually worth
**Status:** ✅ measured, 2026-08-29. One page, because the answers for these five
screens are otherwise spread across a dozen documents and none of them says how
good the result *is*.
Rendered with `sylpheed-cli screen render` (adding `--all --black` for the two
splash halves) and correlated against the committed live captures.
| screen | build | elements drawn | corr vs capture | alignment |
|---|---|---|---|---|
| title | 4 | 15 / 24 | **+0.9500** | dy=0 dx=0 |
| main menu | 5 | 11 / 16 | **+0.9460** | dy=0 dx=0 |
| `EXTRAS` | 6 | 13 / 18 | **+0.9440** | dy=0 dx=0 |
| publisher splash | 10 | 2 / 3 | **+0.9600** | dy=0 dx=0 |
| developer splash | 11 | 6 / 7 | **+0.9643** | dy=0 dx=0 |
Every one aligns at **exactly zero offset** — searched ±2 px in both axes — so
placement and scale are right and the residual is tone and detail, not geometry.
## Every omission is accounted for
The counts are not slack; each undrawn element has a reason already documented:
| screen | not drawn | why |
|---|---|---|
| title | `ptlogo1`×2, `ptlogo2`×2 | `kind & 0x4` **ghost instances** — the draw capture shows one quad per wordmark |
| | `pteff00.prm`, `pteff02.prm` | `.prm` primitives, off without `--primitives` |
| | `ptloop01.rat`, `ptloop02.rat` | `loop*` animations, off without `--animated`; at rest both sit off-screen |
| | `pteff04.t32` | the **`8AX` name mismatch** — the art *is* on screen, drawn via `ptbase2` ([8AX](structures/ui-8ax-fullres-background.md)) |
| main menu, `EXTRAS` | 2 `.prm` + 2 `loop*` + `pteff05.t32` | same three reasons |
| both splashes | `palogo_eff0.prm` | `.prm` primitive |
4 + 2 + 2 + 1 = 9 on the title; 2 + 2 + 1 = 5 on each menu; 1 on each splash.
**Nothing is unexplained.**
## What the residual is, per screen
Ranked by what a port would notice:
1. **Tone.** The capture is darker than the composite by a gamma of ≈1.341.49
([tone curve](structures/ui-render-tone-curve.md)) — the game's own display
ramp, not an emulator artefact. This is the largest single contributor to the
0.940.96 rather than higher.
2. **`8AX` resolution.** The background is drawn from the 640×360 `ptbase`
upscaled 2×, where the game uses the native 1280×720 `8AX`
([measured](structures/ui-8ax-fullres-background.md), two screens at 68 % of
ceiling).
3. **Paint-order ties on `EXTRAS` only.** One drawable overlapping tie
(`ptframe3`×`ptframe4`), and the capture is consistent with our order
([check](structures/ui-paint-order-derived-check.md)). The other four screens
use orders measured from the game.
4. **Rotation is decoded but not rendered** — and does not affect any of these
five at rest ([rotation](structures/ui-keyframe-rotation.md)).
## Reach
* These are **static composites at the resting pose** against **single frames**.
Nothing here says the animation is right; Q1's interpolation law is settled but
a multi-keyframe group's absolute timing is not
([time unit](ui-keyframe-time-unit.md)).
* Correlation on a whole frame is dominated by large areas. It is a sanity figure,
not a per-element check — the per-element work is in the documents linked above.

263
docs/re/menu-audio-cues.md Normal file
View File

@@ -0,0 +1,263 @@
# Menu audio — the event vocabulary is on the disc; the binding is not
**Status:**`CONFIRMED` and **decoded** for the cue vocabulary and the bank
that holds it. 🟡 the cue↔event binding is a **name match**, not a measurement.
❔ two negatives with their reach: no cue names a screen's music, and the audio
for an individual SE cue cannot yet be extracted.
Answers most of [MISSION Q8](../port/MISSION.md).
## ✅ The UI cue vocabulary — decoded
`dat/tables.pak`'s sound table has a `SOUNDS` record of 5 798 cues. 322 are `SE_*`
and the **low block is the user-interface vocabulary**, named by the game's
authors after the *events*, not after the sounds:
| id | cue | id | cue |
|---|---|---|---|
| 1 | `SE_UI_START` | 9 | `SE_UI_SUB_WIN_CLS` |
| **2** | **`SE_UI_CURSOR`** | 10 | `SE_UI_ALART_WIN` |
| **3** | **`SE_UI_DECIDE`** | 11 | `SE_UI_PAUSE` |
| **4** | **`SE_UI_CANSEL`** *(sic)* | 12 | `SE_UI_SPLASH_IN` |
| **5** | **`SE_UI_IMPOSI`** | 13 | `SE_UI_SPLASH_OUT` |
| 6 | `SE_UI_WAIT` | 14 | `SE_UI_LOAD_CMP` |
| 7 | `SE_UI_NEXT` | 17/18 | `SE_UI_WEAPON_PLAN` / `_CREATE` |
| 8 | `SE_UI_SUB_WIN_OPN` | 8183 | `SE_UI_MISSION_START` / `_UPDATE` / `_END` |
Full 322-cue list in [`data/se-ui-cues.txt`](data/se-ui-cues.txt). The remaining
blocks are gameplay: `SE_HUD_*` (2183), `SE_BR_*` briefing (5072), `SE_SW_*` /
`SE_MW_*` weapons, `SE_DS_*` engine, `SE_EXP_*` explosions, `SE_SHIELD_*`.
## ✅ They all live in one bank — decoded
The sound table's `BANK_SE` record is a **single field**: `Static.slb`. And the
disc-wide check agrees — **0 of the 322 `SE_*` cues has an entry in `FILES`**, the
5 135-path list where every voice, briefing and BGM bank is named. SE is the one
family that is not one-cue-one-file.
`SETTINGS` names the XACT project too: `PATH` = `game:\dat\sound.pak+`,
`PARAM` = `Pj_Silph.xgs`.
## 🟡 The binding: a name match, and I am calling it that
MISSION asks which cue fires on **move / confirm / back / error**. Read off the
names:
| event | cue |
|---|---|
| cursor moves | `SE_UI_CURSOR` (2) |
| Ⓐ confirm | `SE_UI_DECIDE` (3) |
| Ⓑ back | `SE_UI_CANSEL` (4) |
| invalid / error | `SE_UI_IMPOSI` (5) |
| submenu opens / closes | `SE_UI_SUB_WIN_OPN` / `_CLS` (8/9) |
| the splash | `SE_UI_SPLASH_IN` / `_OUT` (12/13) |
**Nobody has watched the game emit cue 2 on a d-pad press.** This is inference
from identifiers, and it belongs in the same box as Q4's GamePart ids — with one
honest difference worth stating: these are the *authors' own event names*, chosen
to describe when the sound plays, not asset labels I am interpreting. It is a
strong name match. It is still a name match, and the port is authoring it.
Measuring it needs the guest's cue-play call observed with its argument — the
same instrumentation Q4's residual wants, and not something this container can do
today (it runs `--mute=true` against an SDL dummy device, so there is no audio
path to watch either).
## ✅ Which BGM per screen — the TABLE cannot say, but the CODE can
All 32 BGM cues are named `BGM_001``BGM_109`, and nothing in `SOUNDS`, `FILES` or
the bank headers names a screen. That negative stands **for the tables**.
It does not stand for the executable. `GamePart_Title`'s phase handler
`sub_821C5580` contains `li r5, 1103` feeding a sound call — **cue 1103 =
`BGM_103`** — and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 bytes)
are byte-for-byte the two streams the XMA probe saw decoding at the main menu.
Three routes, one answer; see
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md).
So the per-screen BGM binding is recoverable **per screen, from the code that
plays it** — not from any table.
## ❔ And a new negative: an individual SE's audio is not extractable yet
`Static.slb` is **8 353 472 readable bytes** (declared 8 970 240 — exactly the
616 768 over-declaration the corpus already records), and it contains
**0 `RIFF`, 0 `seek`, 0 `WAVE`** — scanned over the whole buffer, so this is not
an offset problem. Every other bank on the disc is delimited by `seek` magic at
`data_at + declared_size` (7 620/7 620 in
[`slb-data-offset.md`](structures/slb-data-offset.md)); `Static.slb` is outside
that population. So the route that extracts every voice line and every BGM does
**not** give you `SE_UI_CURSOR`'s audio: the cue is named, the bank is named, and
the wave inside the bank is not locatable by any boundary marker.
**The named next step is `Pj_Silph.xgs`** — the XACT project file `SETTINGS`
points at, which in XACT is exactly where cue→wave-index lives. It **is** in
`sound.pak`: `name_hash("Pj_Silph.xgs")` resolves to TOC index 9454. ⚠️ Its entry
is 533 bytes at a 2048-aligned offset and carries no `XGSF` magic — and its region
has phase 1728, so those 533 bytes are very likely the *previous* bank's tail
rather than its own (the leading-region effect, [`slb-data-offset.md`]
(structures/slb-data-offset.md)). Locating its real bytes is the first job, not
parsing XACT.
## For the port
* the cue **names and ids** are disc facts — use them as the event vocabulary;
* which event fires which cue is **authored from the table above**;
* which BGM plays on which screen is **authored** — nothing on the disc says;
* and the UI **sound effects cannot be exported yet**. That is a gap in the
assets, not in the naming.
## ❔ `Pj_Silph.xgs` is a dead end — retracting the lead, with the reach
The section above named `Pj_Silph.xgs` as "the named next step", on the reasoning
that in XACT the cue→wave index lives in the project file. **That route is dead.**
| check | result |
|---|---|
| the entry's own bytes | 533 bytes, high entropy, **no `XGSF` magic** |
| ±8 KB around the entry in the flat concatenation | no `XGSF`, `SDBK`, `WBND`, `RIFF` or `seek` |
| **all of `sound.pak` — 1.08 GB across `.p00``.p04`** | **0 × `XGSF`, 0 × `SDBK`, 0 × `WBND`** |
| the executable | **0** occurrences of `XGSF`/`XACT`; no string matching `xact`, `.xgs`, `wavebank` or `soundbank` |
**Control, run first:** the same magic scan over `BGM_001.slb`'s neighbourhood
finds `RIFF` exactly where the bank structure says it should be. The scan works;
the magic genuinely is not there.
So the `.xgs` / `.slb` names are **inherited from the authoring tool, not from the
shipped format** — nothing on this disc is a parseable XACT container. There is no
XACT project file to read, and writing an XACT parser would have been wasted work.
### Where that leaves the SE audio: undecodable, with reach
Locating an individual SE cue's wave inside `Static.slb` is **not possible from
anything found on the disc**. Looked in:
* `Static.slb` itself — 8 353 472 readable bytes, **0 `RIFF` / 0 `seek` / 0 `WAVE`**,
where every one of the other 7 620 banks is delimited by `seek` magic at
`data_at + declared_size` ([`structures/slb-data-offset.md`](structures/slb-data-offset.md));
* the XACT project file — absent, as above;
* the sound table's five records — `SOUNDS` gives cue→id, `FILES` gives bank
paths, `BANK_SE` gives the one bank name, `SETTINGS` gives a path and the
(absent) project file, `STAGES` is empty. **No record carries an offset.**
**What does corroborate the binding:** `Static.slb` is 8 353 472 bytes, and at the
bitrates the disc uses elsewhere (25 69731 000 B/s) that is **269325 seconds of
audio — 0.84 to 1.01 s per cue over 322 cues.** Exactly the shape of a bank of
short UI/HUD/weapon effects, which is what `BANK_SE` says it is. The bank is the
right bank; only the index into it is missing.
**If it is ever picked up**, the route is the running game, not the disc: watch
the guest hand a wave offset/length to the decoder when a cue fires. That is the
same instrumentation the cue↔event binding needs, and the same one this container
cannot run.
## ✅ Retracting "cannot be extracted" — the waves are locatable, by playing them
The section above concluded that an individual SE cue's audio "cannot be
extracted". **That was too strong, and the tool to do it was already in this
build.** Canary here carries a cvar `xma_param_probe`, added for exactly this
purpose — its own comment says it logs each XMA stream's parameters and head
bytes "so raw sound.pak entries can be matched to real decode params".
Run with `--xma_param_probe=true`, drive the menu, and each newly-decoded stream
prints one line with its packet count, channel count, sample rate and **first 32
bytes**. Searching those bytes in `Static.slb` finds the wave.
| event | packets | bytes | format | offset in `Static.slb` |
|---|---|---|---|---|
| **d-pad move** (cursor) | 4 | 8 192 | mono 48 kHz | **`0x1ec0`** |
| **Ⓑ back** (cancel) | 2 | 4 096 | mono 48 kHz | **`0x0ec0`** |
| **Ⓐ confirm** (title → main menu) | 6 | 12 288 | mono 48 kHz | **`0x5d6c0`** |
Each head matched at **exactly one** offset. Reference data in
[`data/se-cue-runtime-offsets.txt`](data/se-cue-runtime-offsets.txt).
**Structurally this also cracks the bank's layout.** The first two are
*contiguous*`0x0ec0 + 4096 = 0x1ec0` — so `Static.slb` is a packed run of
XMA waves, each a whole number of 2 048-byte packets, with no delimiter between
them. That is why the `seek`-magic scan found nothing: there is nothing to find.
A wave is defined only by (offset, packet count), and those live in the game's
own tables, not in the bank.
⚠️ **The order is not cue-id order.** `Ⓑ`'s wave precedes the cursor's in the
file, while the name match puts `SE_UI_CURSOR` at id 2 and `SE_UI_CANSEL` at 4.
So the index cannot be derived by counting; it has to be observed per cue.
### What is now measured, and what is still a name match
***measured** — a d-pad move and a Ⓑ press each play a *specific, different*
wave, and both waves are located in `Static.slb`. The port can have the audio.
* 🟡 **still a name match** — that the cursor's wave *is the cue called*
`SE_UI_CURSOR`. The event→wave binding is measured; the event→*name* binding is
still read off the authors' identifiers.
***decoded to PCM** — see below. The cues play.
### ✅ The waves decode — artifact, not assertion
[`tools/re-capture/slb_extract_wave.py`](../../tools/re-capture/slb_extract_wave.py)
wraps a `(bank, offset, packet count, channels, rate)` slice in a synthesized
XMA1 `RIFF` for ffmpeg's `xma1` decoder:
```
slb_extract_wave.py Static.slb 0x1ec0 4 # -> Static_0x1ec0.riff -> 0.533 s
```
| event | offset | packets | decodes to | RMS | peak | envelope |
|---|---|---|---|---|---|---|
| d-pad move | `0x1ec0` | 4 | **0.533 s** | 2 085 | 29 813 | sharp attack, monotonic decay |
| Ⓑ back | `0x0ec0` | 2 | **0.344 s** | 2 985 | 16 973 | attack, peak in frame 2 |
| **Ⓐ confirm** | `0x5d6c0` | 6 | **1.016 s** | 4 327 | 32 767 | peaks in frame 3, long decay |
All three are mono 48 kHz, audible from sample 0, and have the percussive
attack-and-decay shape of UI blips — not silence, not noise.
**Control, run first:** the *same* synthesized wrapper applied to `BGM_001.slb`'s
first wave decodes to **173.808875 s** — identical to the duration obtained from
that bank's own on-disc `RIFF` header in
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md). The wrapper is not
approximately right; it reproduces a known-good decode exactly.
The bitrates are consistent too: 8 192 B / 0.533 s ≈ 15.4 kB/s, 4 096 / 0.344 ≈
11.9 kB/s, 12 288 / 1.016 ≈ 12.1 kB/s — mono at roughly half the stereo BGM rate.
🟡 **The decoded audio is deliberately not committed.** Three commands regenerate
it from the disc, and the corpus keeps measurements and tooling rather than
extracted game audio. The offsets, packet counts and this tool are the
deliverable.
## The three cues the milestone needs are bound — 2026-08-28, second run
A second, independent boot repeated the probe and pinned the one that was still
unattributed.
| event | wave | packets / bytes | runs agreeing |
|---|---|---|---|
| **d-pad move** | `Static.slb+0x1ec0` | 4 / 8 192 | **2 of 2** — identical head bytes |
| **Ⓑ back** | `Static.slb+0x0ec0` | 2 / 4 096 | **2 of 2** — identical head bytes |
| **Ⓐ confirm** | `Static.slb+0x5d6c0` | 6 / 12 288 | newly bound this run |
The `0x5d6c0` wave was previously logged as "played before any input", because
the first run only started counting at the main menu. Counting from the **title**
instead shows it appears exactly on the Ⓐ that advances title → main menu, along
with the two stereo BGM stems (the menu's music starting).
So move / confirm / back — everything the five-screen milestone needs — are
located and decodable.
🟡 **What that Ⓐ measurement does not separate.** The same press both *confirms*
and *opens a screen*, so this wave could be the cue the vocabulary calls
`SE_UI_DECIDE` or the one it calls `SE_UI_SUB_WIN_OPN`. The port needs *a* sound
on confirm and this is the one the game plays there; which name it carries is not
established.
## ❔ ⬅ and ➡ play nothing distinct
[`menu-navigation-semantics.md`](menu-navigation-semantics.md) measured that
left/right do not move the cursor. They also **fire no new XMA stream** — so
there is no distinct "invalid" cue on the main menu, and `SE_UI_IMPOSI` was not
observed. The port should leave left/right silent.
⚠️ **Reach of that negative:** the probe dedups on head bytes, so a press that
replayed an *already-heard* wave would log nothing. At that point in the run the
cursor, confirm and BGM waves had all been heard. So this excludes a **distinct**
invalid cue; it cannot exclude left/right quietly replaying one of those three.

View File

@@ -0,0 +1,136 @@
# The title menu — how it moves, and where each button goes
**Status:**`CONFIRMED` (**measured**, by driving the running game) for the
movement rules and for four of the five main-menu destinations. 🟡 the GamePart
*id* behind each destination is a **name match onto the decoded id table**, not a
measurement. ❔ `NEW GAME` deliberately untested.
Answers [MISSION Q5](../port/MISSION.md) and most of Q4. Nothing here is on the
disc in any form found so far — the port is **authoring** these rules from this
page, not transcribing a field.
## Q5 — movement
Two boots via `tools/re-capture/boot_menu.sh`, cursor read off the focus ring
with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py).
| | behaviour | evidence |
|---|---|---|
| **initial focus, main menu** | **`TUTORIAL`** — the *middle* item, not the top | 2/2 boots, the first frame after the menu appears |
| **initial focus, `EXTRAS`** | `MISSION SELECT` — the top item | [`extras-wrap.png`](captures/menu-nav/extras-wrap.png) |
| **up / down** | one item per press, no auto-repeat at the durations tried | |
| **wrap at the top** | ⬆ from the first item goes to the **last** | [`wrap-montage.png`](captures/menu-nav/wrap-montage.png), panels 1→2 |
| **wrap at the bottom** | ⬇ from the last item goes to the **first** | same, panels 3→4, and 4 presses from `EXTRAS` landing on `OPTIONS` — i.e. wrapping — is what makes the count come out |
| **left / right** | **nothing**, on the main menu | cursor unmoved across one ⬅ and one ➡ |
| **Ⓑ on a submenu** | returns to the parent **with focus restored to the item you entered from**`LOAD GAME``LOAD GAME`, `TUTORIAL``TUTORIAL`, `OPTIONS``OPTIONS`, `EXTRAS``EXTRAS` | 4/4 |
| **Ⓑ on the main menu** | goes to the **title**, which re-draws `PRESS Ⓐ BUTTON` after a beat | |
| **Ⓑ on the title** | **nothing** | |
Wrap holds on both screens tested — the 5-item main menu and the 3-item `EXTRAS`
submenu — so it is a menu rule, not a per-screen table.
**🟡 Initial focus is reproducible but not established as invariant.** Both of my
boots opened on `TUTORIAL`, and both used `boot_menu.sh`. The run recorded in
[`menu-state-in-memory.md`](menu-state-in-memory.md) reached `EXTRAS` with *four*
downs from the main menu, which only works from `NEW GAME`. Either the harness
path matters or something persists. **Do not hardcode `TUTORIAL` without
re-testing it**; what is solid is that the menu does *not* open on the top item
in this harness.
## Q4 — where each button goes
Measured by driving: focus the item, press Ⓐ, read the screen's own title.
| button | screen it opens | evidence | GamePart id |
|---|---|---|---|
| `NEW GAME` | ❔ **not tested** | — | — |
| `LOAD GAME` | the save-slot list, `LOAD GAME` / `Current Storage` | [`q4-destinations.png`](captures/menu-nav/q4-destinations.png) left | 🟡 `3 GP_LOAD` |
| `TUTORIAL` | the lesson list, `TUTORIAL`, Level 1 / Level 2 | same, middle | 🟡 `25 GP_TUTORIAL` |
| `OPTIONS` | `OPTIONS` — GAME / CONTROL / SOUND / SCREEN SETTINGS / BACK | same, right | 🟡 `8 GP_OPTIONS` |
| `EXTRAS` | **`GP_TITLE.pak` build 6** — MISSION SELECT / MOVIE THEATER / BACK | [`ui-title-build-map.md`](ui-title-build-map.md) | 🟡 `5 GP_EXTRAS` |
| `EXTRAS ▸ MISSION SELECT` | the stage list + Wide Area Space Map | | 🟡 `7 GP_MISSION_SELECT` |
| `EXTRAS ▸ MOVIE THEATER` | ❔ not tested | | 🟡 `6 GP_MOVIE_THEATER` |
**Say which, as the gate asks.** The *screen* each button opens is **measured**
I pressed the button and read the title off the framebuffer. The **GamePart id is
not measured**: it is the entry of the decoded 29-id table at `.rdata 0x820A1630`
([`challenge-mission-gate.md`](challenge-mission-gate.md) §3) whose *name* matches
the screen I saw. The table is decoded; the *binding* of a button to an entry in
it is a name match I made by eye. The port should treat these ids as authored.
Worth noting that the ids and the paks are not one-to-one: `GP_EXTRAS` is id 5
with **no pak of its own** — its artwork is a build inside `GP_TITLE.pak`.
**`NEW GAME` was deliberately not pressed.** Ⓐ on it leads to a standing
black-screen hang that ends the run
([`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md)),
and this iteration needed the session. It is the one destination still unmeasured.
### The cheap way to finish this, and to make it a measurement
`0x828A690C` holds a **live screen id**`1` title, `3` main menu, `4` extras —
and `0x828F38AC` the cursor ([`menu-state-in-memory.md`](menu-state-in-memory.md)).
Reading those while pressing Ⓐ turns "the screen said OPTIONS" into a measured
transition, and works under `--gpu=null` with no screenshots at all. ⚠️ Note that
those values are **not** GamePart ids — `GP_TITLE` is GamePart 0 and `GP_EXTRAS`
is 5, but the word reads 1 and 4 — so it is a third enumeration and mapping it to
the id table is itself unfinished work.
## ✅ `NEW GAME` — measured 2026-08-28, and it is not a hang
The one destination this page could not test has been driven. **Ⓐ on `NEW GAME`
does not hang.** It opens two more menus first:
```
NEW GAME -> DIFFICULTY -> SELECT DATA -> guest crash
```
* **`DIFFICULTY`** — `EASY` / `NORMAL` / `HARD` / `BACK`, footer
`Ⓐ : OK Ⓑ : Back`, opening focused on **`NORMAL`**
([capture](captures/newgame-path/newgame-difficulty.png)). It sat unchanged for
90 s with the emulator healthy — a menu waiting for input, which is exactly
what "a standing hang" looks like to a screenshot loop that never presses
anything.
* Ⓐ on `NORMAL` → **`SELECT DATA`**, a save-slot picker headed
`Current Storage: Dummy HDD`, prompting to pick a file for the auto-save.
* Then the guest throws, and Xenia pauses with `PC: 0x82307128`
([capture](captures/newgame-path/newgame-selectdata-crash.png)).
**That crash is already in the corpus and is not new**: `0x82307128` is inside
`sub_823070B0`, the cache-manager STL erase documented in
[`title-crash-stl-tree.md`](title-crash-stl-tree.md), whose trigger is an
incomplete on-disc shader/code cache — not the menu path. The corpus also already
holds `captures/select-data-crash.png`.
So Q4's last row closes as **measured**:
| button | screen it opens |
|---|---|
| `NEW GAME` | **`DIFFICULTY`** (then `SELECT DATA`) |
🟡 The GamePart ids for these two are unmeasured, like the rest — `24 GP_DIALOG`
and `3 GP_LOAD` / `2 GP_SELECT_STORAGE` are name-match candidates and nothing
more.
### Initial focus — a fourth data point, and it still varies
This boot opened the main menu on **`NEW GAME`**. Running tally across four
boots of the same harness: `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`.
Unchanged conclusion: **do not hardcode it.**
### The `NEW GAME` path completes — the `SELECT DATA` crash is state, not path
A second run of the same path, 2026-08-28, **did not crash**:
```
NEW GAME → DIFFICULTY → (Ⓐ on NORMAL) → SELECT DATA → (Ⓐ on a slot) → a movie plays
```
The previous run's throw at `PC 0x82307128` is therefore **not inherent to this
menu path** — the same six presses got through it. That is consistent with the
trigger `title-crash-stl-tree.md` already names (an incomplete on-disc cache) and
with nothing about `NEW GAME`. 🟡 n = 1 either way; do not read it as "fixed".
Worth recording because the first observation could easily have hardened into
"the new-game path crashes", which is what "A on NEW GAME hangs" had already
become once.

View File

@@ -264,7 +264,7 @@ and the boot never happens.
A Stage 02 run froze after ~4 minutes of flight (screen still `flight`, not GAME
OVER), and `gdb_bt.sh` took backtraces of all **79** threads
([`captures/stage02-freeze-gdb-backtraces.txt`](../captures/stage02-freeze-gdb-backtraces.txt)).
([`captures/stage02-freeze-gdb-backtraces.txt`](captures/stage02-freeze-gdb-backtraces.txt)).
**Every single one is in a wait.** Not one thread is executing guest code or
sitting in a xenia loop:
@@ -394,7 +394,7 @@ and the control are all in place; what is missing is one frozen sample.
The fourth run froze **9 seconds** into the watcher's window, in flight
(`freeze_watch.sh` confirmed the HUD was still on screen), and the probe built
for exactly this moment reported **the healthy-run baseline and nothing else**
([`captures/stage02-freeze-stuck-wait-probe.txt`](../captures/stage02-freeze-stuck-wait-probe.txt)):
([`captures/stage02-freeze-stuck-wait-probe.txt`](captures/stage02-freeze-stuck-wait-probe.txt)):
```
FROZEN IN FLIGHT at 9s

178
docs/re/movie-binding.md Normal file
View File

@@ -0,0 +1,178 @@
# Which movie plays where — the boot intro, the attract loop, the new-game intro
**Status:**`CONFIRMED`. The bindings are **decoded** from the movie manifest,
and the attract movie is independently **measured** from captured frames. One
part — whether playback is skippable — is 🟡 and the corpus contradicts itself
about it; that is written down rather than resolved by assertion.
Answers [MISSION Q9](../port/MISSION.md), and contributes to Q6.
## The manifest names the boot-side slots — decoded
The movie manifest (`dat/tables.pak` entry `0x5b983a08`, schema `0x067025b9`,
[`movie_manifest.rs`](../../crates/sylpheed-formats/src/movie_manifest.rs)) is an
array of slots whose **key is the semantic role**, with a parallel array of value
records in the same order. Its first eight slots are the whole boot-side flow:
| slot key | movie | also bound |
|---|---|---|
| `LOGO1``LOGO4` | `logo1.wmv``logo4.wmv` | — |
| **`ADVERTISE_MOVIE`** | **`ADV.wmv`** | `VOICE_ADV` |
| `STAFF_ROLL` | `SYLPH_HD720p_8M-CBR_2ch.wmv` | subtitle table |
| **`MS00A`** | **`S00A.wmv`** | `SUBTITLE_S00A`, `VOICE_S00A`, a `pwterop_s01a.prt` overlay |
| `MS01A` | `S01A.wmv` | `SUBTITLE_S01A`, `VOICE_S01A` |
The alignment is not in doubt here even though the manifest has valueless slots
elsewhere: `logo1..4` zip 1:1 onto `LOGO1..4`, and `MS00A``S00A.wmv`,
`MS01A``S01A.wmv` are the manifest's own anchor rule.
**The `logo1``logo4` slots are bound to `.wmv` files that are not on the disc**
already established, and the reason the developer splash is a screen, not a video.
## ✅ The boot intro and the attract movie are the same asset
There is **no separate boot-intro slot**. `ADV.wmv` is bound to
`ADVERTISE_MOVIE` — it *is* the advertise movie, and the boot simply plays it
first. For the port that means one video, not two.
### Measured independently, from captured frames
Frames captured 5 s apart during an attract cycle were matched against five
candidate movies by frame signature (32×18 normalised grayscale, movie frames
cropped to the 675 of 720 rows the game surface shows, 1 fps sampling).
`tools/re-capture/frame_match.py`; full table in
[`data/attract-frame-match.txt`](data/attract-frame-match.txt).
**15 of 19 attract frames matched `ADV.wmv`, and the matched timestamps advance
monotonically at the sampling rate** — 39, 45, 56, 62, 75, 80, 85, 92, 102, 108,
113, 119, 131, 137 s — ending at 137 s, which is `ADV.wmv`'s full length, after
which the title returned. That is not a similarity score, it is a playhead.
**The control was run first and behaves the same way.** Frames captured during
the *boot* movie — known to be `ADV.wmv` — matched `ADV` at 116, 121, 129, 134 s.
Its failure mode is worth stating: on near-black frames the correlation collapses
(one control frame scored 0.000, another tied `S13A` 0.984 against `ADV` 0.926).
Those are exactly the 4 attract frames that did not match. **A dark frame carries
no signature; it is not evidence for the runner-up.**
### 🔴 This corrects an earlier claim of mine
Two iterations ago I recorded the attract movie as "≈85 s, so probably not
`ADV.wmv`'s 137 s". That was wrong, and the mistake was arithmetic on an
unobserved start: my sampling began when the movie was already **39 s in**, so
what I timed was the *tail* of it, not the whole thing.
## ✅ The new-game intro is `S00A.wmv`
Slot `MS00A` — the prologue story intro, 93.87 s, with its own subtitle table,
`VOICE_S00A`, and a text overlay. Decoded from the manifest, **not** measured:
Ⓐ on `NEW GAME` hangs the emulator
([`menu-navigation-semantics.md`](menu-navigation-semantics.md)), so this
binding has not been watched happening.
## 🟡 Skippable — the corpus contradicts itself, and I did not settle it
* [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) states
plainly that "Ⓐ during a movie skips the movie, every time".
* But `boot_menu.sh` / `skip_intro.sh` as they stand **deliberately do not tap
during a movie**, logging `movie … -> waiting it out (tapping breaks the
title)`, and the same document records run G: 88 Ⓐ presses through the boot
left a permanent black screen.
Both cannot be the whole story. **The test**: boot, tap Ⓐ exactly once well
inside the movie, and record whether the title arrives early. One boot, and it
was not run this iteration.
## ✅ What ends the attract movie
Nothing intervenes: it **plays to its end**. The last matched frame is `ADV` at
137 s — the file's full duration — and the title is back on the next sample. So
the attract cycle is: title, idle ~810 s, fade to black, `ADV.wmv` in full, back
to the title (which redraws `PRESS Ⓐ BUTTON`).
## What this contributes to Q6
The manifest is **data the game reads** to sequence the boot side: the slot keys
are in play order and the key *is* the role. That is not the whole driver — it
says what plays, not what decides to advance — but it is the first file-side
piece of Q6's second half, and it means the boot-side asset order does not have
to be authored.
## ✅ Skippable — settled 2026-08-28: **one Ⓐ skips the movie**
The test named above was run, and the answer is unambiguous.
| | |
|---|---|
| baseline: title arrives with **no** input | ~193 s, ~196 s, ~193 s (three boots) |
| **one Ⓐ tapped at ~45 s into the boot** | **title at ~57 s** |
The press is provably the cause and provably singular: Canary's own
`[RE-INPUT] XamInputGetKeystrokeEx reached the driver` counter went **3 → 4**
across the tap, so exactly one keystroke was delivered, and the title arrived
~12 s later instead of ~150 s later.
**And the skipped-to title is fully functional**, which is the part that matters
for the harness folklore: it draws the `PRESS Ⓐ BUTTON` plate (2 408 plate pixels
by the same probe used elsewhere), and a second Ⓐ opened the main menu normally
([`title-after-single-A-skip.png`](captures/newgame-path/title-after-single-A-skip.png)).
### So what is `skip_intro.sh` protecting against?
Not a single tap. The failure recorded in
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) is run G —
**88 presses at 4 s intervals through the whole boot** — which left a permanent
black screen. Hammering breaks it; one press does exactly what the button is for.
The scripts' `waiting it out (tapping breaks the title)` comment is **too broad**,
and it costs every scripted boot ~2.5 minutes.
## ✅ `S00A.wmv` watched — measured 2026-08-28
The manifest's `MS00A → S00A.wmv` has now been confirmed against the running
game. The first attempt, with 5-second screenshots, gave an ambiguous partial
playhead; re-run with **`ffmpeg x11grab` at 10 fps** and matched every 0.5 s, it
is unambiguous:
| capture t | `S00A` score @ playhead | `ADV` (runner-up) |
|---|---|---|
| 5.0 s | **0.998** @ 1.0 s | 0.787 @ 126.5 s |
| 6.0 s | **0.999** @ 1.5 s | 0.820 @ 126.5 s |
| 7.5 s | **1.000** @ 3.0 s | 0.836 @ 126.5 s |
| 9.0 s | **0.999** @ 4.5 s | 0.823 @ 126.5 s |
| 12.0 s | **0.998** @ 7.0 s | 0.985 @ 34.5 s |
| 16.0 s | **0.999** @ 10.0 s | 0.886 @ 37.5 s |
| 17.5 s | **0.967** @ 11.0 s | 0.460 @ 87.0 s |
Across the 25 consecutive samples from capture 5.0 s to 17.5 s the `S00A`
playhead is **strictly non-decreasing** — 1.0, 1.5, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0,
4.5, 5.0, 5.5, 5.5, 6.0, 6.5, 7.0, 7.0, 7.5, 8.0, 8.5, 9.0, 9.0, 9.5, 10.0, 10.5,
11.0 — advancing at essentially real time, with scores at or above 0.96. **`S00A`
is the top match on 23 of the 27 frames that carry signal** (contrast > 35;
the other 25 of 52 frames are the transition and are correctly ignored).
So the new-game intro is `S00A.wmv`: **decoded** from the manifest and now
**measured** off the game. It begins ~4.5 s after Ⓐ on the save slot.
### 🟡 An aside worth recording: `ADV.wmv` reuses `S00A` footage
In the window `S00A` 5.510 s, `ADV` also scores 0.970.99 — at its *own*
monotonically advancing playhead, 33.537.5 s. Two different movies both matching
strongly with two consistent playheads is not noise; it reads as **the boot movie
being a trailer cut from the story cutscenes**, `S00A` among them. That would
explain why the sparse first attempt kept flipping between them. Not investigated
further.
### ⚠️ Sparse sampling was the whole problem
The earlier attempt sampled every 5 s and produced contrast 2337 frames, a
playhead that would not join up, and one high-contrast frame preferring `ADV`. The
same question at 0.5 s spacing answers itself at 0.999. **The movie was never
ambiguous; the sampling was.**
### The run was killed before the mission load
An earlier run of this path disappeared at ~145 s with **no crash line in its own
log** — routine `MEM-WATCH rss=1153MB` to the end — so an external kill, not a
guest fault, and the second this session. The `DELTASABER` plates of
[`ui-title-build-map.md`](ui-title-build-map.md) need a mission load and were not
reached.

View File

@@ -0,0 +1,90 @@
# S1 — the Ready Room probe: **no-go**, and not for the reason expected
**Status:** one iteration, as [MISSION §S1](../port/MISSION.md) specifies. This is
the written go/no-go. No Ready Room work has been started.
## Recommendation: no-go
Not because it is 3D — **it is not** — and not because the names are
unrecoverable. Because the probe found that **`GP_READY_ROOM.pak` does not appear
to contain the Ready Room's own menu screen at all**, so the question the mission
framed ("only 6 of 1 106 names resolve, is that a week or a quarter?") is aimed at
the wrong target. And it is outside the five-screen milestone regardless.
## What the probe measured
### ✅ It is 2D. This is not a 3D room with a UI overlay.
The corpus already holds a framebuffer capture of the running screen,
[`captures/ready-room-extra-mode.png`](captures/ready-room-extra-mode.png): a flat
menu — a `READY ROOM` wordmark, six labelled items (`TAKE OFF`, `BRIEFINGS`,
`HANGAR`, `PILOT LOG`, `OPTIONS`, `MISSION CHANGE`), a focus ring, and an
`Ⓐ : OK / Ⓑ : Mission change` footer — in exactly the visual language of the
title menus. There is no model viewport. The pak's own entry list carries **zero
3D containers** (no `XPR2`, no `XBG7`).
So the mission's own no-go-by-definition criterion is **not** met. That part is a
go.
### ✅ Enumeration by content works — 60 builds
`sylpheed-cli screen list` finds **60 screen builds**. The 1 106 entries break
down as **902 RATC bundles, 192 IDXD objects, 6 `ttf`, 6 `LSTA`**. The worry that
unresolvable *paths* would mean unrenderable *screens* is unfounded here.
### 🔴 But none of it is the Ready Room menu
Two independent signals, and they agree.
**Rendered composites are briefing panels.** Four of the 60 builds — the largest
and most element-rich (18, 26, 44 and 9 elements) — composite to a planet
information panel, unit markers and direction arrows:
[`ready-room-pak-builds.png`](captures/ready-room-probe/ready-room-pak-builds.png).
Tactical-map material, not a six-item menu. ⚠️ **4 of 60 sampled** — this alone
does not close it.
**The name census does close it much harder.** The 902 bundles declare **317
distinct element names**, all under a `pb*` prefix family:
`pbfriendly` (330), `pbacropolis` (168), `pbrhino` (48), `pbsub` (204),
`pbtab` (48), `pbcm` (132)… Searching all 317 for any of the six visible labels —
`takeoff`, `take_off`, `brief`, `hangar`, `pilot`, `option`, `mission`, `ready`
returns **nothing**. The only `main` hits are `pbmain_btn1..3.t32`,
`pbmain_window.t32` and their `_eff` glows, which appear in 9- and 4-element
*fragments*, not a screen, and are declared **kind 0** (decoration).
Read together: this pak is the **briefing / tactical-map** content that sits
*behind* the Ready Room's `BRIEFINGS` item, and the Ready Room's own menu lives
somewhere else. Note the GamePart table has both `10 GP_BUNK` and
`11 GP_READY_ROOM` ([`challenge-mission-gate.md`](challenge-mission-gate.md) §3),
and `GP_BUNK.pak` is a separate archive that was not examined here.
### 🔴 And a caveat that reaches back into the delivered corpus
**`kind == 0x3002` finds zero buttons in this pak.** All 902 bundles, 0 hits —
while `GP_TITLE` has 16. What `GP_READY_ROOM` uses instead:
| kind | count |
|---|---|
| `0x3000` | 491 |
| `0x3004` | 406 |
| `0x300c` | 135 |
| `0x3008` | 60 |
| `0x3001` | 6 |
| `0x3002` | **0** |
So `0x3002` is one member of a `0x3000` **family** with sub-bits, and "element
kind `0x3002` is a button" — a ✅ row in [`HANDOFF.md`](../port/HANDOFF.md) — is
sound for the title screens and **must not be applied as an equality test
elsewhere**. For the five-screen milestone nothing changes; for anything beyond
it, test the family.
*(The kind field is the 4th `u32` of the 60-byte declaration entry, at `+40`.
Calibrated on `GP_TITLE` build 6, where it reads `0x3002` for exactly the three
`EXTRAS` buttons and `0x0` for the decorations.)*
## If it is ever picked up
The first job is **not** naming this pak's keys. It is finding where the Ready
Room's six-button screen actually lives — `GP_BUNK.pak` is the obvious first
place to look, and the same content-based `screen list` / name-census pass costs
one iteration there.

View File

@@ -0,0 +1,117 @@
# What happens between two screens — a fade through black, and where its timing lives
**Status:**`CONFIRMED`. The quad and its ramp are **decoded** (a keyframe
group on the disc, with a disc-wide check); the wall-clock timings are
**measured** off the running game at 30 fps. One piece is **undecodable from this
field** and is called out below.
Answers [MISSION Q7](../port/MISSION.md).
## The mechanism — decoded
Every title-side screen carries a full-screen untextured primitive that is
**black, and paints last**: `pteff00.prm` in `GP_TITLE`, `pfeff00.prm` in
`GP_SAVE_LOAD`. That it sorts last was already established
([`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md)); what is
new here is that its **keyframe group is the transition**.
The group is always four blocks, and always this shape:
| block | alpha | meaning |
|---|---|---|
| 1 | `0xff` at `t = T0` | the screen starts **black** |
| 2 | `0x00` at `t = T1` | ramp to fully clear — the screen **fades in** |
| 3 | `0x00` at `t = T2` | clear; this is the resting pose, the quad is invisible |
| 4 | `0xff`, **no time** | ramp back to black — the screen **fades out** on exit |
Read with the corpus's rule that a keyframe is the *start* of a ramp
([`structures/ui-resting-pose.md`](structures/ui-resting-pose.md)).
```
$ tools/re-capture/fade_quads.py 4 5 6 # GP_TITLE
build 4 (title) pteff00.prm t=16 α=255 t=261 α=0 t=269 α=0 t=— α=255
build 5 (main menu) pteff00.prm t=12 α=255 t= 70 α=0 t= 80 α=0 t=— α=255
build 6 (EXTRAS) pteff00.prm t=12 α=255 t= 64 α=0 t= 74 α=0 t=— α=255
```
Under [Q1](ui-keyframe-time-unit.md)'s `1 unit = 1/60 s`: the screen holds black
for **0.20 s**, then fades in over **0.87 s** (`EXTRAS`), **0.97 s** (main menu)
or **4.08 s** (the title).
### ❔ The fade-OUT duration is not in this field
The fourth block has **no time** — a group's last block stops 4 bytes short and
that word is already the next group's element index
([`ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs) documents the
packing). So the disc gives the ramp's *target* (black) and not its length. That
duration is **measured** below, and the port is authoring it.
### The disc-wide check, and what it shows about overlays
Over every `GP_*.pak`, counting bundles that have a `.prm` element **and** ≥8
elements (screen-sized rather than a two-element fragment):
> **40 of 97** carry a 255 → 0 → 255 quad; 56 of the 60 found disc-wide have
> exactly 4 keyframes.
41 % sounds weak until it is read per pak, where it is nearly all-or-nothing:
| pak | with quad / screen-sized |
|---|---|
| `GP_MISSION_SELECT`, `GP_MOVIE_THEATER`, `GP_OPTIONS`, `GP_TUTORIAL`, `GP_SYSTEM` | 2/2 each |
| `GP_BUNK`, `GP_CHALLENGE` | 6/6 |
| `GP_STAGE_CLEAR` | 4/4 |
| `GP_SAVE_LOAD` | 10/12 |
| **`GP_TITLE`** | **6/12** |
| `GP_DIALOG` | 0/133 |
| `GP_DEBRIEFING_PILOTLOG` | 4/102 |
**`GP_TITLE`'s 6 of 12 is the interesting row, and it is not a gap.** The six
that carry the quad are exactly the six *screen* builds — title, main menu and
`EXTRAS`, English and Japanese. The six that do not are exactly the six
**overlays**: the `PRESS Ⓐ BUTTON` plate and the two `DELTASABER` plates
([`ui-title-build-map.md`](ui-title-build-map.md)). An overlay composited onto a
screen has no transition of its own, so it has no fade quad — which is
independent corroboration that those builds are overlays rather than screens.
`GP_DIALOG`'s 0/133 says the same thing about dialog boxes.
## The timing — measured
Recorded with `ffmpeg -f x11grab -framerate 30` over the game surface, mean
frame luminance per frame; raw data in
[`captures/transitions/transition-luminance.csv`](captures/transitions/transition-luminance.csv),
filmstrip in
[`transition-filmstrip.png`](captures/transitions/transition-filmstrip.png).
| | main menu → `EXTRAS` (Ⓐ) | `EXTRAS` → main menu (Ⓑ) |
|---|---|---|
| press → first visible change | 0.07 s | 0.37 s |
| **fade-out to black** | **0.367 s** | **0.400 s** |
| **pure black** (luminance 0.02) | 0.233 s | 0.167 s |
| luminance rise until settled | 0.567 s | 1.467 s |
The fade-out is the number the file cannot give, and it comes out the same both
ways: **~0.4 s**, i.e. ~24 units under Q1's rule.
The black hold is *consistent with* the file's 12 units (0.20 s) but does not
confirm it — the plateau spans the tail of the outgoing screen's fade-out and the
head of the incoming screen's black, and this measurement cannot separate them.
### ⚠️ The luminance rise is **not** the quad's ramp
The two columns differ by 2.6× where the quad's declared ramps differ by only
1.12× (58 units vs 52). The filmstrip says why: the background reappears *first*
and the labels arrive after it, so what the luminance curve is timing is the
incoming screen's **own element animations**, not the fade quad. Quoting 1.47 s
as "the main menu's fade" would be wrong. The quad's ramp is decoded; the
screen's build-in is a separate, longer thing.
## For the port
* a screen change is: **fade the outgoing screen to black over ~0.4 s**, hold
black briefly, then **fade the incoming screen in over its own declared ramp**
while its elements play their own keyframes;
* the fade-in ramp is **read from the file** (`T1 T0` on the screen's fade
quad);
* the ~0.4 s fade-out and the black hold are **authored from this page** — the
disc does not carry them.

View File

@@ -146,7 +146,7 @@ the encoding: **1 = not yet deployed, 2 = active, 4 = destroyed**.
### ✅ The mission-over branch, observed exactly as disassembled
The phase ended at 694.9 s, but **the ordinal did not advance** — and the reason
is the branch [mission-phase-advance](../mission-phase-advance.md) read out of
is the branch [mission-phase-advance](mission-phase-advance.md) read out of
`sub_82260710`:
```

View File

@@ -0,0 +1,153 @@
# A music bank is two stems of one performance, played together
**Status:**`CONFIRMED` for the structure — **decoded**, with a disc-wide check
over all 32 BGM banks. 🟡 for the *role* of the second wave — **measured** by
signal analysis, and the two readings that survive are named below. ❔ which bank
is the menu's music is **undecodable from the disc**, with the reach stated.
Answers [MISSION Q10](../../port/MISSION.md). It also **withdraws the premise**: the
handoff said `BGM_001.slb` is *three* sub-waves of 10 KB / 4.47 MB / 4.67 MB. The
10 KB is the bank **header**, not a wave.
## The structure — decoded
```
BGM_001.slb (9 178 040 B)
0 BANK id=1001 data_size=9175992 hdr = 5 blocks (10240 B) 16bit/2ch
10240 RIFF data@14336 size=4466688 (2181 packets) 25697 B/s → 173.82 s
4481024 seek 2181 packets
4491264 RIFF data@4495360 size=4673536 (2282 packets) 26887 B/s → 173.82 s
9168896 seek 2282 packets
```
A bank is a 10 240-byte header and then **exactly two waves**, and the two always
have the **same duration** — different byte sizes and different bitrates, same
number of seconds. Duration is `data_size / PsuedoBytesPerSec` (the u32 at
`RIFF+0x20`; `RIFF+0x24` is the sample rate, 48 000 Hz except `BGM_020``023`
at 44 100).
### Disc-wide
`tools/re-capture/bgm_wave_census.py`, full output in
[`../data/bgm-wave-census.txt`](../data/bgm-wave-census.txt):
> **28 of the 32 BGM entries** read straight off their own pak entry as two waves
> of equal duration — equal to 0.01 s, over lengths from 37 s to 277 s.
The other four — `BGM_106``BGM_109` — are **not counterexamples**, they are the
known leading-region straddle ([`slb-data-offset.md`](slb-data-offset.md)): those
entry windows start mid-bank, so an entry contains the tail of the previous bank,
one whole wave of its own, and the `BANK` header of the *next* one.
`slb_segment_phase.py bank` shows it directly — the entry named `BGM_107.slb`
contains `BANK id=1108`. Realigning across the boundaries with the `seek` packet
counts, which pin each join exactly (bank 1107: a `seek` for 1164 packets and a
2 383 872-byte wave = 1164 packets), restores the same shape:
| bank | wave 0 | wave 1 |
|---|---|---|
| 1107 | 2 383 872 B → 65.18 s | 2 418 688 B → 65.18 s |
| 1108 | 3 696 640 B → 128.91 s | 3 696 640 B → 128.91 s |
| 1109 | 2 887 680 B → 81.50 s | 2 932 736 B → 81.50 s |
Bank 1106's wave 0 begins in unclaimed bytes before its entry and is known only
by its `seek` count (1 718 packets), so it is 🟡 rather than measured.
**So: intro + loop is dead, and so is two halves of one piece.** Both would give
unequal durations; 32 banks give equal ones.
## The role of the second wave — measured
Decoded `BGM_001`'s two waves to PCM and compared them.
| | wave 0 | wave 1 |
|---|---|---|
| duration | 173.809 s | 173.809 s |
| first sound / last sound | 0.001 s / **167.663 s** | 0.003 s / **167.663 s** |
| RMS | 6 824 | 3 961 |
| L↔R correlation | 0.61 | **0.14** |
| energy below 200 Hz | 16.5 % | **6.7 %** |
| energy 14 kHz | 31.1 % | 42.5 % |
**They are sample-synchronous.** Transient-envelope cross-correlation searched
over ±5 s peaks at **lag +0.00 s** (0.5624), and both waves stop at the *same
millisecond*, 167.663 s. Two takes, two halves or two alternates would not do
that. They are two stems of one performance and are meant to sound **at the same
time**.
**Therefore concatenating them is wrong**, and not subtly: it plays the piece
twice, the second time as a bass-less secondary stem.
### 🟡 Which kind of second stem — two readings survive
* **The rear pair of a 4-channel mix.** Fits every number: quieter, far more
decorrelated between L and R, and with the bass managed away to the front pair.
* **A second intensity layer** to be mixed in or crossfaded.
**Not settled, and the obvious field does not settle it.** `ChannelMask` is
`0x0002` on *both* waves, and [`sound-slb.md`](sound-slb.md) already records that
this game writes meaningless channel metadata (movie voices declare 2 channels
over mono content). What would settle it is a runtime observation — whether the
game submits both waves to the mixer at once — which needs an emulator with audio
this container does not have.
## ❔ Which bank is the menu's music — undecodable, and here is the reach
The cue table binds cue names to sound ids and banks
([`sound-cue-table.md`](sound-cue-table.md)), and its **32 BGM cues are named
`BGM_001``BGM_109`** — pure numbers, no `TITLE`, no `MENU`, no semantic name
anywhere. Looked in: the `SOUNDS` record (5 798 cues), `FILES` (5 135 bank
paths), and the bank headers themselves (a `BANK id` that simply repeats the cue
number, `id=1001` for `BGM_001`). Nothing names a screen.
So the menu↔bank binding has to come from the running game — the same route Q8's
event bindings need — and until then the port is **choosing** a track, not
transcribing one.
## What the port needs to know
* a music bank is **two waves that play together**, not one track and not a
sequence — do not concatenate;
* both are full length; align them at sample 0;
* **the track is not a seamless loop.** `BGM_001` fades out and is followed by
**6.15 s of silence** (last sound 167.663 s of 173.809 s; the final second
before silence is at RMS 168 against 4 788 at the head). Looping the wave as
stored gives a fade-out and six seconds of nothing every cycle. No loop-point
field has been identified in the XMA header, so a menu loop is **authored**.
## ✅ Confirmed at runtime — two stereo streams decode at once
The page above said the "played together" reading needed "a runtime observation
of whether the game submits both waves to the mixer at once", and that this
container could not do it. It can: `--xma_param_probe=true` logs every XMA stream
the decoder is handed.
Sitting on the **main menu**, two stereo 48 kHz streams were decoding
simultaneously — **1 893 packets / 3 876 864 B** and **1 919 packets /
3 930 112 B**. Two stereo streams alive at the same moment on a screen with one
piece of music playing is the direct observation this page was missing: **a music
bank's two waves are simultaneous, not sequential.** Concatenating them is wrong,
now measured as well as inferred.
🟡 It still does **not** separate the two readings — surround-rear pair versus a
second intensity layer — because both predict simultaneity.
## ✅ The menu's music is `BGM_103` — three independent routes agree
An earlier version of this section said the two observed byte sizes "do not match
any bank's declared wave sizes". **That was wrong** — I had checked only the
`BGM_0xx` rows. They match `BGM_103` exactly, and the code names it:
| route | evidence |
|---|---|
| **static, code** | `GamePart_Title`'s phase handler `sub_821C5580` does `li r5, 1103` into a sound call (`sub_8217ACF8`, with `r4 = 4`) — **cue 1103 = `BGM_103`** |
| **static, disc** | [`../data/bgm-wave-census.txt`](../data/bgm-wave-census.txt): `BGM_103.slb` = two waves of **3 876 864** and **3 930 112** bytes, 87.75 s each |
| **runtime** | the XMA probe at the **main menu** saw two stereo 48 kHz streams decoding simultaneously, of **3 876 864** and **3 930 112** bytes |
Byte-for-byte on both waves. So:
* **which bank the menu plays is decoded** — `BGM_103`, from the executable, since
the cue *table*'s BGM entries are numeric and name no screen;
* and the two-stems-play-together finding is now confirmed **on a named bank**,
with the runtime stream sizes equal to the bank's declared wave sizes — so the
game hands the decoder the whole wave, not a window, which is the opposite of
what the earlier note guessed.

View File

@@ -82,7 +82,7 @@ names had no text to resolve *to*, and the 100 % figure was unreachable.
* **Which recording plays.** The caption is the *text*; the voice bank binding
is a separate and still-unresolved question — see
[voice-bank-leading-region.md](voice-bank-leading-region.md) and the
[voice-bank-leading-region.md](../voice-bank-leading-region.md) and the
known case of a generic line playing against a specific subtitle.
* **Which page a `MSG_DEMO_*` id belongs to.** The `DEMO` family is not called
from the stage scripts at all — 78 of its ids are multi-page, so something

View File

@@ -270,7 +270,7 @@ result of the two.
## ✅ 2026-08-23 (third pass) — the counter is at `0xbdb59668` again, and the refutation above is *refined*, not reversed
Run 4, with the hunt automated end to end
([`ob_hunt.py`](../../tools/re-capture/ob_hunt.py) + the HUD reader), produced
([`ob_hunt.py`](../../../tools/re-capture/ob_hunt.py) + the HUD reader), produced
**exactly one** surviving address:
```
@@ -322,7 +322,7 @@ With the per-entity searches refuted at both word and bit level
counter. It sits at `0xbdb59668`, inside the entity-heap window, so the answer is
readable directly: sample ±0x200 around it across one transition and keep the
words that move **with** it
([`ob_neighbourhood.py`](../../tools/re-capture/ob_neighbourhood.py),
([`ob_neighbourhood.py`](../../../tools/re-capture/ob_neighbourhood.py),
[`captures/ob-counter-neighbourhood-stage02.json`](../captures/ob-counter-neighbourhood-stage02.json)).
Control first: over an 8-second interval while the counter sat still, **0 of the

View File

@@ -40,7 +40,7 @@ resolves all twenty-four:
| `stage\StageParameter_Test.tbl` | 1 — the developer stage |
**That is exactly the 24, and it explains 24 against the 29 stage records**
([`challenge-mission-gate.md`](challenge-mission-gate.md) counts 29:
([`challenge-mission-gate.md`](../challenge-mission-gate.md) counts 29:
16 story + 6 tutorial + 6 challenge + `Test`): the six tutorials do not get one
table each, they **share a single `_Tutorial` table**, and `_Test` accounts for
the last. 16 + 6 + 1 + 1 = 24.

View File

@@ -0,0 +1,123 @@
# 🟡 `pteff04` / `pteff05` are dropped — the texture is registered as `8AX`
**Status:** 🟡 a real name-resolution gap, **currently harmless to look at**, and
the obvious fix would make the render *worse*. Found by auditing what
`screen render` silently omits on the port's five screens.
## What `screen render` drops, and why
`sylpheed-cli screen render` reports its omissions. On the five screens the port
needs, every one is accounted for except two:
| screen | not drawn | reason |
|---|---|---|
| title (4) | `ptlogo1`×2, `ptlogo2`×2 | `kind & 0x4` ghost instances — the draw capture shows one quad per wordmark |
| | `pteff00.prm`, `pteff02.prm` | `.prm` primitives, off without `--primitives` |
| | `ptloop01.rat`, `ptloop02.rat` | `loop*` animations, off without `--animated` |
| | **`pteff04.t32`** | ⚠️ **unexplained — this page** |
| main menu (5), `EXTRAS` (6) | as above, plus **`pteff05.t32`** | ⚠️ same |
| splash (10, 11) | `palogo_eff0.prm` | primitive |
`pteff04`/`pteff05` are `kind = 0x0`, one keyframe, rest **`a = 255`**, and pivot
`(640,360)` — i.e. a **full-screen, fully opaque** element. Exactly the shape of
the defect the port agent reported (an element the game draws that we omit).
## The cause: the element's name and its texture's name differ
Both names occur twice per bundle, the same as `pteff03.t32`, which resolves
fine. The difference is what follows the `opt ` link:
```
pteff03.t32 @0x4661a4 opt → T8aD ← resolves
pteff05.t32 @0x0e2035 opt → 8AX T8aD ← the sprite is named 8AX
```
Hex at `0x0e2035`:
```
opt .... 70 74 65 66 66 30 35 2e 74 33 32 00 38 41 58 54 38 61 44
p t e f f 0 5 . t 3 2 \0 8 A X T 8 a D
```
So the declaration says `pteff05.t32` while the `T8aD` that follows is registered
under **`8AX`**. `compose` does `build.sprites.get(sprite)` and, on a miss, hits a
silent `continue`. `8AX` is a **1280×720** texture and appears in all six
title-family bundles (`a60fcb85`, `a715f485`, `a81c1d85` and the three JP twins);
it is a *sprite* in builds 4/5/6 and never an element.
## ✅ Why it does not currently show
The same artwork is already on screen. `ptbase.t32` is **640×360** and its
element draws at **200 %**, and the background our render produces is
pixel-identical to `8AX` in every patch sampled:
| patch | capture | our render | `8AX` |
|---|---|---|---|
| top-left | 4.5, 9.4, 19.5 | **17.7, 29.7, 46.7** | **17.7, 29.7, 46.7** |
| top-right | 4.4, 9.0, 18.2 | 17.2, 28.2, 43.8 | 17.1, 28.2, 43.8 |
| bottom-left | 3.5, 6.0, 11.1 | 13.6, 20.5, 31.2 | 13.6, 20.5, 31.2 |
Directly: `ptbase` upscaled 2× against `8AX` is **mean abs diff 2.05** (max 80),
not byte-identical. They are **the same art at two resolutions**`8AX` is the
full-res copy, `ptbase` the half-res one the element scales back up.
## ⚠️ So do not "fix" this by drawing it
Resolving `pteff05 → 8AX` and drawing it *in addition to* `ptbase` would
**double-draw the background** — an opaque full-screen layer over an identical
one. It would not be visible as a doubling, which is worse: it would silently
cost fill and hide any future change to either layer.
**And that is now the recommendation, not just an option:** use `8AX` at 1:1.
The detail test above says the game's background carries 8AX's full-resolution
detail, so upscaling the 640×360 `ptbase` 2× is *wrong*, not merely softer.
⚠️ Still do not draw **both** — an opaque full-screen layer over an identical one
costs fill and hides any later change to either. `ptbase`'s element carries the
keyframes, so a consumer needs its timing with `8AX`'s pixels.
## ✅ Settled statically: the game draws the FULL-RES `8AX`
This was parked as "needs a per-draw capture". It did not — the two candidates
carry the same art at two resolutions, so what separates them is **the detail
`8AX` has that an upscale cannot**. Compute `8AX upscale(ptbase)` and ask
whether the live capture contains it.
[`tools/re-capture/eightax_detail_test.py`](../../../tools/re-capture/eightax_detail_test.py),
output at [`data/eightax-detail-test.txt`](../data/eightax-detail-test.txt).
Both candidates are first mapped into the capture's tone domain with the measured
gamma ([tone curve](ui-render-tone-curve.md)); without that the capture's
residual is dominated by the tone difference and the test is blind.
| screen | corr | control (shift 7 px) | control (flip) | ceiling | % of ceiling |
|---|---|---|---|---|---|
| main menu | **+0.0475** | +0.0032 | 0.0075 | 0.070 | **68 %** |
| title | **+0.0634** | +0.0095 | +0.0086 | 0.094 | **68 %** |
The "ceiling" is what a clean *"8AX is drawn"* would give, `sd(8AX-only detail) /
sd(capture residual)` — the 8AX-only detail is small (sd 1.94.1) against
everything else that differs (sd 2743), which is why the absolute correlation is
small and why the controls matter more than the magnitude.
**Two independent screens both land at 68 % of ceiling, 715× their matched
controls.** The controls preserve the spatial correlation structure and destroy
only the alignment, so they are what "no signal" looks like here.
### 🔴 An earlier, cruder version of this test does NOT support the conclusion
A pixel-pair test (a 2×-nearest upscale has identical adjacent columns) gave
ratio 0.00 for nearest, 0.600.72 for bilinear/bicubic/lanczos, 0.98 for native
`8AX`, and **1.01 for the capture** — apparently decisive. It is not: additive
noise raises both terms equally and pushes *any* ratio toward 1. Fitting a noise
term, both "native + noise ≈ 1.2" and "bilinear upscale + noise ≈ 1.8" reproduce
the observed numbers. Recorded because the number looks conclusive and is not.
### ⚠️ What it still does not show
Whether `ptbase` is **also** drawn underneath. `8AX` is ~86 % opaque and carries
the same art, so it would hide it either way. The observable fact is that 8AX's
detail reaches the screen.
## ❔ Not established
* The capture is ~4× darker than the render in these patches (4.5 vs 17.7), and
not by a constant ratio. That is a separate colour/gamma question, untouched
here.

View File

@@ -0,0 +1,212 @@
# ❔ A group's DURATION is in the data; its START TIME is not
**Status:** ✅ the durations are measured and match; ❔ the start offset is
**undecodable from the placement data**, with the obvious candidate refuted.
Found while testing whether the splash timeline, *played*, reproduces the
capture sequence — the last gap in the animation model.
## The durations are right
From the 235-frame draw capture of the developer splash, at the settled
2 units/frame, under the shifted time reading:
| element | drawn on frames | = units | declared visible span | match |
|---|---|---|---|---|
| `palogo_gamearts_eff`, `palogo_seta_eff` | 94115 (22) | 44 | ~0 … 45 = **45** | **97.8 %** |
| `palogo_gamearts`, `palogo_seta` | 116211 (96) | 192 | 15 … 210 = **195** | **98.5 %** |
(The logos are `a = 0` until t=15, so they are *drawn* for 15…210; the glows fade
in from their untimed first keyframe.) **Each element is on screen for its
declared span, to within 2 %.**
## 🔴 But they do not share a clock origin
Every glow declares the same times — `15, 30, 45` — and every logo the same
`15, 30, 190, 194, 206, 210`. On one clock they would overlap almost entirely:
both families start at 15.
**They do not overlap at all.** The glows run frames **94115** and the logos
**116211** — strictly sequential, the logos starting the frame after the glows
end. Fitting a single origin bears this out: `gamearts_eff` needs f₀ ≈ 93.5 and
`gamearts` needs f₀ ≈ 103, about **19 units apart**, and the same edit that
aligns one throws the other off by ~9 frames at both ends.
## 🔴 The obvious candidate is refuted
`parse_placements` reads each group header as `(element index, keyframe count)`
followed by **one undecoded lead-in word**. That is exactly where a per-group
start offset would live. It is **`0x00000000` for all seven elements** of the
developer splash — glows and logos alike. It carries no offset.
## ❔ So where does the start come from?
Not from the keyframe times (identical across each family), not from the group
header's spare word (zero), and not from declaration order (`eff0.prm`, gamearts,
gamearts_eff, seta, seta_eff, anima, anima_eff — which interleaves logos and
glows, while the observed sequencing groups them). The RATC child order does not
match the observed order either.
Reach: three places looked, all negative. What remains is that the sequencing is
**driven by code**, consistent with what
[boot-config-and-gamepart-registry](../boot-config-and-gamepart-registry.md)
already concluded for the boot order — *the driver is code, not data*.
## ⚠️ What a port needs to know
A group tells you **how long** an element's animation runs and what it does; it
does **not** tell you **when** that animation starts relative to other elements
on the same screen. On the developer splash the observed order is: both glows
together, then both logos together. That is a **measured** sequence for one
screen, not a decoded rule — a port animating a screen has to author the
sequencing, and should know it is authoring.
---
## ✅ Verified against its own refutation — and it goes further than start times
**2026-08-29.** Two checks on the "sequential, not simultaneous" reading.
### The phases really are disjoint
If the glows and logos ever shared a frame, the sequencing claim would be wrong.
Across all 235 captured frames, the number of frames containing **both** a glow
and a logo is **zero**. The switch is a single clean boundary:
```
f110115 1280x720 + 262x108 + 525x90 ← both glows
f116122 1280x720 + 243x86 + 499x72 ← both logos
```
Two sprites either side, no overlap, no transition frame.
### A third of the bundle is never drawn at all
Entry 11 declares **three** logo/glow pairs. Only two are ever drawn:
| sprite | declared | drawn |
|---|---|---|
| `palogo_gamearts` / `_eff` | 500×71 / 521×91 | **95 / 22 frames** |
| `palogo_seta` / `_eff` | 240×89 / 261×110 | **95 / 22 frames** |
| `palogo_anima` / `_eff` | 388×136 / 407×156 | **never** |
…and `palogo_anima` declares the *same* keyframe times as `palogo_gamearts`.
⚠️ Reach: the capture covers frames 1214, so this is "never **within the
window**", not "never". The pair may follow after it.
### What that means for a consumer
**A bundle is a palette, not a script.** Its elements carry what to draw and for
how long; which of them run, and when each starts, is decided outside the
placement data — selectively enough that two elements with byte-identical
keyframe times get 95 frames and 0 frames in the same run.
That is the same conclusion the boot-order work reached from the other end
(*the driver is code, not data*), now with a per-element measurement behind it.
⚠️ Concretely: **compositing every element of a bundle does not reproduce what
the game shows over time.** It is the right thing for a static screen that
settles, and it is not a timeline.
---
## 🔴 Narrowing "a bundle is a palette" — the mechanism is NOT established
**2026-08-29.** The glow phase and the logo phase are disjoint, but there are two
explanations and I asserted one of them:
1. **one bundle, selective activation** — the game runs some of entry 11's
elements, then others;
2. **two bundle-loads in sequence** — the game shows one composition, then
another (entries 11 and 14 are twins declaring identical sprites).
The draw log records each draw's **texture base**, which looked like it would
separate them. It does not:
| phase | frames | texture bases |
|---|---|---|
| publisher splash | 190 | `0x11C30000`, `0x10000000` |
| glows | 94115 | `0x11C30000`, `0x10000000` |
| logos | 116211 | `0x11C30000`, `0x10000000` |
**The test fails its own control.** The publisher splash is certainly a different
bundle from the developer splash — different entry, different artwork — and it
uses the *same* base. So `0x11C30000` is a reused upload address, not a bundle
identity, and it cannot distinguish hypothesis 1 from hypothesis 2.
### What survives, and what I withdraw
**Survives:** a bundle's declared elements are not what gets drawn.
`palogo_anima` and `palogo_gamearts` carry byte-identical keyframe times, and in
the same run one is drawn for 95 frames and the other for none — whichever of the
twins was active declares *both*. The phases are also strictly disjoint (0 frames
of overlap in 235).
🔴 **Withdrawn:** the mechanism. "One bundle, elements activated selectively" was
my reading, and the evidence does not choose it over "two compositions shown in
sequence". The practical consequence is the same either way — compositing every
element of a bundle does not reproduce what the game shows over time — but the
*why* is not established and should not have been stated as though it were.
❔ What would separate them: a per-draw capture recording the **bundle** each draw
came from, or a kernel/file-IO log showing whether a second RATC entry is read
between frames 115 and 116.
---
## ✅ Re-established, by elimination — selective activation is happening
**2026-08-29.** The alternative can be killed statically, which I had not tried.
Hypothesis 2 needs a bundle that declares the **glows without the logos**. There
is none. Enumerating every entry in `GP_TITLE` that contains `palogo` elements:
| entry | elements |
|---|---|
| 10, 13 (publisher twins) | `palogo_eff0.prm`, `palogo_sqex`, `palogo_sqex_eff` |
| 11, 14 (developer twins) | `palogo_eff0.prm`, **all three logos and all three glows** |
Four entries, and each developer entry declares the **complete set of six**. So
whichever bundle was active across frames 94211 — entry 11, entry 14, or both in
turn — it declared the logos *and* the glows, while the game drew two sprites at
a time in disjoint phases.
**⇒ Only a subset of a bundle's elements is drawn at any moment, whatever the
bundle-loading story is.** The conclusion does not depend on resolving how many
bundles are involved, which is why the texture-base test's failure no longer
matters.
✅ So "a bundle is a palette, not a script" is **reinstated** — this time by
eliminating the alternative rather than by assuming it away. The route that
worked was not a better capture but asking what the competing hypothesis would
*require on the disc*, and finding it absent.
---
## ✅ The measured splash sequence — what a port has to author
Since the sequencing is not in the placement data, here is the measurement, so a
port authors from evidence rather than from taste. Frames are VdSwap counts;
**1 frame = 1/30 s** (2 units/frame × 1 unit = 1/60 s, both settled in
[Q1](../ui-keyframe-time-unit.md)). Data:
[`data/splash-phase-timeline.txt`](../data/splash-phase-timeline.txt).
| phase | frames | n | seconds | starts at |
|---|---|---|---|---|
| publisher — `palogo_sqex` | 190 | 90 | **3.00+** | 0.00 |
| *(gap — nothing drawn)* | 9193 | 3 | 0.10 | 3.00 |
| developer — **both glows** | 94115 | 22 | **0.73** | 3.10 |
| developer — **both logos** | 116211 | 96 | **3.20** | 3.83 |
⚠️ **Three limits on these numbers.**
* The capture opens at frame 1 with `palogo_sqex` **already at `a=255`**, so the
publisher phase began before the window. **3.00 s is a floor**, and every
"starts at" is relative to the capture, not to boot.
* `palogo_anima` and `palogo_anima_eff` get **0 draws** in all 214 frames, so the
third logo pair's phase — if there is one — is not in this measurement.
* One capture, one run. The glow→logo switch is a single frame boundary with no
overlap, which is a strong shape, but the *durations* are one sample each.
✅ What is solid: the **0.73 s glow phase** and the **3.20 s logo phase** are
each within 2 % of their element's declared span, so the durations are the
bundle's own and only the sequencing is authored.

View File

@@ -0,0 +1,227 @@
# ✅ The keyframe block's `+12` is a screen-plane ROTATION, in degrees
**Status:**`DECODED` — field, disc-wide census, and confirmed against the
**framebuffer** (a GPU draw capture), not against our own renderer.
🟡 `+4` and `+8` remain unexplained.
This closes the ⚠️ that stood on the `Keyframe` type: *"they do not explain the
title screen — every element of `GP_TITLE` build 4 has all three at zero, while
the game demonstrably submits rotated quads there."* That note was wrong about
**reach**, not about the bytes it read.
## The block
```text
+0 u32 ARGB fade colour
+4 i32 🟡 unexplained
+8 i32 🟡 unexplained
+12 i32 ✅ rotation, DEGREES, clockwise-positive in screen space (Y down)
+16 u32 scale X, percent
+20 u32 scale Y, percent
+24 u32 tint
+28 i32 X ← signed
+32 i32 Y ← signed
+36 u32 time (the last block of a group does not own this word)
```
## How it was confirmed: two quads, two different angles, measured
The live title submits one draw of **two rotated quads** that our renderer does
not reproduce ([`title-draw-capture-vertex-colours.log`](../captures/title-builds/title-draw-capture-vertex-colours.log),
draw 2). Converting its NDC vertices to screen space — `X = (x+1)·640`,
`Y = (1y)·360` — and taking the quads' **edge lengths** rather than their
bounding boxes:
| quad | size | rotation | centre |
|---|---|---|---|
| A | 400.1 × 1076.3 | **+30.26°** | (992.0, 359.1) |
| B | 400.2 × 1444.5 | **45.28°** | (467.2, 360.0) |
Both are true rectangles (adjacent edges perpendicular to < 0.6 %).
The two elements they belong to are the title's nested leaf records
`ptloop01.rat``pteff03.t32` and `ptloop02.rat``pteff03a.t32`. Their
keyframe blocks, at `0xbb5966+0x68` and `0xbb5a82+0x68` in the decompressed
build 4:
| | `ptloop01` | `ptloop02` |
|---|---|---|
| sprite | `pteff03.t32` 399×180 | `pteff03a.t32` 399×180 |
| scale | 100 %, **600 %** → 399 × **1080** | 100 %, **800 %** → 399 × **1440** |
| `+12` | **30** | **45** |
| measured | 400.1 × 1076.3 at **+30.26°** | 400.2 × 1444.5 at **45.28°** |
Magnitude *and* sign, on **two different values**, from the GPU.
### Why this is an identification and not a coincidence
The same capture was first attributed to a different element entirely
([REFUTED.md](../REFUTED.md)), so the identification is carried by five
independent agreements, not by elimination:
1. The draw has exactly **two** quads; the screen declares exactly **two**
`ptloop` elements.
2. Both widths measure **400** against a declared sprite width of **399**.
3. The heights are **different numbers that both land**: 1076 ≈ 1080 (600 %) and
1444 ≈ 1440 (800 %). A coincidence would have to hit both.
4. Between the capture's two frames quad A moves **+19 px right** and quad B
moves left — matching each record's own decoded sweep direction
(`ptloop01` x: 639 → 1521, `ptloop02` x: 1721 → 839).
5. The drawn vertex alphas `0xC3` / `0xB6` both fall inside the declared
`0x80``0xff` alpha ramps.
### A second corroboration, by ramp shape rather than by angle
`GP_BUNK` entry `117ca14f` at `0x7395f3` holds a two-keyframe group in which
`+12` ramps **0 → 360** while position, scale and alpha all stay constant. A
full-turn ramp that changes nothing else is a spin in place; no non-rotation
reading of the field explains it. Disc-wide, `360` occurs 1 173 times and `360`
180 times.
## ✅ The strongest evidence is `GP_DIALOG`, and it is visible in the CLI
`sylpheed-cli screen info --build 0 --geometry dat/GP_DIALOG.pak`. These are
**top-level** elements, so no hex dump is needed to see them:
| element | `+12` across the group | what else moves |
|---|---|---|
| `pceff03.t32` | **90 → 30 → 10 → 3 → 0** | alpha 0 → 128 → 192 → 224 → 255; position slides (630,348) → (470,198) |
| `pceff04.t32` | **90 → 30 → 10 → 3 → 0** | the same ramp, mirrored path (413,77) → (553,247) |
| `pzeff02.t32` (build 6) | **43 → 61 → 75 → 90** | scale 112 % → 200 %, alpha 255 → 0 |
The first two are a swing-in that *decays to zero exactly as the element
settles*; the third is a burst that spins as it expands and fades. A monotone
decay to 0 synchronised with a fade-in, and a spin-out synchronised with an
expanding fade-out, are what a rotation field does and are not what any other
reading of the word would produce.
`GP_DEBRIEFING_PILOTLOG` build 10 holds the static case: `pjeff24a.t32`, one
keyframe, `r=90` at 210 % scale.
These four rows are committed verbatim at
[`data/rotation-top-level-examples.txt`](../data/rotation-top-level-examples.txt).
⚠️ The sweep that found them — every build of every `GP_*.pak`, grepped for a
non-zero `r=`**had not finished** at the time of writing; it was in
`GP_DIALOG` build 37 with two archives hit so far. Treat the *count* as
incomplete and the *existence* as settled.
## ✅ Bonus, from the same two quads: the pivot-anchored scale is measured-correct
`ui_layout::blit` places a scaled element as
`ox = kf.x pivot·(scale100)/100`, keeping the **pivot** fixed rather than the
top-left. That was implemented and reasoned about, never measured — and until now
there was nothing to measure it against, because the title's other elements are
all at 100 % scale where the term vanishes. The `ptloop` pair scale **600 %** and
**800 %** vertically, so the term is worth 450 and 630 px:
| element | measured centre Y | pivot-anchored | top-left | position-as-centre |
|---|---|---|---|---|
| `ptloop01` (600 %) | **359.1** | **360.0** ✅ | 810.0 | 270.0 |
| `ptloop02` (800 %) | **360.0** | **360.0** ✅ | 990.0 | 270.0 |
Two different scales, both landing on 360.0. Horizontally the same term is worth
199.5 px, and there is an independent cross-check available: solve the group's
`t` from the quad's **position**, then from its **vertex alpha**, and see whether
the two agree.
| element | | t from position | t from alpha | Δ |
|---|---|---|---|---|
| `ptloop01` | with the pivot term | 571.98 | 571.65 | **0.33** |
| | without | 579.65 | 571.65 | 8.00 |
| `ptloop02` | with the pivot term | 668.92 | 668.27 | **0.65** |
| | without | 659.71 | 668.27 | 8.55 |
🟡 **What this does *not* show is that interpolation is linear.** Both fields
were inverted through the same linear map, so a shared easing curve would cancel
out and still agree. What it does show is that position and alpha are driven off
**one shared parameter** — which is worth knowing on its own, and is why the
agreement is a real check on the placement formula rather than a tautology.
## Disc-wide census
[`tools/re-capture/kf_rotation_census.py`](../../../tools/re-capture/kf_rotation_census.py),
output committed at [`data/kf-angle-census.txt`](../data/kf-angle-census.txt).
Over **83 862** keyframe blocks in the `GP_*.pak` UI archives:
| word | non-zero | dominant values |
|---|---|---|
| `+4` | 3 990 (**4.76 %**) | `180` (3 880) |
| `+8` | 3 878 (**4.62 %**) | `180` (3 102), `90` (201), `180` (156) |
| `+12` | 12 164 (**14.50 %**) | `90`, `90`, `360`, `120`, `180`, and a long tail of arbitrary angles (`53`, `58`, `125`, `114`, `33`, …) |
`+12`'s long tail of arbitrary values is itself part of the decode: `+4` and `+8`
are almost entirely `±180`/`±90`, which is the signature of a **flip flag**
expressed in degrees rather than of a free angle. 🟡 That is a hypothesis about
`+4`/`+8`, not a decode — nothing observed turns on them.
## ⚠️ Reach and limits
* **`rotation_deg` is decoded but NOT rendered.** `ui_layout`'s blitter draws
axis-aligned quads only, so `sylpheed-cli screen render` still paints a
rotated element upright. Rotating the blit is a real change to the compositor
and was not attempted here. **The title render's residual band is expected to
persist until it is.**
* 🔴 ~~"Rotation appears to live only in nested `.rat` leaf records."~~
**Refuted by my own sweep, within the hour.** It held for `GP_TITLE`,
`GP_BUNK` and `GP_CHALLENGE` — the three archives I had checked when I wrote
it — and fails on the very next one: `GP_DIALOG` and
`GP_DEBRIEFING_PILOTLOG` carry rotation on **top-level** elements, and those
are the best evidence on the disc (below). Rotation lives in both places.
The half that survives is the one that matters to a consumer: a composer that
reads only the declaration table will miss the *title's* rotations, because
those particular ones are nested.
* **The blocks are not 4-byte aligned.** A nested `RATC` blob can start at an odd
offset (`ptloop01.rat` at `0xbb5966`), and its blocks inherit that. Any scanner
over raw bundle bytes must not assume alignment — see
[METHOD.md](../METHOD.md).
* `+4` and `+8` are **not** claimed as X/Y rotation. Three adjacent signed
degree-valued words invite that reading; only `+12` has been observed to do
anything.
## ✅ The top-level census, finished (2026-08-28)
The earlier attempt drove `sylpheed-cli screen info --geometry` in a shell loop
and never finished — it decodes every texture per build.
[`tools/re-capture/rotation_toplevel_census.py`](../../../tools/re-capture/rotation_toplevel_census.py)
walks the top-level placement region the way `ui_layout.rs` does; seconds instead
of hours. Output: [`data/rotation-toplevel-census.txt`](../data/rotation-toplevel-census.txt).
Both controls pass — `GP_TITLE` build 4 reports **0** (its rotations are the
nested `ptloop` records) and `GP_DIALOG` build 0 reports the **2** expected
`pceff03`/`pceff04` elements.
| | |
|---|---|
| top-level elements with a keyframe group | 15 493 |
| carrying a non-zero rotation | **2 152 (13.89 %)** |
By archive: `GP_READY_ROOM` **1 868**, `GP_DIALOG` 40, each of the six
`GP_MAIN_GAME_*2D` 37, `GP_GAMEOVER` 10, `GP_DEBRIEFING_PILOTLOG` 8,
`GP_TITLE` **4**.
### Two more corroborations, both free
**The rotated population is tactical-map ship icons.** The most-rotated names are
`pbb_destroyer` (444), `pbr_destroyer` (402), `pbr_fighter` (276),
`pbb_fighter` (174), `pbb_aircraft` (168) — map markers, in the archive that
holds the tactical map. Rotating a ship icon to its heading is what the field is
*for*, and it is the single largest use of it on the disc.
**`GP_TITLE` entry 7's four rotated elements carry their own control.** Three of
them are the Japanese wordmark's pieces, and they settle to upright from
**alternating** tilts:
```
ptlogo3a.t32 r = 0, -14, -4, -1, 0, 0, 0, 0
ptlogo3b.t32 r = 0, +14, +4, +1, 0, 0, 0, 0
ptlogo3c.t32 r = 0, -14, -4, -1, 0, 0, 0, 0
```
Same magnitudes, alternating sign, all decaying to 0 — three sibling elements
tilting opposite ways and straightening. A misread field does not produce that.
(The fourth is `ptlogo_eff3.t32`, `r = 0, 0, 80, 150` — the transient bloom
discussed in [ui-resting-pose](ui-resting-pose.md).)
⚠️ **This is not subtractable from the byte-scan census.** That one counts
*blocks* (12 164 of 83 862 with a non-zero `+12`); this one counts *elements*.
The two are different units and the difference is not "the nested population".

View File

@@ -0,0 +1,169 @@
# ✅ Does the derived paint order reproduce the measured ones? Mostly — and the gap is bounded
**Status:****checked, with numbers.** `compose` uses a paint order *measured
from the running game* for the builds that have one and falls back to
`derived_paint_order` — a sort on each sprite's layer key — everywhere else. The
code's doc comment claimed the derived order "reproduces both measured orders up
to ties". That was a hedge with no measurement behind it, and it was stale:
there are **three** measured orders now, not two.
Tool: `cargo run -p sylpheed-formats --example paint_order_audit -- dat/GP_TITLE.pak`.
Output committed at [`data/paint-order-audit.txt`](../data/paint-order-audit.txt).
## The claim holds, and the exception is all ties
| build | derived == measured | inverted pairs | of which same-key ties |
|---|---|---|---|
| main menu (entries 5, 8) | **YES** | 0 | — |
| developer splash (11, 14) | **YES** | 0 | — |
| title (entry 4) | **NO** | 8 | **8 — every one** |
Four of the five measured bundles reproduce **exactly**. The title's eight
disagreements are all between elements that share a layer key, which the sort
cannot order and breaks by declaration index.
## ⚠️ Two of those ties are total occlusions
The tied family is the five `ptlogo_back2eff` glows, all key `32899`:
```
measured paints: 14, 15, 18, 16, 17
derived paints: 14, 15, 16, 17, 18
```
Two pairs flip, and they are not near-misses:
| pair | overlap | |
|---|---|---|
| `back2eff5` vs `back2eff3` | 82 824 px² | **100 % of the smaller** |
| `back2eff5` vs `back2eff4` | 152 047 px² | **100 % of the smaller** |
`back2eff5` is 1133×280 and **fully contains** both. Derived paints it on top of
two glows it completely covers; the game paints it underneath. So a tie-break by
declaration index is not cosmetic — where it is wrong, it can be wrong by a whole
layer. ✅ The title is unaffected in practice, because it has a measured order.
## ✅ The port's actual exposure is two element pairs
Per build, counting tied pairs and how many of them **overlap** (only those can
paint visibly differently):
| entry | the port's screen | order used | tied pairs | overlapping |
|---|---|---|---|---|
| 4 | title | **measured** | — | — |
| 5 | main menu | **measured** (derived agrees exactly) | 0 | 0 |
| **6** | **`EXTRAS`** | **derived** | 15 | **2** |
| 10 | publisher splash | derived | **0** | **0** |
| 11 | developer splash | **measured** | — | — |
So of the five screens, **one** rests on an unverified derived order, and its
risk is **two overlapping tied pairs** — not the 15 the raw tie count suggests.
The publisher splash's derived order is fully determined (no ties at all).
🟡 For completeness, outside the port's set: entry 7 (the Japanese title) is the
worst on the disc at 37 tied pairs, 16 overlapping.
## Reach
* This checks the derived order against the orders **measured from the running
game**; it is not an independent derivation of what the game does. Where no
measured order exists, agreement cannot be checked at all — only the *tie
exposure* can, which is what the table above reports.
* Overlap uses `pivot × 2` as the element's size (documented as the sprite's own
dimensions for a `.t32`) at its resting placement, so scaled or rotated
elements are approximated.
---
## ❔ The tie-break is undecodable from the bundle — searched, with reach
The audit above leaves one question: the layer key orders elements, but what
orders elements that **share** a key? On the title that tie-break decides two
total occlusions, so it is not academic.
The game paints the five tied `ptlogo_back2eff` glows in the order
**eff1, eff2, eff5, eff3, eff4**. Three static structures were searched for
anything that reproduces it.
**1. The declaration table.** Entries 1418 are *byte-identical* apart from the
pivot, which is just half the sprite's own size:
```
14 ptlogo_back2eff1 00000000 ffffffff ffffffff 00000000 ffffffff 0000004e 0000003c 00000000
15 ptlogo_back2eff2 00000000 ffffffff ffffffff 00000000 ffffffff 00000074 0000005b 00000000
16 ptlogo_back2eff3 00000000 ffffffff ffffffff 00000000 ffffffff 000000aa 0000005b 00000000
17 ptlogo_back2eff4 00000000 ffffffff ffffffff 00000000 ffffffff 00000149 0000005b 00000000
18 ptlogo_back2eff5 00000000 ffffffff ffffffff 00000000 ffffffff 000001fb 0000007e 00000000
```
**2. The `T8aD` headers.** All five carry identical `+0x04` (`0x8832`) and
identical `+0x08`/`+0x0A` (`32899`, the layer key itself). They differ only in
position and tile count. Searched **exhaustively**: every offset `0x000x7f`, at
u8, u16 and u32 width, sorted both ascending and descending —
| | |
|---|---|
| fields sorting to the **measured** order | **0** |
| fields sorting to the **declaration** order (control) | **64** |
The control matters: 64 fields *can* be found that reproduce a known ordering, so
the scan is capable of finding an ordering field when one exists. It finds none
for the order the game uses.
**3. The RATC child order** — the bundle's second element list, which is
genuinely a different permutation from the declaration table on other screens.
For this family it reads `eff1, eff2, eff3, eff4, eff5`: declaration order again.
### The answer
**Undecodable from the bundle, with reach.** All three static orderings give
`eff1…eff5`; the game gives `eff1, eff2, eff5, eff3, eff4`. That is consistent
with what [ui-screen-runtime](ui-screen-runtime.md) already concluded from the
other direction — the game builds a **reordered child list at load time** and
paints that.
So Q3 resolves as: the layer key is ✅ **decoded** and orders 4 of the 5 measured
bundles exactly; the **tie-break within a key is ❔ undecodable**, and a consumer
must either use a measured order or accept declaration index as an arbitrary
stand-in.
---
## ✅ `EXTRAS`'s exposure narrows to ONE pair — and the capture is consistent with it
The 15 tied pairs on `EXTRAS` reduce twice.
**2 of the 15 overlap** (only an overlapping tie can paint differently):
```
[0] ptframe3.t32 x [1] ptframe4.t32 key 32848 overlap 102x132
[14] ptloop01.rat x [15] ptloop02.rat key 32784 overlap 400x180
```
**1 of those 2 can be drawn.** `ptloop01`/`ptloop02` are `loop*` sprite
animations, which `compose` skips unless `--animated` is passed, so their tie is
unreachable in a default render. The real exposure is `ptframe3` × `ptframe4`,
overlapping **102 × 132 px** at x 584686, y 318450.
### The capture is consistent with our order
Correlating our render against
[`live-extras.png`](../captures/title-builds/live-extras.png):
| region | correlation |
|---|---|
| **the contested overlap** (x 584686, y 318450) | **+0.9622** |
| `ptframe3` alone, outside the overlap | +0.8502 |
| `ptframe4` alone, outside the overlap | +0.9903 |
| whole frame | +0.9440 |
The contested region matches **better than the frame as a whole** and sits inside
the range of regions where order *cannot* matter. Were our tie-break wrong, that
region should correlate worse than its neighbours.
🟡 **Consistent with, not proof.** Correlation cannot see a swap if the two
frames happen to be locally similar. But it is a real check, and it removes the
"entirely unverified" character of the one tie the port can actually hit.
**So the chain is: 15 tied pairs → 2 overlapping → 1 drawable → consistent with
the capture.** That is the whole paint-order risk on the port's five screens.

View File

@@ -84,10 +84,16 @@ Verified three ways rather than by a green build:
([capture](../captures/mission-select-derived-order.png)).
**Found on the way, and worth its own line:** the developer-logo splash bundle
has no `.rat` child, so `ui_layout::is_build` rejects it and the compositor never
sees it. Its measured order is therefore inert in practice, and the splash cannot
be rendered by `screen render` at all. That is a separate gap in what counts as a
"build", not a paint-order question.
has no `.rat` child, so `ui_layout::is_build` rejects it and the **default**
`screen render` never sees it.
⚠️ **Corrected 2026-08-28:** the sentence that used to end this paragraph — "the
splash cannot be rendered by `screen render` at all" — is **wrong**. It can:
`screen render --all` widens the enumeration past `is_build` and the splash
composites fine, both halves.
[`../ui-title-build-map.md`](../ui-title-build-map.md) has the renders. What is
true is narrower: it is invisible to the *default* listing, so anyone who does not
pass `--all` will conclude it is missing.
## What the change did to the screens that were already verified (2026-08-19)
@@ -395,3 +401,260 @@ Three published numbers on this page were wrong the same way: each was computed
over a population I had not checked was the population in question — the six
language copies, then the top-level-only sprites, then the size of that gap. The
analysis was never the problem. **Check the sampling frame before the statistic.**
## `EXTRAS` — the derived order against a capture nobody had taken (2026-08-28)
The five orders above are all **measured**: the derived rule is checked against a
live child list. This one is the other direction — a screen with **no measured
order at all**, composited purely from the derived rule and scored against a
framebuffer capture taken the same day
([capture](../captures/title-builds/live-extras.png),
[composite](../captures/ui-layout/extras-composited.png)).
`GP_TITLE.pak` build 6, 18 elements, two `.prm` primitives:
```
sylpheed-cli screen render --build 6 --black --primitives GP_TITLE.pak extras.png
align_to_capture.py live-extras.png extras.png --region 150 100 1150 600
```
| composite | best shift | score |
|---|---|---|
| **build 6, `--black --primitives`** | **(0, 0)** | **0.9620** |
| build 6, no primitives | (0, 0) | 0.9576 |
| *control:* main menu vs `main-menu-oracle.png`, same tooling | (0, 0) | 0.9657 |
The control was run first and reproduces the screen this rule was fitted to, so
the harness is known-good before the new screen is scored. `EXTRAS` lands within
0.004 of it, at zero shift, with the primitives helping rather than hurting.
### ⚠️ What this does and does not show
**It does show** that every other part of the stack transfers to an unfitted
screen: element enumeration, resting pose, pivots, `fade` alpha, and that the
two primitives land somewhere harmless under the derived `implied_layer_key`.
**It does not show that the paint order is right.** `align_to_capture.py`
correlates *gradient magnitude* — edges — and a paint-order change moves blend
values, not edges. The earlier derived-vs-declaration A/B on the pause menu and
main menu measured exactly that: 1.13.8 % of pixels differing by ≤45/255, in
translucent overlaps. A score computed this way cannot see a difference that
size, so **0.9620 is evidence for placement and only consistent with the order.**
**The sharp test, not run.** Composite build 6 twice — the committed derived
order, and `compose` temporarily reverted to declaration order — and diff both
against the capture over the pixels where they disagree. That needs a rebuild of
`sylpheed-formats`, which is why it is named here rather than done; it is the
same A/B this page already ran on the two screens that had oracles, and `EXTRAS`
would be the first screen where the two orders can be judged against ground
truth rather than declared indistinguishable.
## 🟡 A second flag in the same header: `+0x04`, bit `0x02` (2026-08-28)
This page reads the layer key at `+0x0A`. The word at **`+0x04`** is a different
flag word, and on the title screen it splits the sprites exactly along
**effect versus normal**:
| `+0x04` | title sprites |
|---|---|
| **`0x8832`** | `pteff01`, `pteff03a`, `ptlogo_back2eff1…5`, `ptlogoall_eff`, `ptlogoall_eff2` |
| **`0x8830`** | `ptlogo1`, `ptlogo2`, `ptlogo_tm`, `ptbase2`, `ptlogo_back2`, `ptcopyright`, `ptlogo_back2eff` |
One bit apart: **`0x02`**.
**Disc-wide** (19 216 `T8aD` sprites, top-level and `RATC`-nested): 18 distinct
values, and bit `0x02` is set in **27.1 %**. It varies independently of the rest
of the word — `0x8830`/`0x8832`, `0x0830`/`0x0832`, `0x0810`/`0x0812` and
`0x0030`/`0x0032` all occur — so it is a genuine flag, not part of a larger
enumeration.
**Why it matters:** the title's swoosh renders opaque white where the game draws
a thin coloured stroke, and every other candidate is eliminated — position and
size come from the texture and match, `fade` is white-with-alpha, `tint` is white,
and the texture itself is blue-leaning
([`../ui-title-build-map.md`](../ui-title-build-map.md)). A per-sprite **blend
mode** is what is left, and this is the only per-sprite field found that
distinguishes the elements involved.
### 🔴 Tested — it does NOT select an additive blend
Blending bit-`0x02` sprites additively and re-correlating against the title
capture moved **every** measure the wrong way:
| | alpha-over | additive |
|---|---|---|
| whole-frame mean diff | **+0.55** | +1.04 |
| swoosh-band mean diff | **+1.83** | +3.98 |
| swoosh-band edge-corr | **0.6971** | 0.5578 |
So the bit is real and independent, but **additive is refuted**. `T8adImage` now
carries the word as `flags`, documented and *not* acted on.
⚠️ **What remains is a correlation, not a decode.** The original test was against
[`../captures/title-builds/live-title-build4-no-plate.png`](../captures/title-builds/live-title-build4-no-plate.png).
Worth noting `ptlogo_back2eff` carries `0x8830` **despite** having `eff` in its
name — so the split is the field's, not a naming pattern's. What the bit *does*
mean is still unknown.
---
## 🟡 The `+0x04` bit `0x02`: two candidate meanings killed, attribution now sound
**2026-08-29.** This page recorded the bit as a real independent field
(set in 27.1 % of 19 216 sprites) with its "additive blend" reading refuted and
its meaning ❔ *not diagnosed*, noting that `ptlogo_back2eff` is `0x8830`
"despite its name". Two things here: that note rested on a **size match**, and
the size is ambiguous.
### ✅ First, a sound way to attribute a header to a name
`T8aD` headers appear in the bundle **in RATC child order**. Verified on
`GP_TITLE` build 4 by an independent property — each header's decoded
dimensions against the dimensions the named child should have:
**18 of 18 match, 0 mismatches.**
That matters because two of the eighteen share a size (`ptlogo_back2eff` and
`ptlogo_back2eff5`, both 1133×280), so a size-keyed lookup cannot tell them
apart — the trap [METHOD.md](../METHOD.md) already records for this corpus.
Ordering resolves them: index 12 is `ptlogo_back2eff5` (`0x8832`, bit **set**),
index 14 is `ptlogo_back2eff` (`0x8830`, bit **clear**).
So the documented counterexample is **real and correctly attributed** — now on
evidence rather than on a guess between two same-sized sprites.
### 🔴 Two candidate meanings, both refuted
| hypothesis | verdict |
|---|---|
| bit ⟺ the name contains `eff` | **refuted**`ptlogo_back2eff` is an `eff` name with the bit clear |
| bit ⇒ the name contains `eff` (the surviving one-way reading) | **also refuted, disc-wide — see below.** It held 10/10 on build 4 and fails on **2 657 of 4 995** bit-set sprites across the disc |
| bit ⟺ the element is transient (gone before the screen settles) | **refuted**`pteff03`/`pteff03a` carry the bit and run to `t=250`, ramping to `a=255` and holding, i.e. they persist |
### 🟡 What the exception looks like up close
`ptlogo_back2eff` and `ptlogo_back2eff5` are the same size and the same artwork,
and differ in **two** header words:
```
ptlogo_back2eff5 +0x04 00008832 +0x08 00008083 (layer key 32899, bit SET)
ptlogo_back2eff +0x04 00008830 +0x08 00008081 (layer key 32897, bit clear)
```
⚠️ They are **not** duplicates: pixel-compared, `max abs diff 21`. Their alpha
summaries are identical to one decimal (4.5 % opaque, 86.7 % clear, mean 19.3),
which is precisely why the summaries were not trusted — two different renditions
of one image at one size.
So the bit tracks something that also distinguishes two versions of the same
artwork, alongside a different layer key. ❔ **Still not diagnosed**, but the
search space is smaller by two and the attribution underneath it is now sound.
### 🔴 The one-way implication is refuted too — build 4 was a local pattern
Having fixed the attribution, the surviving reading was *"bit set ⇒ the name
contains `eff`"*, true 10/10 on `GP_TITLE` build 4. Checked disc-wide with
[`tools/re-capture/eff_bit_census.py`](../../../tools/re-capture/eff_bit_census.py)
([data](../data/eff-bit-census.txt)) over 14 709 sprites whose preceding name
resolves:
| | count |
|---|---|
| bit SET & name has `eff` | 2 338 |
| **bit SET & name lacks `eff`** | **2 657** |
| bit clear & name has `eff` | 1 399 |
| bit clear & name lacks `eff` | 8 315 |
`P(eff | set) = 0.468` against `P(eff | clear) = 0.144`. **The implication fails
more often than it holds.** What survives is an *association* — the bit is 3.3×
enriched for `eff` names — and one build's 10/10 was a local naming habit, not a
rule.
🟡 The counterexamples are the interesting part: `pv_loading_ring0`,
`pv_loading_light0``3`, `pv_loading_line`, `px_bunk_line`, `px_top_extra`
rings, glows, lights and thin lines. **Effect-like artwork that does not carry
the `eff` naming convention.** Consistent with the bit marking effect sprites by
authoring intent rather than by name, which is a description, not a decode.
⚠️ Name caveat: these names come from the string **immediately preceding** each
`T8aD`, validated 17/18 on build 4 against the child order. The one mismatch is
the known `pteff04.t32` → registered as `8AX` case, so this is the **element**
(`opt `) name, not the sprite's registered name.
### 🔴 Premultiplied alpha — a fourth candidate, refuted
A per-sprite flag controlling **premultiplied vs straight alpha** would matter
enormously to a port (it changes the blend equation) and has a sharp static
signature: a premultiplied texture has `RGB ≤ A` everywhere.
[`tools/re-capture/eff_bit_alpha_test.py`](../../../tools/re-capture/eff_bit_alpha_test.py),
over the 170 decoded `GP_TITLE` textures that pair to a flag word
([data](../data/eff-bit-alpha-test.txt)):
| group | n | mean %(RGB>A) | median |
|---|---|---|---|
| bit SET | 61 | **55.5** | 52.5 |
| bit clear | 109 | **33.7** | 30.2 |
Premultiplied requires ~0 % for the flagged group. Both groups are far from it,
and the flagged group violates **more** — the opposite of the hypothesis.
**Refuted.**
🟡 What is left is a weak association: flagged sprites carry more bright-RGB /
low-alpha pixels, which is what glow art looks like. But the distributions
overlap badly — the best single threshold classifies **76.5 %** against a
**64.1 %** base rate, a 12-point lift. That is a tendency, not a rule, and
nothing to build on.
### ⚠️ One re-test worth remembering
"`0x02` selects an additive blend" was refuted by *"blending those sprites
additively worsens every measure against the capture"* — but that ran against a
title render which has since been fixed twice (the `rest_plateau` bug, and the
`8AX` background our composer drops). The refutation may still stand; it was
simply measured through a renderer with known other errors. If `ui_layout::blit`
ever gains additive blending, it is worth one re-run.
### 🛑 Parking this field
Four candidate meanings are now dead — additive blend, `eff` name (both
directions), transient element, premultiplied alpha — and none of the four
searches produced a positive account. The bit blocks nothing: the port's screens
composite at 0.947 correlation against a capture without it. **Parked**, with the
search space narrowed and the attribution method (child order, not size) left
sound for whoever picks it up.
## The reach of the draw-capture cross-check (2026-08-29)
The orders above are read off the **live child list**; the draw captures confirm
them. That confirmation is partial, and this is how partial — counted by
[`order_crosscheck_reach.py`](../../../tools/re-capture/order_crosscheck_reach.py),
data in [`order-crosscheck-reach.txt`](../data/order-crosscheck-reach.txt):
| screen | captured frames | draws/frame | quads/frame | order length | positions the capture confirms |
|---|---|---|---|---|---|
| title (entry 4) | 3 + 2 | 8 | 12 | 24 | **7** named in [`ui-screen-runtime.md`](ui-screen-runtime.md) |
| main menu (entry 8) | 2 | 9 / 7 | 16 / 9 | 16 | ≤ the richest frame |
| splash (11/14) | 235 | — | — | 7 | within each half only |
Three things this makes explicit, none of which changes an order:
* **Count quads, not draws.** The menu's draw 9 is `indices=24`**six** quads
batched out of one texture. A per-draw count reads 9 where 16 are on screen,
and an earlier pass of this analysis "found" three quads for six declarations
that way and briefly concluded elements were missing. They were batched.
* **The title capture is stable**: 8 draws / 12 quads / 5 textures, byte-for-byte
the same census in every one of the five captured frames across two logs. So
everything it shows co-occurs, and the 7 confirmed positions are mutually
ordered by observation. The other 17 rest on the child-list read alone — which
is the ground truth here, but it is one source, not two. (The census counts 8
draws where that page names 7 confirmed elements, so the capture carried a
little more than it was read for — not chased, and not a claim either way.)
* **The menu capture is not stable**: texture `0x11C30000` is in frame 0 and gone
by frame 3. Its order is still confirmed as far as any single frame goes.
* **The splash capture cannot cross-check its middle.** Glows (f94115) and logos
(f116211) never share a frame, so no capture of this screen orders one against
the other. The static layer key does (`0xa100` < `0xa110`), which is why the
order stands — see the ❌ withdrawn section in
[`ui-prm-primitives.md`](ui-prm-primitives.md).

View File

@@ -174,3 +174,74 @@ have never been measured, so they are not in the table.
***The 8 non-full-screen ones**, including two with a zero dimension.
* 🟡 `kind = 0x3010` (38 elements) is `0x10` plus `0x3000`, the button-record
bits — a primitive that is part of a button. Unexamined.
---
## ✅ Measured on the splash: the `.prm` paints FIRST, full-screen, opaque black
**2026-08-29.** This page recorded that where a `.prm` paints "on a screen
without a measured order is unsolved". For the developer splash it is now
measured, from the 235-frame draw capture.
Every frame begins with two full-screen draws before any sprite:
```
f120: [1280x720] [1280x720] [499x241] …
^clear ^this one
```
The second is **untextured** in all **212** frames, with a constant vertex colour
of **`FF000000`** — opaque black. That matches `palogo_eff0.prm`'s declaration
exactly:
| declared | observed |
|---|---|
| `kind = 0x10` (the primitive marker) | untextured draw |
| pivot (640,360) → **1280×720** | 1280×720 quad |
| **one** keyframe, `a = 255` | constant across 212 frames |
| — | vertex colour `FF000000` |
So the splash's backdrop is an **opaque black full-screen quad from the bundle
itself**, painted behind every sprite — not a clear colour, which is why our
splash renders need `--black` to match.
⚠️ **Not a general rule.** The measured *main menu* order puts `pteff02.prm` at
position 4 and `pteff00.prm` **last** — that one is the screen-transition fade.
A `.prm` paints where its screen's order says; the splash's happens to be first.
## ❌ WITHDRAWN (2026-08-29, same day): "the splash's measured order is a sequence, not depth"
This page briefly claimed that `measured_paint_order`'s `[0, 2, 4, 6, 1, 3, 5]`
records, between its glow half and its logo half, the *temporal* order they were
seen in rather than a depth order — on the grounds that the two halves never
share a frame (0 overlap in 235, which is
[measured and still true](../data/splash-phase-timeline.txt): glows f94115,
logos f116211).
**The measurement was right and the conclusion was wrong**, on two independent
grounds, either of which is fatal:
* **Wrong source.** The vector was never read off the draw capture. It is a read
of the live screen object's **child array**
[`ui-screen-runtime.md`](ui-screen-runtime.md) records it literally as
`paint order (child slots): 0 2 4 6 1 3 5`. A child list has a definite order
whether or not any two of its children are ever drawn together, so a
co-occurrence argument cannot touch it. The capture was the *cross-check*, not
the source. That page had already stated the limit honestly for the title
build: the `kind = 0x4` instances "draw at α=0 and so never appeared in the
capture, but their placement in the list is consistent with the grouping".
* **The order is in the file anyway.** `examples/paint_order_audit` on
`GP_TITLE.pak` entry 11: `derived == measured : YES`, `inverted pairs: 0`,
**`same-layer-key ties: 0`**. The glows and the logos carry *distinct* T8aD
layer keys (`0xa100` vs `0xa110` — [key](ui-paint-order-key.md)), so the glow
half sorts before the logo half **statically**, with no capture involved. The
relationship I called unobservable is decoded.
What survives is narrower and worth keeping: **a draw capture of this screen
cannot cross-check the glow-vs-logo half of the order**, because the two halves
never co-occur. The capture validates the order *within* each half only. So the
line in [`ui-paint-order-key.md`](ui-paint-order-key.md) — "read off the live
child list, checked against a draw capture" — has that reach limit on the splash.
The order itself is not in doubt; the *capture's* power to confirm all of it is.
The ✅ above for element 0 is unaffected: it is a direct draw observation.

View File

@@ -4,7 +4,7 @@
**reassembled from the disc**: the tutorial PAUSE menu rebuilds pixel-accurately from its
sprites placed at the coordinates in their `.rat` records — no fitting, no manual nudging.
![real vs rebuilt](captures/ui-layout/pause-tutorial-real-vs-rebuilt.png)
![real vs rebuilt](../captures/ui-layout/pause-tutorial-real-vs-rebuilt.png)
*Left: the running game (Canary screenshot). Right: rebuilt from `GP_PAUSE_MENU.pak` alone.
The remaining differences are the animated frame/glow sprites (`*eff*`) that were not
@@ -157,7 +157,7 @@ game, twice, in that order — the records were never fitted to the picture.
The same method run against `GP_TITLE.pak` reproduces the **main menu**, which is a
different screen with a different item count and a different pitch:
![main menu real vs rebuilt](captures/ui-layout/title-mainmenu-real-vs-rebuilt.png)
![main menu real vs rebuilt](../captures/ui-layout/title-mainmenu-real-vs-rebuilt.png)
`ptbtn01..05.rat` give X = 542 for all five and Y = 162 / 242 / 322 / 402 / 482 — an
**80 px** pitch, where the pause menu used 70. Measured against the screenshot, the sprite
@@ -695,4 +695,64 @@ rule, and the kind of moderate split that would be easy to over-read.
rather than untried, and the field is at least correctly *sized*. Assigning
meanings almost certainly needs the game observed with individual bundles
loaded, not more static correlation — every property visible in the file has now
been tried.
been tried.
## A leaf record's last keyframe time is the chunk terminator (2026-08-28)
The build placement region's "a group's data stops 4 bytes short of its final
block's time slot — that word is already the next group's element index" has a
second form in **leaf `.rat` records**, where there is no next group.
`ptbtn00f.rat` (`GP_TITLE` build 2, `RATC` at `0x04115e`) holds 8 keyframe blocks
of the ordinary 40-byte layout starting at `+0x68`. The eighth block's time slot
at `+0x1a4` contains **`end `** — the record's ASCII terminator. So the last
keyframe's time is not merely unread, it is **not present**, and any animation's
full cycle length has to come from measurement.
❔ The word at `+0x004` is **not** the keyframe count: `0x003c0000` (60) with 8
keyframes here, `0x001e0000` (30) with 3 in `ptloop01.rat`/`ptloop02.rat`.
## ✅ `scale = 0` means COLLAPSED, not "unset" (2026-08-28)
`ui_layout::blit` and `fill_quad` both used to open with
```rust
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
```
so an element whose pose is *collapsed to nothing* was drawn at **full size**.
(The `.max(1)` two lines below never saw a zero — see [METHOD.md](../METHOD.md).)
### The control, run before the change
If `0` meant "unset", some element would use it throughout. Over the disc
([`data/zero-scale-census.txt`](../data/zero-scale-census.txt)):
| | |
|---|---|
| elements with a keyframe group | 15 493 |
| at least one keyframe at scale 0 | 2 166 |
| **every** keyframe at scale 0 | **0** |
| zero is a transient the element grows back out of | **1 762** |
Not one element on the disc is zero-scale throughout, and 1 762 grow out of zero
— `ptlogo_eff3.t32` runs `0 % → 200 %`. **An "unset" marker that nothing ever
uses throughout is not a marker.** Zero means zero.
### The fix, and its reach
Both functions now return without drawing when either scale is 0.
* **Renders: 24 of 24 byte-identical** across `GP_TITLE` (all 16 builds),
`GP_PAUSE_MENU` and `GP_OPTIONS`. The change is additive
on every screen the port needs.
* 195 elements have a guessed rest pose at scale 0; **126 of those had a non-zero
alpha**, so the old code painted them at full size.
* ⚠️ **All 126 are in `GP_READY_ROOM.pak`** — the archive
[S1](../ready-room-probe.md) already declared a no-go. They are tactical-map
sprites (`pbacropolis`, `pbFriendly`, `pbr_enemy`) at `sx=0, sy=100`, i.e.
mid-horizontal-wipe, which the old coercion drew full-width.
🟡 So this is a **real correctness fix with no visible effect on the menu port**.
It matters for anything that later reads `GP_READY_ROOM`, and it removes a case
where the renderer contradicted the format.

View File

@@ -0,0 +1,185 @@
# 🟡 Our composite is brighter than the emulator's frame — measured, not decoded
**Status:** 🟡 **measured, with a narrow reach and a live confound.** Closes an
observation left dangling by
[ui-8ax-fullres-background](ui-8ax-fullres-background.md) ("the capture is ~4×
darker than the render"), and puts a number on the ❔ that
[INDEX](../INDEX.md)'s texture row already carried: *"exact fidelity
(gamma/sRGB curve, premultiplied alpha, per-channel scale) is untested, since a
hue comparison cannot see it."*
⚠️ **This is not a decode.** A port applying it is authoring a value.
## First: the geometry is right
Cross-correlating `live-main-menu.png` against our render over ±6 px finds the
best alignment at exactly **dy = 0, dx = 0**, correlation **0.9466**. So the
composite is in the right place at the right size and only the *tone* differs.
(The capture is 1279×675 and top-aligned; that is the screenshot tool's crop.)
## 🔴 The first two methods were wrong, and both failed visibly
* **Three dark patches** gave "capture ≈ 4× darker". Over the whole frame the
best linear scale is **0.914**. Three patches from one region are not a
transfer curve.
* **A pixel-wise fit** over 854 685 pixels produced a non-monotonic transfer
(render 96127 → capture *143*, brighter than render 128159 → 132). That is
the signature of **edge misalignment**, not of a tone curve: at a 0.947
correlation a bright render pixel routinely lands on a dark capture pixel.
Mean abs error was 1014 for every model, which is the tell that none of them
fit.
Both are recorded because the second is the interesting failure — a fit whose
*residual* is large everywhere is not a model to choose between, it is a method
to throw away.
## The method that works: flat patches only
16×16 patches where **both** images have `std < 8`, so local edges cannot
contribute. The threshold is not arbitrary — at `std < 3` there are **zero**
patches, and the count runs 0 / 83 / 404 / 1055 / 1788 for `std <`
3 / 5 / 8 / 12 / 20.
| screen | flat patches | gamma exponent | mean abs err | best linear | its err |
|---|---|---|---|---|---|
| main menu | 404 | **1.491** | 0.28 | 0.276 | 0.34 |
| `EXTRAS` | 382 | **1.493** | 0.22 | 0.273 | 0.28 |
| title | 506 | **1.338** | 1.08 | 0.842 | 9.02 |
So `capture ≈ 255·(render/255)^γ` with **γ ≈ 1.34 1.49**.
## ⚠️ The reach — and it is narrow
* **The flat patches are almost all dark**: render values ~060. Over that range
a gamma and a linear scale are nearly indistinguishable — on the two menus the
errors are 0.28 vs 0.34 and 0.22 vs 0.28, which decides nothing. **Only the
title separates them** (1.08 vs 9.02), because its flat regions reach ~60.
* **The held-out control could not test it.** Running the same fit on the
developer splash gives 2 918 flat patches whose render range is **04** — pure
black. Every model scores ≈ 0.00 there. That is a control that failed to
discriminate, not a control that passed.
* **Nothing here constrains midtones or highlights**, which is exactly where a
γ = 1.4 curve does its visible work.
## 🔴 The confound as I stated it is REFUTED — canary applies no gamma of its own
I wrote that "canary applies its own output transform: `kernel_display_gamma_type
= 2` — BT.709". **That is not what the cvar does.** Reading the source:
```cpp
void VdGetCurrentDisplayGamma_entry(lpdword_t type_ptr, lpfloat_t power_ptr) {
// 1 - sRGB. 2 - TV (BT.709). 3 - use the power written to *power_ptr.
// Anything else - linear.
// Used in D3D SetGammaRamp/SetPWLGamma to adjust the ramp for the display.
*type_ptr = cvars::kernel_display_gamma_type;
...
```
It is a **getter the guest calls** (`xboxkrnl_video.cc`, exported `kStub`). The
cvar is a value *reported to the game*, which then builds its own ramp. The
emulator's role is downstream and faithful:
* the guest writes its ramp to the `DC_LUT` registers;
* `command_processor.cc` reads them into `gamma_ramp_256_entry_table_`;
* the **swap** (present) path applies them —
`swap_apply_gamma_pipeline_layout`, with `apply_gamma_table.ps` /
`apply_gamma_pwl.ps` compiled in.
So there is no emulator-side BT.709 post-process to subtract. Any gamma in the
captured frame is a ramp **the game installed**.
### 🟡 What that does and does not settle
✅ The stated confound is gone: the measured exponent is not an emulator artefact
bolted onto the game's output.
**The game DOES query the display gamma — measured 2026-08-29.** Booted with
`--log_mask=12 --log_level=3` (Kernel logging on, Cpu/Gpu off), which changes
nothing about the output. `VdGetCurrentDisplayGamma` is called **once at video
init**, between the command-buffer setup and `VdSetDisplayMode`:
```
d> F8000008 VdGetSystemCommandBuffer(701CF830, 701CF804)
d> F8000008 VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))
d> F8000008 VdSetDisplayMode(40000000)
d> F8000008 VdGetCurrentDisplayInformation(701CF110)
```
**The control is in the same log:** 359 `VdRetrainEDRAM` and 358
`VdGetSystemCommandBuffer` lines, so an absent call would have been visible.
Evidence in [`data/gamma-call-evidence.txt`](../data/gamma-call-evidence.txt).
Per the export's own comment the returned type is "used in D3D
SetGammaRamp/SetPWLGamma", so the game asks the question a ramp-builder asks, at
the moment one would ask it.
### 🟡 The ramp write: inferred from a closed chain, not directly observed
The write is a GPU register operation (`XE_GPU_REG_DC_LUT_RW_INDEX` in
`CommandProcessor::WriteRegister`), invisible to kernel logging and unlogged in
any case. But two facts from the source close the reasoning:
**1. The swap-path gamma stage is a pure LUT — nothing else.**
`apply_gamma_table.xesli` is the whole transform:
```
uint3 apply_gamma_input = uint3(texel_fetch(source, pixel).rgb * 255.0 + 0.5);
apply_gamma_output.r = texel_fetch_buffer(xe_apply_gamma_ramp, input.r).b;
… .g = …(input.g).g; … .b = …(input.b).r;
```
An index into a 256-entry table. No sRGB encode, no second transfer function.
**2. The table defaults to IDENTITY.** `CommandProcessor::Initialize` fills it
with `value = i * 0x3FF / 0xFF`, and its own comment says so: *"Initialize the
gamma ramps to their default (linear) values — taken from what games set when
starting with the sRGB (return value 1) `VdGetCurrentDisplayGamma`."* An unwritten
ramp is a no-op.
So: the only transform is a LUT; the LUT is identity unless the guest writes it;
the game queries the display gamma at init (measured above); and the captured
frame differs from our composite by γ ≈ 1.341.49, which identity cannot produce.
**⇒ the guest wrote a non-identity ramp.**
⚠️ **This is an inference, and here is its weak joint.** It assumes our composite
faithfully reproduces the *pre-ramp* framebuffer, which it does not exactly — our
renderer has its own inaccuracies. What makes it hold up is the *shape*: a
systematic exponent near 1.4, fitted on flat patches, consistent across three
screens, is not the signature of a compositor bug.
A fixed sRGB stage elsewhere in the presenter is the obvious alternative and does
not fit: an sRGB **encode** (≈ `^0.45`) brightens, and we measured darkening; an
sRGB **decode** (`^2.2`) darkens far more than 1.4.
**Direct observation is still available and cheap**, and needs the emulator only
to boot: a GPU trace records gamma ramps as their own command type
(`TraceWriter::WriteGammaRamp`), or one log line at
`XE_GPU_REG_DC_LUT_RW_INDEX` would settle it outright. Not done.
⚠️ And the ramp a game builds depends on the display type it is *told*. Canary
hard-codes `2` (TV/BT.709); on hardware that is the console's display setting. So
the exponent is display-profile dependent **by design**, not a fixed property of
the game.
### ✅ The corrected next experiment
My planned run — set `kernel_display_gamma_type = 0` and re-fit — was the wrong
design: it changes what the *guest* is told and therefore which ramp the *game*
builds, so it could never isolate an emulator stage that does not exist. It also
perturbs the capture harness, because `skip_intro.sh` classifies movie-vs-static
on an **absolute** rmse threshold and a brighter frame biases it (see
[capture-harness-status](../capture-harness-status.md)).
The right run changes nothing about the output: boot with `LOG_MASK=12
LOG_LEVEL=3` (both are needed — kernel calls log at Debug) and look for
`VdGetCurrentDisplayGamma` and the `DC_LUT` writes. Same frames, no perturbation.
**Run, and it answered the first half** — see above. ⚠️ And note it needed the
emulator only to **boot**, not to reach a menu: video init happens in the first
seconds. This had been parked behind the title-screen blocker for no reason.
## What a port should do with this
Treat it as **authored**, not transcribed. If the goal is to match the emulator —
which is what every capture in this corpus is — a γ ≈ 1.4 darkening of the
composite gets closer, and is best applied where it was measured (the dark
background), not extrapolated to the whole range on this evidence.

View File

@@ -153,3 +153,500 @@ invisible — in the literal sense — until `fade` is applied.
0 and never hold at a visible value, so the plateau rule rests them at alpha 0
— invisible. That is *probably* right for a settled screen, but the capture
cannot confirm it while `fade` is unapplied.
## 🔴 `rest_plateau` is wrong for elements with no exit animation (2026-08-28)
Reported by the port agent with a capture that proves it, and it affects
`sylpheed-cli screen render` too — this is not only a port concern.
`rest_plateau` drops a trailing run of identical keyframes because that run is
normally the **exit** animation. On an element that has **no exit**, the trailing
run *is* the hold, and dropping it puts the element back at its **first**
keyframe — off-position and transparent.
**The condition that identifies these exactly** (no false positives across the
port's whole export):
> the final untimed keyframe has the same pose as the last timed one
**Six elements** on `main_menu` match it and `rest()` misses all six. The
visible cost: on `main_menu` the timeline render and the `rest` render differ in
exactly one region — **400 × 470 at (440,108)**, the bounding box of `ptframe1`
and `ptframe2` and nothing else. That is the **bright circuit bracket around the
menu**, plainly present in
[`../captures/main-menu-oracle.png`](../captures/main-menu-oracle.png) and absent
from the `rest` render. Cropping the same region from the capture and from both
renders puts the ring and its elbow trace pixel-aligned with the game's in the
timeline render.
This also explains a long-standing ❔ on
[`ui-paint-order-key.md`](ui-paint-order-key.md): *"`ptframe1`/`ptframe2` rest at
`0x00ffffff` (alpha 0) and are therefore not drawn, but the capture shows the menu
frame plainly."* Same two elements, same cause — now identified.
## ✅ Fixed 2026-08-28 — but the condition is the ALPHA, not the pose
The report's proposed test — *"the final untimed keyframe has the same pose as the
last timed one"* — **misfires**, and on the exact case the exclusion was written
for. `pgptitle.rat`'s last two keyframes are also identical:
```
pgptitle.rat kf4: fade=0x00ffffff pos=(220,69) t=30
kf5: fade=0x00ffffff pos=(220,69) t=None <- same pose
```
Adopting it as stated would erase the word PAUSE again. What separates the two is
**visibility**:
| | trailing run | alpha | is it the hold? |
|---|---|---|---|
| `ptframe1`/`ptframe2` (main menu) | 3 × `0xffffffff` at (440,108) | `0xff` | **yes** — no exit animation |
| `pgptitle` (pause menu) | 2 × `0x00ffffff` | `0x00` | no — it is the fade-out |
An exit fades the element out, so its last keyframe is transparent; an element
with no exit ends on the pose you can see. **So a trailing run is the hold exactly
when it is visible**, and that is what `rest_plateau` now tests.
### Verified against a capture, not against another renderer
| check | result |
|---|---|
| `ptframe1` rest | `(620,108) t=16`**`(440,108) t=62`** |
| bracket region draws | mean 61.74 → **62.79** |
| **oracle correlation** over that region vs [`main-menu-oracle.png`](../captures/main-menu-oracle.png) | **0.9596 → 0.9748** |
| pixels changed, whole frame | 10 082, bounding box **x 440839, y 108577** — exactly the 400 × 470 at (440,108) the report predicted |
| **regression control**: PAUSE wordmark, 3 pause builds | **unchanged** (2833 / 2858 / 2833 bright px) |
**`cargo test -p sylpheed-formats` with `SYLPHEED_DISC` set: 131 passed, 0
failed** across 6 binaries including the disc-gated ones. (One pre-existing
`ignored` — the XBG7 shared-resource test — unrelated.)
### ✅ The disc-wide check
Both rules reimplemented over the parsed keyframes of **every** `RATC` bundle on
the disc — 2 859 bundles, 13 991 elements with ≥ 2 keyframes:
| | |
|---|---|
| elements whose `rest` moves | **30** (0.21 %) |
| invisible → visible | **4** |
| **visible → invisible** | **0** — the safety property |
The change is surgical and it never hides something that was being drawn. The
remaining 26 move `rest` between two *visible* poses (position, not visibility).
The 4 revealed are `ptframe1.t32` and `ptframe2.t32` in `GP_TITLE` **entry 5 and
entry 8** — the same two elements, once per language build.
### 🟡 Reconciling with the report's "six elements"
On the **English main menu exactly two** elements satisfy the report's condition
(last two keyframes identical), and both are `ptframe1`/`ptframe2`. So the six
must span its whole 12-screen export, not that one screen — consistent with its
own observation that the timeline and `rest` differ *"in exactly one region …
the bounding box of `ptframe1`/`ptframe2` and nothing else"*.
**Open, and it matters:** if any of the other four have a **transparent**
trailing run, this rule deliberately leaves them alone — that exclusion is what
protects the word PAUSE. If a capture shows one of them drawn, the alpha rule is
incomplete and needs a third discriminator. **Which screens are they on?**
---
## 🔴 The dwell fallback is unsound whenever it actually runs (2026-08-28)
`rest()` tries `rest_plateau()` first and, failing that, picks the keyframe with
the longest **dwell** — the largest gap `t[k+1] t[k]`. That rule is not sound,
and the reason is structural rather than a tuning problem.
A gap between `t[k]` and `t[k+1]` is time the element spends **interpolating from
pose `k` to pose `k+1`**. Neither pose is *held* during it — unless the two poses
are equal, which is exactly a plateau, and the plateau path has already handled
that case and returned. **So by the time the fallback runs, it is guaranteed that
no pose is held, and the rule is choosing an endpoint of a movement.**
### The element that exposed it
`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom:
```
46: (98,42) 100%,100% a=0
61: (108,72) 0%,0% a=0
103: (108,72) 200%,200% a=255 r=80
-: (108,72) 0%,0% a=0 r=150
```
No two adjacent poses are equal, so there is no plateau. The longest gap is
`61 → 103` (42 units), during which the sprite grows from nothing to **200 %** at
full alpha while rotating 80°, then collapses again. The rule returns whichever
end of that movement the indexing lands on:
| | returned "rest pose" |
|---|---|
| as decoded | `(108,72) 0%,0% a=0` — invisible |
| with `SYLPHEED_KF_TIME_SHIFT=1` | `(108,72) 200%,200% a=255` — the peak |
An 896×389 sprite at 200 % scale is 1792×778 — larger than the screen. Painting
it permanently is what made build 7's render 13.1 % different and 4.9 luminance
units brighter.
**The element has no resting pose.** It is a flash; after it plays there is
nothing. Neither answer is *derived* — one of them is merely harmless.
### 🔴 What this retracts
Last iteration I reported the build 7 render difference as evidence **against**
the keyframe-time shift, on the reasoning that language twins should match in
brightness. **Withdrawn.** The difference is not about the time association at
all: it is the dwell fallback guessing, and it would guess on this element under
any reading of the times. The brightness comparison was measuring a heuristic,
not a decode.
What that leaves: the case *for* the shift (a factor of 26 on the hold:fade-out
ratio, [`ui-keyframe-time-unit.md`](../ui-keyframe-time-unit.md)) is no longer
opposed by render evidence — 10 of 11 builds are byte-identical and the 11th
differs only through an unsound heuristic.
🟡 **It is still not adopted**, for a different reason than before: adopting it
would flip this element to the visibly wrong answer, so the shift and a decision
about what `rest()` should do for plateau-less elements have to land together,
and the second half has no capture to verify against either.
### ⚠️ What the port should take from this
Any element whose keyframes contain **no two adjacent identical poses** has a
`rest()` result that is guessed, not decoded — in our renderer and in anything
built from it. That is a property a consumer can test for itself in one pass over
the keyframes, and it is worth flagging in an export rather than silently
inheriting our guess.
## The size of the defect, and why it does not block the menu port
[`tools/re-capture/plateau_census.py`](../../../tools/re-capture/plateau_census.py)
walks every `GP_*.pak` placement region directly (the CLI route decodes every
texture and is far too slow for a disc-wide pass). Its control reproduces
`GP_TITLE` build 7's three fallback elements and names `ptlogo_eff3.t32` among
them before it counts anything. Output: [`data/plateau-census.txt`](../data/plateau-census.txt).
| | |
|---|---|
| elements with a keyframe group, disc-wide | **15 493** |
| no plateau | 3 807 (24.57 %) — ⚠️ **overstated, see the correction below** |
| …of those, **single-keyframe** (trivially at rest, not a guess) | **1 502** |
| **genuinely ambiguous** (2+ keyframes, no plateau) | **2 305 (14.88 %)** |
| of those, current rule returns an **invisible** pose | 1 711 (44.9 %) |
| of those, current rule returns a **zero-scale** pose | **195 (5.1 %)** |
| the two candidate rules **agree** | 1 911 (50.2 %) |
A returned pose with `scale = 0 %` is not a pose at all, and 195 elements get
one. Disc-wide the choice of rule is not cosmetic: the candidates agree only half
the time.
### ✅ But on the five screens the port needs, the exposure is one element
| screen | plateau-less | rules differ |
|---|---|---|
| main menu (entry 5) | 5 / 16 | 0 |
| `EXTRAS` (entry 6) | 5 / 18 | 0 |
| title (entry 4) | 2 / 24 | 0 |
| developer splash (entry 11) | 2 / 7 | **1** |
Thirteen of the fourteen affected elements get the same answer either way. The
one disagreement is `palogo_anima_eff.t32`.
### 🔴 And "rest = last keyframe" is refuted on it
That was the alternative I named last iteration. The developer splash carries
**three sibling glows**, identical in structure and in every time:
```
palogo_gamearts_eff 15:a=0 30:a=255 45:a=255 -:a=0 → plateau → visible
palogo_seta_eff 15:a=0 30:a=255 45:a=255 -:a=0 → plateau → visible
palogo_anima_eff 15:a=0 30:a=255 45:a=212 -:a=0 → no plateau
```
They differ in **one byte**`212` where the others have `255`. Under "last
keyframe", `anima_eff` alone goes invisible while its two siblings stay lit. A
rule that makes one of three parallel elements behave differently because of a
single alpha count is producing an artefact, not a decode.
The capture agrees weakly. Comparing box means in
[`live-splash-developer.png`](../captures/title-builds/live-splash-developer.png)
against our render (the screenshot is 1279×675, top-aligned, so only ratios are
comparable): gamearts **0.717**, seta **0.723**, anima **0.772**. If our render
were adding a glow the game does not draw, anima's ratio would sit *below* its
siblings'. It sits above.
### 🟡 Where this leaves it
The **defect** is established and measured: `rest()` guesses for **14.88 %** of
elements disc-wide and returns a degenerate zero-scale pose for 195 of them.
The **fix** is not decided — "last keyframe" is refuted, and the current rule
survives on the only captured element that discriminates. ⚠️ For the menu port
specifically this is **not a blocker**: one element on one screen, and our
current answer for it is the defensible one.
---
## 🔴 The 24.57 % was overstated, and my proposed fix is refuted
**2026-08-29.** Two corrections, both from one experiment.
### The prediction: draw nothing for an element with no held pose
A keyframe group is **entry → hold → exit**, and the exit ends invisible — on the
five port screens the final keyframe is invisible for **21/24** (title),
**8/16** (main menu), **12/18** (`EXTRAS`), **2/3** and **6/7** (splashes). So
the screen "as seen" is the *hold*, which is why `rest_plateau` is the right
primary rule and why "rest = the last keyframe" would empty every screen.
That suggested a fix: an element with **no** hold has no representative pose, so
draw nothing rather than guess an endpoint. Tested via `compose`'s `visible` mask
(`examples/plateauless_suppression.rs`), correlated against the live captures:
| screen | as-is | plateau-less suppressed | Δ |
|---|---|---|---|
| title | +0.9500 | +0.6839 | **0.2661** |
| main menu | +0.9460 | +0.9037 | 0.0423 |
| `EXTRAS` | +0.9440 | +0.9094 | 0.0346 |
**Refuted, decisively, on all three.**
### Why — and it invalidates the headline number
An element with a **single keyframe** has no *adjacent pair*, so the plateau test
marks it plateau-less. But a single-keyframe element is not animated at all: its
one pose *is* its rest, unambiguously. Suppressing those removes backgrounds and
full-screen layers, which is where the title's 0.27 comes from.
The same flaw is in the census this page published:
| | |
|---|---|
| no plateau (as published) | 3 807 (24.57 %) |
| …of which **single-keyframe** | **1 502** — trivially at rest |
| **genuinely ambiguous** | **2 305 (14.88 %)** |
So the guessed-rest population is **2 305, not 3 807** — the published figure
overstated it by **65 %**. The defect is real and smaller than reported.
---
## ✅ Where the fallback actually fires — and "last keyframe" wins there
**2026-08-29.** Two things fell out of scoring rules by rendering instead of
arguing about them.
### The port's exposure is TWO elements, not fourteen
The guessing fallback is reached only by an element that is plateau-less **and**
has more than one keyframe — a single-keyframe element short-circuits at
`match len { 1 => first }`. Per screen:
| screen | elements | plateau-less | **reach the fallback** |
|---|---|---|---|
| title (4) | 24 | 2 | **0** |
| main menu (5) | 16 | 5 | **0** |
| `EXTRAS` (6) | 18 | 5 | **0** |
| publisher splash (10) | 3 | 2 | **1** |
| developer splash (11) | 7 | 2 | **1** |
On the three screens a port cares most about, **`rest()` never guesses at all**.
That is why three different fallback rules render builds 4, 5 and 6 to *identical*
correlations (+0.9500 / +0.9460 / +0.9440 for every rule) — the code is
unreachable there.
### On the two splashes, the last keyframe is markedly better
Rendering under each rule (`SYLPHEED_REST_RULE=last|maxalpha`, default off) and
correlating against the live captures:
| splash | dwell (current) | **last** | maxalpha |
|---|---|---|---|
| publisher | +0.9600 | **+0.9982** | +0.9600 |
| developer | +0.9643 | **+0.9758** | +0.9643 |
`maxalpha` is indistinguishable from the current rule; **`last` gains +0.038 and
+0.012**, and 0.9982 is very close to exact.
### 🔴 This refutes my own refutation
I had killed "rest = the last keyframe" by argument: on the developer splash it
makes `palogo_anima_eff` invisible while its two siblings stay lit, which looked
like an artefact. **The capture says otherwise** — making it invisible is what
improves the match. The sibling symmetry was my expectation, not evidence.
⚠️ **The caveat that keeps this honest:** both captures are *single frames of a
transient animation*. They fix which pose matches **these reference frames**, not
which pose is canonically "at rest" — a grab a second earlier would show the
glows. What the measurement supports is: for reproducing the committed captures,
use the last keyframe.
🟡 **Default unchanged.** The rule is better on both screens where it fires and
provably identical on the other three, but it would alter the rest pose of
**2 305** elements disc-wide and two of them have been measured. Reachable via
`SYLPHEED_REST_RULE=last` for anyone who wants to take that further.
✅ Unaffected by the earlier correction: all **195** zero-scale rest poses are
inside the genuinely-ambiguous 2 305 — none is a single-keyframe element.
### The disc-wide blast radius — measurable even though the rule is not scorable there
Only two ambiguous elements sit on a screen with a live capture, so the rule
cannot be *scored* disc-wide. What can be quantified is what changing it would
do ([`tools/re-capture/rest_rule_blast_radius.py`](../../../tools/re-capture/rest_rule_blast_radius.py),
[data](../data/rest-rule-blast-radius.txt)):
| | dwell (current) | `last` |
|---|---|---|
| the two rules **differ** | **1 896 of 2 305 (82.3 %)** | |
| returns an **invisible** pose | 1 711 (74.2 %) | 1 618 (70.2 %) |
| returns a **zero-scale** pose | **195 (8.5 %)** | **43 (1.9 %)** |
Two things worth reading off this.
**It is not a marginal choice.** The rules disagree on 82 % of the affected
elements, so "either is fine" is not available.
**The current rule produces 4.5× more degenerate poses.** A zero-scale pose is
*collapsed to nothing* ([rat layout](ui-rat-layout.md)) — an element's **pre-roll**,
before it has grown in. A pre-roll is definitionally not a rest, so 195 elements
currently rest at a frame the element is only passing through, against 43 under
`last`. That is an argument from the data's own structure rather than from the
two captures, and it points the same way they do.
🟡 It remains **indirect**: fewer degenerate results is not the same as more
correct results, and `last` still returns an invisible pose 70 % of the time —
which for a transient element may well be right, and for a persistent one would
be wrong. The default stays put.
---
## ✅ Why `rest_plateau` is right, and why `last` is right only for a transient
**2026-08-29.** The shifted keyframe-time reading
([time unit](../ui-keyframe-time-unit.md)) appears to predict something simple:
the final pose is reached at a definite time and nothing follows, so "rest"
should just be the last keyframe, with no heuristic at all. Tested
(`SYLPHEED_REST_RULE=lastall`, bypassing the plateau rule for **every** element):
| screen | current | `lastall` | Δ |
|---|---|---|---|
| title | +0.9500 | +0.6819 | **0.2681** |
| main menu | +0.9460 | +0.6416 | **0.3044** |
| `EXTRAS` | +0.9440 | +0.5745 | **0.3695** |
| publisher splash | +0.9600 | **blank** | — |
| developer splash | +0.9643 | **blank** | — |
The splashes render *entirely empty* — zero variance, so the correlation is not
even defined. **Refuted.**
### What the failure shows
A group's keyframes describe **entry → hold → exit**, and the exit is the
screen's *dismissal*. While a screen is on display it has not reached its last
keyframe; it is sitting at the hold, and the exit plays only when the screen
leaves. That is why:
* `rest_plateau` is the correct primary rule — the hold is where the screen
lives, and it is what a capture of a displayed screen shows;
* the last keyframe is the **post-exit** state, correct only at the moment the
screen is already gone — which is why applying it everywhere blanks the
splashes;
* and it does **not** contradict the shifted time reading. That reading says when
each pose is reached; it says nothing about the group being played to
completion while the screen is still up.
### And it explains why `last` wins for the plateau-less two
An element with **no hold** is a transient: it flashes and is over. At any
settled moment it is *gone*, and "gone" is its last keyframe. That is not a
general rule about rest — it is what a transient's settled state *is*.
The draw capture says exactly this independently: on the developer splash the
`_eff` glows are drawn on frames **94115** and the logos on **116211**. The
glows are already finished when the logos are up, so a capture of the logo
moment must show them invisible — which is what `SYLPHEED_REST_RULE=last`
produces, and why it scored +0.9982 and +0.9758.
✅ So the picture is coherent across three independent observables — animation
timing, static composites, and the per-frame draw log — and the rule it supports
is: **plateau where there is one, last keyframe where there is not.**
### ✅ The structural case for `last`, disc-wide — 2 293 of 2 305
Only two ambiguous elements could be *scored* against a capture. But the
entry → hold → exit model makes a prediction that can be checked on all 2 305:
what does each element's **final** keyframe look like?
([`tools/re-capture/plateauless_endstate.py`](../../../tools/re-capture/plateauless_endstate.py),
[data](../data/plateauless-endstate.txt))
| final keyframe | count | is `last` the right answer? |
|---|---|---|
| **invisible** (`a = 0`) | **1 618** | the element is a transient; at any settled moment it is *gone* — ✅ model-consistent, and confirmed by the draw log for the two observed |
| **visible, at maximum alpha** | **675** | it faded in and stopped — the final pose *is* the settled one ✅ |
| visible, **below** maximum alpha | **12** | ❔ genuinely unclear |
Of the 687 that end visible, **472 have monotonically non-decreasing alpha** — a
plain fade-in that stops, `[0, 255]` over two keyframes in the commonest case
(`pjex_eff.rat`, `pghud_speed_cut.t32`) — and another 203 end at their maximum
after dipping. Only **12 elements in 2 305** end visible at less than full alpha.
So `last` is **structurally defensible for 2 293 of 2 305 (99.5 %)**, against a
dwell rule that returns a mid-movement frame by construction. It is *observed*
correct for 2, *structural* for 675, *model-consistent* for 1 618, and unclear
for 12.
⚠️ The 1 618 carry the weight of an assumption, and it should be visible: that a
plateau-less element's animation has finished by the time the screen is settled.
The draw log establishes this for the two splash glows (drawn on frames 94115,
with the logos on 116211) and nothing establishes it for the rest.
---
## ✅ A static "rest" composite is only meaningful for a screen that SETTLES
**2026-08-29.** The model's sharpest prediction, tested with its control.
The draw log says that on the developer splash the `_eff` glows are drawn on
frames **94115** and the logos on **116211** — so at the moment the reference
capture was taken, *every* glow is already finished, including the two that have
plateaus and which `rest_plateau` therefore renders visible. Suppressing them
should improve the match. Suppressing them on a screen that genuinely settles
should make it worse.
| screen | as-is | `eff` elements suppressed | Δ |
|---|---|---|---|
| **publisher splash** | +0.9604 | **+0.9982** | **+0.0377** |
| **developer splash** | +0.9659 | **+0.9980** | **+0.0321** |
| title *(control)* | +0.9500 | +0.9480 | 0.0020 |
| main menu *(control)* | +0.9460 | +0.8544 | **0.0916** |
| `EXTRAS` *(control)* | +0.9440 | +0.8370 | **0.1070** |
Both splashes jump to **≈0.998**; all three persistent screens get worse. The
control is what makes this a finding rather than a coincidence — the same edit
helps on exactly the screens the model says it should and hurts on exactly the
ones it says it should not.
### What this settles
**`rest_plateau` is not over-drawing in general — it over-draws on transient
screens.** A plateau in the middle of an animation is evidence the element is
held *at that point in the timeline*, not that it is on screen when the screen
has settled. On a screen that settles, the held pose is the settled pose and the
rule is right (the controls). On a screen that plays through and leaves, there is
no settled moment at all, and the reference capture is a picture of one arbitrary
frame.
⚠️ **So the honest answer to "what is the rest pose of a transient screen" is:
there isn't one.** The splashes are animations. A static composite of them can
match a chosen frame — ≈0.998 for the frame these captures hold — but "the
resting pose" is a question the format does not answer because the screen never
rests.
✅ For the port: **play the timeline for the splashes**, which the settled
keyframe timing now supports ([time unit](../ui-keyframe-time-unit.md)). Use a
static composite for the title, main menu and `EXTRAS`, where the screen does
settle and `rest_plateau` is measurably right.

View File

@@ -507,7 +507,7 @@ run scan that builds the candidate list — which does not emit a start for thes
resources' real vertex buffers. That is where the remaining 51 live.
The ignored test
[`mesh_consistency_disc.rs`](../../crates/sylpheed-formats/tests/mesh_consistency_disc.rs)
[`mesh_consistency_disc.rs`](../../../crates/sylpheed-formats/tests/mesh_consistency_disc.rs)
still asserts the target state and now records 63 rather than 125; the remaining
cases are where the neighbourhood is itself wrong or absent.

View File

@@ -0,0 +1,78 @@
# 🟡 `build-reborn test` takes about an hour — and I left two runaways
**Status:** ✅ measured. Two facts, one of them my own mess.
## The gate the brief names does not complete
`build-reborn test` (`cargo test --workspace` with `SYLPHEED_DISC` wired up) is
the verification gate. It contains a disc test that decodes **every `.xpr` in
`hidden/resource3d` — 166 files, 1.4 GB** — through the full `Xbg7Model`
anchoring path:
```rust
#[test] // NOT #[ignore]d
fn twin_pairs_do_not_share_a_buffer() {
for f in &files { Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) }
```
Measured: one instance accumulated **3 h 26 m of CPU at 89 %** without finishing.
### 🔴 "Cannot terminate" was too strong — corrected
It terminates; it is merely heavy, and I said the stronger thing before measuring
it. Timing `sylpheed-cli mesh info` on each of the 166 files with a 25 s cap
([`data/slow-xpr-files.txt`](data/slow-xpr-files.txt)):
| | |
|---|---|
| files scanned | 166 |
| exceeding 25 s | **19**`Hangar`, 17 `Stage_*`, `ptc_pack` |
| `Stage_S02` timed to completion | **144 s**, `rc = 0` |
So nothing hangs. Nineteen heavy containers at roughly two minutes each is
**~4560 minutes** for one pass, before the 147 fast ones. The observed 3 h 26 m
was that work running at a **load average of 914** — inflated by my own two
duplicate runs competing with each other and with this one. The runaways did not
merely coexist with the slowness; they multiplied it.
⚠️ The practical conclusion is unchanged and only the wording softens: an
hour-scale suite is not an iteration-scale gate.
Its sibling in the same file, `shared_resources_decode_identically_in_every_container`,
walks the same 166 files and *is* `#[ignore]`d (as known-failing), so the binary's
cost is easy to underestimate from a glance at the file.
⚠️ **Consequence for anyone using this gate:** every "green run" reported in this
corpus from a workspace test is necessarily **partial** unless it explicitly says
the suite terminated. `cargo test -p sylpheed-formats` reaches ~131 tests in a few
minutes and then sits in this one; the workspace run is the same picture. Quote
the suite count and the elapsed state, not the word "green".
🟡 Not proposed here: `#[ignore]`-ing it, or bounding it to a sample of the 166.
Both change what the suite asserts, which is the project's call and not a
side-effect of an audit.
## 🔴 And I left two of them running for four hours
Two `cargo test -p sylpheed-formats` runs launched detached in earlier iterations
never exited, because they were sitting in this test. At the moment they were
found:
| pid | elapsed | child | CPU time | %CPU |
|---|---|---|---|---|
| 103375 | 4 h 12 m | `mesh_consistency_disc` | **3 h 26 m** | 89.3 |
| 99965 | 4 h 39 m | `pak_idxd_disc` | 1 h 16 m | 93.8 |
Load average **14.18** on 12 cores. Killed; load fell to **9.68** within a
minute, and the legitimately-running workspace suite was checked first and left
alone.
### 🔴 What this does NOT explain
It is tempting to pin the session's emulator troubles on this, and the
measurement says no. `screenshot` cost **0.49 s** with both runaways live and the
emulator stopped, against the **10.8 s** measured earlier with the emulator
running. So the 92× figure in
[capture-harness-status](capture-harness-status.md) really was emulator
contention, and the runaways were a background tax on top — not the cause. The
black surface and the unreachable title stand as measured, with their own
controls.

View File

@@ -109,14 +109,46 @@ fit are:
t = 16 … 269) is then **4.2 s**, and the main menu build (build 5, t = 12 … 80)
**1.1 s**.
**The reach of this negative:** the present rate was measured *during the boot
splash*, where the guest is also streaming from the ISO, so it is a lower bound on
the emulator's speed and cannot by itself exclude the 60 Hz reading. What would
settle it is the same 300-frame timing taken on the **idle title screen**, where
nothing is loading — if that also comes out near 28 fps the game is 30 Hz and the
unit is 1/60 s; if it doubles to ~55 fps the game is 60 Hz and every duration on
this page halves. Until then the port should treat **2 units per rendered frame**
as the measured fact and `1/60 s` as the working conversion.
**The reach of this negative** *(as written before the test below — kept for the
reasoning)*: the present rate was measured *during the boot splash*, where the
guest is also streaming from the ISO, so it is a lower bound on the emulator's
speed and cannot by itself exclude the 60 Hz reading. What would settle it is the
same 300-frame timing taken on the **idle title screen**, where nothing is
loading — if that also comes out near 28 fps the game is 30 Hz and the unit is
1/60 s; if it doubles to ~55 fps the game is 60 Hz and every duration on this page
halves.
## ✅ That test was run — 2026-08-28. The game is 30 Hz; `1 unit = 1/60 s`
Reached the title with a single Ⓐ to skip the intro
([`movie-binding.md`](movie-binding.md)), let it settle 12 s so nothing was
loading, then armed the bounded 300-frame capture and timed it to its own
`[UI-CAP] done` line:
| | 300 frames in | rate |
|---|---|---|
| idle title, trial 1 | 10.40 s | **28.8 fps** |
| idle title, trial 2 | *arm produced no capture* | — |
| idle title, trial 3 | 10.60 s | **28.3 fps** |
| *(prior)* boot splash | 10.87 s | 27.6 fps |
**~28.5 fps on an idle title — the same rate as the loading splash.** By this
page's own criterion that settles it: the title presents at **30 Hz**, the
timeline ticks at **60 units/second**, and **`1 unit = 1/60 s`**.
**The 60 Hz reading is now excluded, not merely disfavoured.** It would require
the emulator to be running at 47 % of real time *while sitting idle on a static
title* — and the capture says that screen costs **1 526 draws over 300 frames,
about 5 draws per frame**. Nothing there is expensive enough to halve the
emulator's speed, and the splash and the idle title returning the same rate is
exactly what a constant ~95 %-of-real-time emulator looks like.
So the durations on this page stand as written: title build 4 ≈ **4.2 s**, main
menu build 5 ≈ **1.1 s**, and `EXTRAS` build 6's declared fade-in ≈ **0.87 s**.
⚠️ Still **measured, not decoded** — no field on the disc says "sixtieths of a
second". What changed is that the measurement now has an idle-state control and
the competing reading is ruled out.
## What this does not say
@@ -129,3 +161,232 @@ as the measured fact and `1/60 s` as the working conversion.
`_sqex_eff` absence is explainable (the capture joined the SQUARE ENIX logo
mid-hold); the missing studio-anima logo is not, and is left as an open
observation for Q2 rather than a claim.
---
## 🟡 Open: the `palogo_*` LOGO elements do not play their declared timeline
**Added 2026-08-28.** Everything above stands — it rests on the `_eff` glows, and
they reproduce exactly. What follows is a **different element in the same bundle
and the same capture**, and it does not.
Reproduce with
[`tools/re-capture/splash_ramp_check.py`](../../tools/re-capture/splash_ramp_check.py);
output committed at [`data/splash-ramp-check.txt`](data/splash-ramp-check.txt).
### Why this is a test and not a fit
The clock is calibrated on `palogo_gamearts_eff` — its declared 15-unit fade-in
`0@15 → 255@30` against its captured alphas 34, 68, 102, 136, 170, 204, 238, a
constant step of exactly 34. That gives `t = 2f 171`, and the calibration
checks itself: the glow's declared hold ends at `t=45`, predicted **frame 108.0**,
and the observed last full-alpha frame is **107**.
That calibration is then applied to `palogo_gamearts` — same bundle, same frames,
**no free parameter left**:
| declared | predicted frame | observed alpha |
|---|---|---|
| `a=0` at `t=15` | 93.0 | not drawn |
| `a=0` at `t=30` | 100.5 | not drawn |
| `a=255` at `t=190` | 180.5 | 255 |
| `a=232` at `t=206` | 188.5 | **255** |
| `a=32` at `t=210` | 190.5 | **255** |
The logo is still at full alpha nine frames after it should have been at `a=32`.
Its fade-out actually runs frames **199211**, some 17 frames late, and its
declared 80-frame fade-in (`t=30→190`) is **not drawn at all** — the element's
first appearance, frame 116, is already at 255.
⚠️ "Not drawn" is not a culling artefact: the same element is submitted all the
way down to `a=7` on the way out, so low-alpha quads plainly do reach the GPU.
### The shape mismatch, which needs no calibration at all
The declared fade-out spends **12 of its 16 units** dropping only **23/255** of
the alpha — a near-flat leg — then **200/255** in the remaining 4. The captured
per-frame drops are `1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8`. There is no
near-flat leg.
### 🟡 A candidate, offered as one and NOT adopted
If the word at `+36` were the time of the **next** keyframe rather than of its own
block, the logo's fade-out would read `255@190 → 232@194 → 32@206 → 0@210`:
slow, fast, slow — which is the captured shape. Fitting both readings to the
captured fade-out gives RMS alpha error **4.05** shifted against **12.13** as
decoded, on two elements independently (`gamearts` and `sqex`).
It also removes a special case. The decoder currently notes that a group's data
"stops 4 bytes short of its final block's time slot", so the last block's time is
unreadable. Under the shifted reading the last block simply *has no successor*,
so it has no time word — the same bytes, no special case.
**Not adopted, for three reasons:**
1. It does not explain the missing fade-in, which is the larger anomaly.
2. It does not fix the 17-frame lateness of the fade-out.
3. The `_eff` elements **do not discriminate** between the two readings — I
checked: with four blocks the shift merely relabels which phase is which, and
both reproduce the observed fade-in / hold / fade-out. So the entire case for
the shift rests on one element's fade-out shape.
**The decoder is unchanged.** 🟡 What a port should take from this: the
interpolation *law* is settled (linear, 2 units per frame), and the *group
timeline* for multi-keyframe elements is not.
### 🔴 One thing I got wrong in the course of this
I first reported the `_eff` glows as holding "a constant α ≈ 33" and read that as
contradicting the declared 255 plateau. They do no such thing — they ramp 34 →
255 in exact steps of 34. I had printed only the series' **minimum** and read it
as its range. Withdrawn; the trap is in [METHOD.md](METHOD.md).
### ✅ Settled the next iteration: the hold duration decides it
The section above left the candidate unadopted because its whole case rested on
one fade-out's *shape*. There is a much blunter measurement in the same capture,
and it needs **no calibration at all** — just the ratio of two observed spans:
| | full-alpha hold : fade-out | ratio |
|---|---|---|
| **observed** | 83 frames : 13 frames | **6.38** |
| as decoded | 4 units : 16 units | 0.25 — **off by 26×** |
| `+36` = the NEXT pose's time | 160 units : 20 units | **8.00** |
With the glow's 2 units/frame fixed and nothing else free, the current reading
predicts `palogo_gamearts` holds full alpha for **2.0 frames**. The capture holds
it for **83**. The shifted reading predicts **80.0**.
That is no longer a shape argument, and it is not a fit — it is a factor of 26.
### A fifth argument, from the corpus's own behaviour
`Element::rest()` tries `rest_plateau()` first and falls back to picking the
keyframe with the longest **dwell**. Run that fallback on `palogo_gamearts`:
| reading | dwell winner |
|---|---|
| as decoded | **`a=0`** (dwell 160) — a fully transparent pose, for a logo |
| shifted | **`a=255`** (dwell 160) — the visible hold |
Under the current reading the plain dwell rule picks an invisible pose for a
publisher logo, and only the `rest_plateau` special case rescues the render. That
special case has been repeatedly troublesome — it is the one the port agent
reported a bug in on 2026-08-28. Under the shifted reading the dwell rule is
simply correct on its own.
### What the shift is, exactly
Blocks `0…n1`; `W[k]` is the word at block `k`'s `+36`. `W[n1]` lies outside the
group, because a group owns `frames·40 4` bytes.
* **as decoded:** `kf[k].time = W[k]`; the last keyframe has no time, as a special
case for the missing word.
* **shifted:** `kf[k].time = W[k1]`; the **first** keyframe has no time, and
`kf[n1]` takes `W[n2]` — which exists. Nothing is missing and nothing is
special-cased: `W[k]` is simply *the time at which pose `k+1` is reached*.
Gated by `SYLPHEED_KF_TIME_SHIFT=1`, default unchanged. See below for what
adopting it would cost.
### 🔴 …and what it costs — which is why the default is UNCHANGED
Rendering every build of six UI paks under both readings, and byte-comparing:
| | result |
|---|---|
| builds rendered identically | 10 of 11 compared |
| builds that changed | **`GP_TITLE` build 7** — 13.1 % of pixels |
Build 7 is the **Japanese twin of build 4**, and build 4 — the one verified
against a live capture — is byte-identical under both readings. So the one build
the shift moves is the one with no capture to adjudicate it. The available proxy
says the shift makes it *worse*:
| build 7 rendered | corr vs verified build 4 | mean luminance |
|---|---|---|
| as decoded | 0.6206 | **70.94** (build 4: 71.41) |
| shifted | 0.6201 | 76.32 |
Two language twins are the same artwork with different text, so their brightness
should match closely. As decoded it matches to **0.5**; shifted it is **4.9**
brighter. Correlation does not separate them (0.6206 vs 0.6201) — the luminance
does.
### The honest position
These two results are about **different things**, and both are real:
* For **animation timing** — what the port actually needs to play a screen — the
shifted reading is favoured by a factor of 26 on a calibration-free
measurement, and the current reading predicts a 2-frame hold where the game
holds 83.
* For **resting-pose selection** — what `screen render` does — the current
reading matches the EN/JP twin brightness and the shifted one does not.
They can both be true: `rest()`'s dwell fallback is a heuristic layered *on top
of* the times, and moving the times moves its tie-breaks. Adopting the shift
would mean revisiting that heuristic in the same change, and there is no capture
of build 7 to verify the result against.
**So the default stays as decoded.** 🟡 The port should treat a multi-keyframe
group's *timing* as unverified — and specifically should not expect a
2-frame hold where the game holds 83 — while `rest()` and `screen render` remain
as they are.
---
## ✅ Replicated: three elements, two screens, and the shifted reading wins every time
**2026-08-29.** The case for reading `+36` as *"the time the NEXT pose is
reached"* rested on one element's fade-out shape, then on one element's hold
duration. Both splash halves supply more, and they all say the same thing.
Phase durations in frames (2 units/frame, from the `_eff` glow ramp):
| element | screen | observed hold at `a=255` | as decoded | shifted |
|---|---|---|---|---|
| `palogo_gamearts` | developer splash | **83 f** | 8 f | **80 f** |
| `palogo_seta` | developer splash | **83 f** | 6 f | **80 f** |
| `palogo_sqex` | publisher splash | **≥ 77 f** * | 6 f | **102 f** |
\* the capture opens mid-hold at frame 1, so 77 is a floor, not the length.
The two readings predict **opposite structures** for these elements. On
`palogo_gamearts`, as decoded: `hold 8f, in 80f, hold 2f, out 6f, out 2f`
an eighty-frame *fade-in* and a two-frame hold. Shifted: `in 8f, hold 80f,
out 2f, out 6f, out 2f` — an eight-frame fade-in and an eighty-frame *hold*.
The capture shows a **83-frame hold** and no fade-in at all.
### And the elements that cannot discriminate are not contradicted
`palogo_gamearts_eff` observed `in 7f, hold 7f, out 8f`. As decoded it reads
`in 8f, hold 8f`; shifted it reads `hold 8f, out 8f`. Both fit — with only four
blocks the shift relabels which phase is which without changing any duration. So
the glows, which are what Q1's linear law was measured on, **do not argue against
the shift**; they simply say nothing about it.
### 🟡 Why the decoder's default is still unchanged
The one thing that opposes the shift is `Element::rest()` on `ptlogo_eff3.t32`,
where the shifted reading makes the longest-dwell fallback return the bloom's
200 % peak instead of an invisible frame. But that fallback is **unsound whenever
it runs** ([resting pose](structures/ui-resting-pose.md)) — it returns an
endpoint of a movement, and neither endpoint is held. Checked: the shift does not
fix it either, so this is not a case of two readings disagreeing about the times.
It is a heuristic guessing, in both.
**So the two questions are separable, and only one of them has evidence.** The
times govern animation; `rest()` picks a static pose through a rule that consults
them only after its own precondition has failed.
### ⚠️ What a port should do
For **animation timing**, read `+36` as the *next* pose's time: three elements
across two screens, each off by an order of magnitude under the other reading.
For **static composites**, nothing changes — `screen render` is unaffected, and
the five screens' correlations stand ([acceptance](five-screens-acceptance.md)).
🟡 Classified **measured, not decoded**: this is three elements in one game
screen family, not a disc-wide field check, and our own decoder still defaults to
the other reading behind `SYLPHEED_KF_TIME_SHIFT=1`.

View File

@@ -0,0 +1,739 @@
# Which `GP_TITLE` build is which screen — measured against the running game
**Status:**`CONFIRMED` for the four screens the boot path actually shows
(title art, `PRESS Ⓐ BUTTON`, main menu, `EXTRAS`); 🟡 `PROBABLE` for their
Japanese twins; ❔ open for the two `DELTASABER` plates.
Answers [MISSION Q2](../port/MISSION.md). The previous statement — *"build 4
title, 5 main menu, 6/8/9 submenus"* — is **partly wrong** and is withdrawn:
build 8 is the **Japanese main menu**, not a submenu, and `GP_TITLE` holds
exactly **one** submenu (`EXTRAS`), in two languages.
## The archive is eight screens, each shipped twice
`GP_TITLE.pak` has 16 entries; `sylpheed-cli screen list` calls 12 of them
composable builds. The 16 fall into **eight pairs**, and each pair's two members
have near-identical sizes and name hashes that differ by a constant — one
character of the name apart:
| entry | hash | bytes | build idx | pair |
|---|---|---|---|---|
| 0 | `01a2db9c` | 483 958 | 0 | A |
| 1 | `0ff0b8a8` | 483 958 | 1 | A |
| 2 | `285d8849` | 267 014 | 2 | B |
| 3 | `369773cb` | 267 014 | 3 | B |
| 4 | `a60fcb85` | 12 278 666 | 4 | C |
| 7 | `b483e6e6` | 13 363 328 | 7 | C |
| 5 | `a715f485` | 6 977 437 | 5 | D |
| 8 | `b58a0fe6` | 6 931 653 | 8 | D |
| 6 | `a81c1d85` | 6 549 126 | 6 | E |
| 9 | `b69038e6` | 6 548 438 | 9 | E |
| 10 | `cdba806e` | 426 473 | — | F |
| 13 | `db2ea1f8` | 423 333 | — | F |
| 11 | `cec0a96e` | 999 643 | — | G |
| 14 | `dc34caf8` | 999 643 | — | G |
| 12 | `cf2a8ccd` | 1 774 639 | 10 | H |
| 15 | `dd56d99b` | 1 774 639 | 11 | H |
For the three pairs whose members differ visibly — C, D, E — **the difference is
English versus Japanese**: build 7 is the title art with the katakana subtitle
プロジェクト シルフィード, build 8 the main menu reading 新規 / ロード /
チュートリアル / オプション / エクストラ, build 9 the `EXTRAS` submenu as
エクストラ. That is the whole of the language split we can see. In pairs A, B and
H the two members render **byte-identical PNGs** — the archive still carries two
copies, but the artwork does not change with the language.
Pairs F and G are the four entries `screen list` does *not* classify as builds by
default. **They are the developer splash**, and they render — see below.
## The map
Contact sheet of every render:
[`captures/title-builds/title-build-contact-sheet.png`](captures/title-builds/title-build-contact-sheet.png).
| build | what it is | confirmed how |
|---|---|---|
| 0, 1 | a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background | ❔ **not observed running.** Never seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT` |
| **2**, 3 | the `PRESS Ⓐ BUTTON` plate — **an overlay build of its own**, not a state of build 4 | ✅ seen composited over build 4 on the live title, at the same rect our render puts it |
| **4** | title art, English (`PROJECT SYLPHEED`, ™, `(C)2006,2007 SQUARE ENIX`) | ✅ [`live-title-press-a.png`](captures/title-builds/live-title-press-a.png) |
| 7 | the same, Japanese | 🟡 renders as the JP twin of build 4; the container runs an English locale, so it was not seen |
| **5** | main menu, English: NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS, footer `Ⓐ : OK` | ✅ [`live-main-menu.png`](captures/title-builds/live-main-menu.png) — element for element |
| 8 | the same, Japanese | 🟡 as above |
| **6** | the `EXTRAS` submenu, English: MISSION SELECT / MOVIE THEATER / BACK, footer `Ⓐ : OK Ⓑ : Back` | ✅ [`live-extras.png`](captures/title-builds/live-extras.png) |
| 9 | the same, Japanese | 🟡 as above |
| 10, 11 | the same `DELTASABER` plate as build 0, over a dark circuit-line background | ❔ **not observed running** |
## ✅ The splash — the four "non-build" entries, rendered
`screen list --all` widens the enumeration to every composable bundle and shows
all **16** entries. The four that the default listing drops are the two halves of
the developer splash, each shipped twice:
| entry | elements / sprites | what it is |
|---|---|---|
| **10, 13** | 3 / 2 | the white **`SQUARE ENIX`** publisher logo |
| **11, 14** | 7 / 6 | **`GAME ARTS` / `SETA` / `studio anima`** — the developer logos |
[`splash-entries-rendered.png`](captures/title-builds/splash-entries-rendered.png).
Rendered with `screen render --all --primitives --black`. Entry 11's seven
elements are the three logos, their three `_eff` glows and the
`palogo_eff0.prm` black backdrop — exactly the composition
[`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) measured
for the splash.
### ✅ Confirmed against the running game — 2026-08-28
The 🟡 above ("no framebuffer capture to diff against") is closed. Recording the
boot from the moment the window appears, at 2 fps, catches the splash before the
movie:
[`live-splash-publisher.png`](captures/title-builds/live-splash-publisher.png),
[`live-splash-developer.png`](captures/title-builds/live-splash-developer.png).
Edge-correlated against the renders, **with the other half as a negative
control**:
| capture | vs entry 10 | vs entry 11 |
|---|---|---|
| publisher frame (t ≈ 2.0 s) | **0.9146** @ (0,0) | 0.027 ✗ |
| developer frame (t ≈ 6.0 s) | 0.033 ✗ | **0.9792** @ (0,0) |
Both halves match their own render at zero shift and are firmly rejected by the
other. The `13`/`14` twins score 0.876 / 0.966 — near-identical artwork, so this
test cannot tell a pair apart, only a screen from a different screen.
### ✅ The splash's timing is DECODED, and the capture confirms it
Re-recorded at **10 fps** (the 2 fps pass below was ±0.5 s and could not see
ramps at all). The splash **fades, both ways** — it does not cut — and the
bundle's own keyframes say so:
```
$ sylpheed-cli screen info --all --build 10 GP_TITLE.pak
1 palogo_sqex.t32 7 kf [15 30 235 239 251 255 -]
2 palogo_sqex_eff.t32 4 kf [15 30 45 -] (the glow, child of 1)
$ ... --build 11
1 palogo_gamearts.t32 7 kf [15 30 190 194 206 210 -] (seta, anima identical)
2 palogo_gamearts_eff 4 kf [15 30 45 -]
```
Under Q1's `1 unit = 1/60 s`:
| | declared | measured at 10 fps |
|---|---|---|
| `SQUARE ENIX` ramp in | `15 → 30` = 0.25 s | rise 0.4 → 0.8 s |
| `SQUARE ENIX` hold | `30 → 235` = **3.42 s** | ≈ 3.5 s (0.8 → 4.3 s) |
| `SQUARE ENIX` fade out | `235 → 255` = **0.33 s** | **≈ 0.3 s** (4.4 → 4.7 s) |
| developer hold | `30 → 190` = **2.67 s** | ≈ 2.4 s (5.6 → 8.0 s) |
| developer fade out | `190 → 210` = **0.33 s** | **≈ 0.3 s** (8.1 → 8.4 s) |
The offset between declared and measured start is ≈ 0.35 s, which is simply that
the recording's `t = 0` is when the *window* appears, not when the guest starts
drawing. Everything downstream of that lines up.
**The overshoot is the glow.** The measured rise peaks (6.21) at 0.8 s and settles
back (5.39) by 1.1 s, which looks like a bloom. It is the `_eff` child element:
its keyframes are `15 → 30 → 45`, so it ramps in *after* the logo and then back
down, while the logo itself holds. Decoded, not a rendering artifact.
So the port can **read** the splash's timing off the disc rather than author it —
the first screen's animation is not a measurement it has to trust.
### The wall-clock sequence, for orientation
| | |
|---|---|
| `SQUARE ENIX` on screen | **≈ 0.5 → 4.0 s** after the window appears |
| black | ≈ 4.5 s |
| `GAME ARTS` / `SETA` / `studio anima` | **≈ 5.0 → 7.5 s** |
| black | ≈ 9.0 s |
| `ADV.wmv` begins | **≈ 9.5 s** |
⚠️ The frames at ≈ 9.512.5 s show `SQUARE ENIX` again in **cyan**. That is not a
third splash — it is the intro movie's own opening, which the milestone-2 notes
describe as "white SQUARE ENIX + cyan glow + red diamonds". A capture-only reading
would have recorded a third logo screen that does not exist.
**So the first of the five screens now has a reference composite, a framebuffer
capture, and its on-screen durations.**
**Reach of the two negatives.** Builds 0/1 and 10/11 were looked for in: the whole
boot sequence (a 5 s-cadence filmstrip from launch to the title, ~190 s), the
title, the main menu, the `EXTRAS` submenu, the `LOAD GAME` slot list, the
transition into `MISSION SELECT` (a 40-frame burst), and the attract cycle. They
appear in none of them. The obvious remaining candidate is a *long* load — a
mission launch — which is out of this objective's scope; they are most likely a
loading/AI-chatter plate. That is a hypothesis, not a result.
## Three things the captures settle beyond the map
**The live title is two builds composited.** Build 4 draws the art; build 2 draws
`PRESS Ⓐ BUTTON` on top, and it **fades in a beat later** — a screenshot taken
2.5 s after arriving at the title has the art and no plate, one taken ~1 s later
has both. The port must treat the plate as its own timed element.
**The attract-loop title carries the plate too.** After ~810 s idle the title
fades to black, a full-motion video plays for ~85 s, and the title comes back —
*with* `PRESS Ⓐ BUTTON`
([`live-attract-title-press-a-band.png`](captures/title-builds/live-attract-title-press-a-band.png)).
This is consistent with, and adds nothing to, the draw-quad comparison in
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md): the plate is
not the tell that distinguishes the boot title from the attract title.
**`EXTRAS` is the only main-menu destination inside `GP_TITLE`.** Ⓐ on `EXTRAS`
opens build 6 — measured. The other four destinations leave the archive: Ⓐ on
`LOAD GAME` opened a `LOAD GAME` slot list, and Ⓐ on `MISSION SELECT` inside
`EXTRAS` opened a `MISSION SELECT` screen, neither of which is a `GP_TITLE`
build. `dat/` carries `GP_SAVE_LOAD.pak`, `GP_TUTORIAL.pak`, `GP_OPTIONS.pak`,
`GP_MISSION_SELECT.pak` and `GP_MOVIE_THEATER.pak`; **that those are the archives
behind the other four buttons is an inference from the names, not a measurement.**
## How to reproduce
```bash
sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"
sylpheed-cli screen render --build 6 "$SYLPHEED_DISC/dat/GP_TITLE.pak" /tmp/b6.png
tools/re-capture/boot_menu.sh q2 # boots to the main menu, cursor on NEW GAME
tools/re-capture/pad.py dpad down 0.35 # x4 -> EXTRAS
tools/re-capture/pad.py tap A 0.30
```
Mind the d-pad hold — see [`METHOD.md`](METHOD.md).
## ✅ The title is not a still image — it loops, ≈ 2.2 s
Recording the title at 10 fps for 22 s shows the screen **never settles**. After
the build-in it oscillates by about ±0.5 in mean luminance, continuously:
```
peaks at 5.6 7.8 10.0 12.7 15.2 17.1 19.2 21.3 s
intervals 2.2 2.2 2.7 2.5 1.9 2.1 2.1
mean 2.24 s
```
**measured**, n = 7 intervals, spread 1.92.7 s — peak-picking a low-amplitude
signal is coarse, so read it as **≈ 2.2 s ± 0.4**, not a precise period.
The mechanism is already decoded: build 4 declares **`ptloop01.rat` /
`ptloop02.rat`**, and `loop*.rat` is a **looping sprite animation** rather than a
composition ([`INDEX.md`](INDEX.md), UI screen layout row). So the title carries a
looping element by construction; what is new is that it runs at ≈ 2.2 s and never
stops.
### ✅ The loop records ARE decoded — and they are not what I measured
`ptloop01.rat` is an **`opt `-linked leaf record**: in build 4 the chunk `opt `
(size `0x0c`) carries the name, immediately followed by a `RATC` blob at
**`0xbb5966`** (`ptloop02.rat` the same at `0xbb5a82`). Its pivot fields read
`200`/`90`, matching `screen info`'s `pivot (200,90)` — the right blob.
**The keyframes use the ordinary 40-byte block layout**, starting at `+0x68`:
| | `ptloop01``pteff03.t32` | `ptloop02``pteff03a.t32` |
|---|---|---|
| kf0 | `t=150` x=**639** α=`ff` | `t=150` x=**1721** α=`00` |
| kf1 | `t=540` x=39 α=`80` | `t=630` x=1111 α=`80` |
| kf2 | `t=600` x=**1521** α=`ff` | `t=720` x=**839** α=`ff` |
| scale | 100 × 600 | 100 × 800 |
| span | 150 → 600 = **450 units = 7.5 s** | 150 → 720 = **570 units = 9.5 s** |
Y is constant at 270 and X runs off one edge to the other, so these are
**horizontal light sweeps**`loop01` left → right, `loop02` right → left.
### 🔴 Two things I wrote last iteration are wrong
**1. "A leaf record's keyframes are not in the build's 40-byte layout."**
Withdrawn — they are, exactly. The scan that "found nothing" demanded **29**
strictly-increasing times because I read the word at `+0x004` (`0x001e0000`) as a
keyframe count. These records hold **three** keyframes. A filter that hard-codes
the expected count rejects the right structure; whatever the `30` is, it is not
the number of keyframes here.
**2. "The ≈ 2.2 s oscillation is the `ptloop` elements."** Withdrawn — I asserted
the link because build 4 declares those elements, not because anything showed it.
The decoded sweeps run **7.5 s and 9.5 s**. A 22 s capture would show ~3 peaks
from a 7.5 s cycle; it showed **8**. So the loops are *not* what the oscillation
measured.
### ✅ Identified: it is the `PRESS Ⓐ BUTTON` plate pulsing
A per-tile amplitude map over the capture (8 × 6 grid, 18 s after build-in)
localises the 2.3 s period precisely:
| region | sd | dominant lag |
|---|---|---|
| band x ≈ 318954, y ≈ 560672 | **7.65** | **2.3 s** |
| wordmark centre | 0.06 | — |
| bottom-right corner | 0.003 | — |
That band is the `PRESS Ⓐ BUTTON` plate's rest position (`ptbtn00.rat`, rest
`(383,550)`, pivot `(256,25)`). It is **build 2**, composited over the title — not
build 4.
Decoding `ptbtn00f.rat`, the plate's highlight variant, gives the pulse directly:
| kf | t | alpha |
|---|---|---|
| 0 | 6 | `0x00` |
| 1 | 29 | `0x06` |
| 2 | 35 | `0x4a` |
| 3 | 50 | `0x50` |
| 4 | 58 | `0x50` (hold) |
| 5 | 97 | `0x4a` |
| 6 | 105 | `0x06` |
A glow that fades in to `0x50` and back out — exactly a "press start" pulse.
There is an **eighth** keyframe: fade `0x00ffffff` at the same position — the glow
returns to **fully transparent**, so this is a closed cycle, not a one-shot ramp.
🟡 **The cycle length is still not readable — and now that is an observation.**
The eighth block's time slot contains the four bytes `end `, the record's ASCII
terminator: the record simply stops there and the value does not exist. So the
corpus's "a group's last block has no time of its own" rule holds here in a second
form — not the next group's index, but the chunk terminator.
Declared span is **≥ 105 units = 1.75 s**; measured **≈ 2.3 s** (≈ 138 units),
which would need a final step of ≈ 33 units. **That 33 is fitted to the
measurement, not read from the file**, and is recorded only so nobody re-derives
it as if it were a decode.
**The word at `+0x004` is not a keyframe count.** It reads `0x003c0000` (60)
here with 8 keyframes, and `0x001e0000` (30) in the loop records with 3. Whatever
it is, it is not the count, and it is not decoded.
### ❔ The declared 4.08 s build-in was not tested
That was the intent of this recording and it did not work. The title was reached
by **skipping the movie with Ⓐ**, which cuts to black and brings the title up on a
path that may not be the normal one; and the visible rise (≈ 2.7 s, from t ≈ 0.8
to 3.5) is a *luminance* curve, which
[`screen-transitions.md`](screen-transitions.md) already establishes is **not**
the fade quad's ramp. So ≈ 2.7 s neither confirms nor contradicts build 4's
declared `16 → 261` (4.08 s); the two are not measuring the same thing.
Testing it properly needs the title reached **without** a skip, and a way to
separate the quad from the elements — neither of which this recording had.
## ✅ For the port: build 4 alone, and groups HOLD rather than loop
Both answers the port agent asked for, in one recording.
### The capture — the title with no `PRESS Ⓐ` plate over it
Ⓑ from the main menu returns to the title and the plate fades in **a beat later**,
which opens a clean window. Recorded at 20 fps from the press:
| | |
|---|---|
| title art appears | t ≈ 1.10 s |
| builds in | 1.10 → 3.70 s (mean 41.8 → 63.2) |
| **settled, still no plate** | **3.70 → 5.00 s** |
| plate arrives | t ≈ 5.10 s (band jumps 282 → 3 755 bright px) |
[`live-title-build4-no-plate.png`](captures/title-builds/live-title-build4-no-plate.png)
is t = 4.0 s — build 4, settled, unobstructed. That is the reference for the
washed-out cyan glow slab the port reports drawing and the game not having.
### ✅ A keyframe group HOLDS at its last keyframe — it does not loop
This follows from the decoded sweeps plus a measurement, and the two agree:
* `ptloop01.rat`'s final keyframe puts `pteff03.t32` at **x = 1521** and
`ptloop02.rat`'s puts `pteff03a.t32` at **x = 839** — both **off-screen** on a
1280-wide design. A group that holds therefore parks both sweep sprites out of
view and nothing moves after the build-in.
* Measured: over 18 s of settled title the centre tiles sit at **sd ≤ 0.01**
(per-tile map above). A looping group would recross the screen every **7.5 s**
and be unmissable.
So the name `loop*.rat` is misleading — in this build the records animate **once**
during the build-in and then rest off-screen. 🟡 This is about *these* groups on
*this* screen; nothing here says no group anywhere loops.
## ✅ The title's settled pose is `rest`, and the "washed-out slab" is a MISSING dim
The port agent could not decide whether `rest` or its played-out timeline is the
title's arrived pose (they disagree by 142247/255), and separately reported a
*"washed-out cyan glow slab over the title logo that the running game doesn't
have"*. The plate-free capture settles both.
`sylpheed-cli screen render --build 4 --black`, edge-correlated against
[`live-title-build4-no-plate.png`](captures/title-builds/live-title-build4-no-plate.png)
— a capture of the real screen, so this is an independent oracle and not one
renderer checking another:
> **0.9163 at shift (0,0)**, and per band 0.92 / 0.78 / 0.93.
**Geometry is right**, so `rest` is the arrived pose for the title.
### The slab is the 25 % dim, absent — not a glow, present
`rest` alone is **uniformly too bright**, and the excess leans cyan:
| render | mean (render capture) | R | G | B |
|---|---|---|---|---|
| `--black` | **+13.14** | +12.35 | +13.58 | +13.48 |
| `--black --primitives` | **+0.55** | +1.30 | +0.89 | 0.54 |
Drawing the `.prm` primitives collapses the excess to nothing. The element is
**`pteff02.prm`**, the 25 % dim quad (build 4, rest at `t=46`, fade `0x40` = 64 =
25 %) — and `--primitives` is **off by default**.
So the "washed-out cyan slab" is not something being drawn that shouldn't be. It
is the **dim that should be drawn and isn't**: without it every pixel sits ~13
high, and because the title art is blue-dominant the shortfall reads as a cyan
wash. ⚠️ Any consumer of `screen render` that omits `--primitives` on this screen
gets it.
### 🟡 The residual: right on average, not right per pixel
With primitives the *mean* is essentially exact (+0.55) but pixel agreement is
slightly **worse** — edge-correlation 0.9163 → **0.9066**, and pixels differing by
> 20 rise 108 051 → 162 636. So the dim's average contribution is right while its
application is not exactly the game's (blend mode or per-region alpha). Not
diagnosed.
### 🔴 The real defect: the logo swoosh is drawn white and thick
With the dim in place, the residual is **not uniform** — it is a dark patch beside
a bright one in one band:
```
row1 (y 112-225): -2.0 +3.0 -8.8 -38.6 -17.6 +16.2 +33.8 +24.4
```
Cropping that band from capture and render
([`title-swoosh-capture-vs-render.png`](captures/title-builds/title-swoosh-capture-vs-render.png))
shows it plainly: the game draws the logo's `Z` **swoosh thin, with a
pink/magenta edge**; our render draws it **thick and solid white**. Too bright to
its right, too dark where the game's thin stroke actually falls.
**This — not the missing dim — is the port agent's "washed-out slab over the title
logo".** The dim explains a *uniform* +13; the slab is this.
The elements are `ptlogo_back2.t32` (rest `(71,126)`, pivot `(500,117)` — a
1000 × 234 diagonal), its glow `ptlogo_back2eff.t32`, and the five
`ptlogo_back2eff1…5` segments at y ≈ 117194 — exactly the band that disagrees.
🟡 **A connection worth chasing, not a diagnosis.** Those five segments are the
group whose **paint-order tie-break is the known unsolved residual**
([`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md)): they
share key `0x8083`, the game paints them `14,15,18,16,17`, the stable sort paints
`14,15,16,17,18`, and the measured cost is "`ptlogo_back2eff5` against `eff3`
(22 568 px) and against `eff4` (32 395 px)". **Same screen, same elements.**
❔ But a blend-order swap is a poor explanation for *white instead of pink* — that
looks like a tint or blend-mode problem, so I would expect a second cause.
### 🟡 A quantified cause for the GEOMETRY: the pivot belongs to the other language
The fade and tint fields are **not** the culprit — every keyframe of every swoosh
element carries `0x??ffffff`, white RGB with only alpha varying, and no tint is
anything but `0xffffffff`. Nor is the texture pink: `ptlogo_back2.t32` decodes
**blue**-leaning (175, 174, 198) and its glow warm (255, 253, 234).
What *is* wrong is the **pivot**. Checking every `GP_TITLE` element whose texture
we decode (109 of 178) against the rule *pivot = texture ÷ 2*:
| | |
|---|---|
| exact | 17 |
| off by ≤ 1 px (rounding) | 54 |
| off by ≤ 8 px | 14 |
| **off by > 8 px** | **24** |
and the gross ones are **concentrated on the title's logo elements**:
```
a60fcb85 ptlogo_back2.t32 pivot (500,117) texture 1118x262 -> implies (559,131) off 59.0
a60fcb85 ptlogo_back2eff.t32 pivot (507,126) texture 1133x280 -> implies (566,140) off 59.5
a60fcb85 ptlogo2.t32 pivot (449, 46) texture 992x104 -> implies (496, 52) off 47.0
b483e6e6 ptlogo1.t32 pivot (451, 50) texture 822x100 -> implies (411, 50) off 40.0
```
**Why:** build 4's `ptlogo_back2` pivot `(500,117)` is *exactly* half of the
**Japanese** texture (1000 × 234), not its own English one (1118 × 262). The
layout record is authored once and shared while the `.t32` sprites are swapped per
language — the effect
[`structures/ui-rat-layout.md`](structures/ui-rat-layout.md) warns about in
general, here measured on the screen where the render disagrees with the game.
Note it cuts **both ways**: the Japanese build's `ptlogo1` is off by 40 px too.
A 59 px offset on a 1118 px sprite is the right order to produce "too thick and
extending too far right", which is what the crop shows.
### 🔴 …and that candidate is refuted
Checked, as promised. `ui_layout`'s `blit` takes the drawn size from the
**texture** (`img.width`/`img.height`), and uses the pivot only for the
scale anchor:
```rust
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
```
At `sx_pct == 100` that term is **zero**. And **every one of the seven swoosh
elements is scale `(100,100)` at every keyframe** — `ptlogo_back2`,
`ptlogo_back2eff` and `ptlogo_back2eff1…5` all report a single scale. So the
pivot mismatch, real as it is in the data, **cannot** move or resize the swoosh in
our render.
⚠️ It is not harmless everywhere: `ptlogo1`/`ptlogo2` run scales
`100 → 101 → 103 → 112 → 150` during the build-in, so there the wrong pivot *does*
displace them — during the animation, not at rest.
### 🟡 So the swoosh defect is a BLEND problem, by elimination
Position and size are the texture's own and are right; fade is white-with-alpha;
tint is white; the texture is blue-leaning, not pink. What remains is how the
seven overlapping sprites are combined — `ptlogo_back2` is only **5.4 % opaque**,
its glow 10.3 %, the five `eff` segments 1023 %, all white or warm. Stacked with
plain alpha-over they saturate toward opaque white, which is what we draw and
would read as "thicker" beside the game's thin coloured stroke. ❔ Not diagnosed —
no blend mode has been identified in the data.
### 🔴 Refuted on the way
The obvious guess — *our dim is applied over the whole frame instead of beneath
the UI, where its layer key puts it* — is **wrong**. If it were, the logo would
render too dark; it reads **+2.36** against a background of **0.74**. The
compositor honours the paint order here.
## 🔴 The swoosh is not displaced either — and the residual is restated
Two more candidates eliminated, and the residual is smaller than earlier sections
implied.
**Not a displacement.** Shifting the render's swoosh band over ±80 px × ±8 px and
re-correlating peaks **sharply at (0, 0)** — 0.7342, falling to 0.22 at ±24 px and
0.10 at ±48. The swoosh is where it should be.
**Not additive blending.** See
[`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md): every
measure worsens.
**And the residual, restated with the current best render** (`--black
--primitives`, `rest()` fixed):
| | |
|---|---|
| whole-frame mean diff | **+0.55** |
| swoosh-band mean diff | **+1.83** |
| swoosh-band edge-correlation | **0.6971** (vs ≈ 0.92 frame-wide) |
⚠️ Earlier sections quoted band tiles at **+16 … +34**. Those were measured on a
render **without** `--primitives`. With the dim drawn the band's *average* is
nearly right; what is wrong is its **structure** — the tiles run 38.6 then +33.8
across the band and cancel. So the defect is neither brightness, nor position, nor
additive blending: it is a shape difference in one band, and it is **not
diagnosed**. Six candidates eliminated: pivot (twice — inert at scale 100, and no
measured displacement), `fade`, `tint`, texture colour, additive blend.
### 🔴 The capture IS settled — my own caveat, tested and withdrawn
The previous version of this section worried that the plate-free capture, at
t ≈ 4.0 s, might be too early: elements have keyframes to t = 600 (10 s), and
"settled" had been judged from mean luminance, which cannot see a thin sprite
still moving.
The plate sits at y ≈ 550600, **disjoint** from the swoosh band at y 112225, so
a *late* capture works even with the plate present. Correlating the render's band
against the same band at several ages of the screen:
| capture | band edge-corr | band mean |
|---|---|---|
| t = 4.0 s (plate-free) | **0.7342** | 126.63 |
| t = 6.0 s | 0.7351 | 126.97 |
| t = 12.0 s | 0.7352 | 126.98 |
| t = 18.0 s | 0.7353 | 126.98 |
| t = 21.5 s | 0.7353 | 126.98 |
Identical to within 0.001 over 17.5 s. **The band is settled by t = 4.0 s**, the
capture handed to the port agent is sound, and the caveat is withdrawn. It also
corroborates that the groups **hold**: nothing crosses that band in 22 s.
## The swoosh — SOLVED by draw capture (see below); the elimination trail kept
Seven candidates eliminated, none confirmed:
| candidate | verdict |
|---|---|
| element **pivot** (off by 59 px, authored for the other language) | inert — `blit` sizes from the texture and applies the pivot only when scale ≠ 100; all seven elements are scale `(100,100)` |
| **displacement** | none — shifting ±80 × ±8 px peaks sharply at (0,0), 0.734 → 0.22 at ±24 px |
| **`fade`** | `0x??ffffff` on every keyframe: white RGB, alpha only |
| **`tint`** | `0xffffffff` throughout |
| **texture colour** | blue-leaning (175,174,198); the glow warm — neither pink |
| **additive blend** via `T8aD +0x04` bit `0x02` | refuted: every measure worsens |
| **capture not settled** | refuted above |
The residual is stable and modest: band mean **+1.83**, band edge-correlation
**0.69710.735** against ≈ 0.92 frame-wide. Real, persistent, and **not located in
any field this project can read from the disc**.
**Where a next attempt should start, and it is not another field** — but check
what the tool actually records first, because the obvious phrasing of this is
wrong.
⚠️ **The per-draw capture does NOT record blend state.** Reading
`command_processor.cc`, each captured draw carries: primitive type, index count,
index-buffer address, vertex- and pixel-shader `ucode_data_hash`, the pixel
shader's **texture bindings** (base, dimensions, format), and **vertex attribute
0 of binding 0**. There is no `RB_BLENDCONTROL` / `RB_COLORCONTROL` dump. An
earlier version of this section claimed the capture "reads the actual blend
state"; it does not.
So the route splits:
***Testable today, no code change** — whether the game passes a **vertex
colour** for those draws. The capture dumps vertex attributes, and a pink vertex
colour would explain white-versus-pink directly.
***Needs a Canary change** — the blend mode itself, which means adding an
`RB_BLENDCONTROL` dump to the same capture path.
Either way it is instrumentation of the running guest, not another field in the
file.
## ✅ SOLVED — the game draws the swoosh as ROTATED QUADS, which our blit cannot
The route named in the previous section was run: `--ui_draw_capture_frames=3`,
armed with F10 on the settled title.
[`title-draw-capture-vertex-colours.log`](captures/title-builds/title-draw-capture-vertex-colours.log)
is the capture, 22 draws over 3 frames.
### 🔴 First, the vertex-colour hypothesis dies
Every vertex colour in the entire capture is `<alpha>FFFFFF`**white RGB**, only
the alpha varying: `FFFFFFFF`, `C5FFFFFF`, `C3FFFFFF`, `B8FFFFFF`, `B6FFFFFF`,
`31FFFFFF`, `1EFFFFFF`. The game passes no colour. That was the eighth candidate.
### ✅ And the ninth is the answer — it is the GEOMETRY
Draw 2 submits **two parallelograms, neither axis-aligned**:
| quad | corners (NDC) | edge `v0→v1` | axis-aligned? |
|---|---|---|---|
| A | `(0.70,1.58) (1.24,1.02) (0.40,1.57) (0.14,1.02)` | `(0.54, 0.56)` | **no** |
| B | `(1.29,1.02) (0.85,1.81) (0.75,1.02) (0.31,1.81)` | `(0.44, 0.79)` | **no** |
Both verified parallelograms (opposite edges equal to 0.01), both **rotated**
roughly 45° and 61° — and both extending to `y = ±1.81`, well beyond the screen.
That is the diagonal `Z` stroke.
**Our compositor cannot draw that.** `ui_layout::blit` walks destination rows and
columns of an **axis-aligned rectangle** (`for row in 0..dh { for col in 0..dw`),
sampling the source by a straight ratio. It has no rotation. So the swoosh is
blitted upright where the game draws it skewed — which is exactly the observed
signature: **right on average (+1.83), right in position (peak at (0,0)), wrong in
structure (edge-corr 0.70)**, dark on one side of the true stroke and bright on
the other.
### ❔ And a real gap in the decoded format
The keyframe carries `fade`, `scale_x`, `scale_y`, `tint`, `x`, `y`, `time`
([`ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs)) — **no
rotation**. So where the game's rotation comes from is **not decoded**: either a
field not yet identified, or the element is positioned by code rather than by its
keyframes. That is the open question this leaves.
⚠️ **The "pink versus white" reading is now suspect.** It was a visual comparison
of two differently-*shaped* renderings. Whether any colour difference survives
correct geometry is **untested**, and should be re-checked rather than carried
forward as a separate defect.
## 🟡 Three angle fields found — and they are NOT the title's rotation
The obvious place to look for the rotation was the keyframe block's three words
at `+4`, `+8`, `+12`, which `ui_layout.rs` documented as `0`.
**They are not zero.** Over **72 287** keyframe blocks disc-wide:
| word | non-zero | commonest values (signed) |
|---|---|---|
| `+4` | **4.81 %** | `180`, `180`, `22`, `90` |
| `+8` | **4.56 %** | `180`, `90`, `180`, `178` |
| `+12` | **15.82 %** | `90`, `90`, `120`, `58` |
Values clustering on ±180, ±90, 120 read as **degrees**, and three of them suggests
rotation about three axes. 🟡 That reading is **not tied to an observed rotation**
it is the shape of the numbers, nothing more. The doc comment is corrected either
way: "0 on every frame seen" was a sample artefact.
### ✅ They *do* explain this screen — the earlier negative was wrong about reach
~~**Every element of `GP_TITLE` build 4 has all three at zero** — checked element
by element. So the title's rotation comes from outside the keyframe data.~~
**Withdrawn (2026-08-28).** That check walked build 4's **top-level declaration
table**. The rotated quads belong to its two **nested leaf records**, and there
`+12` reads **30** and **45** — against a measured **+30.26°** and **45.28°**.
The bytes were read correctly; the *region* was too small. See
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md).
⚠️ **One thing I should not have stated flatly:** that the skewed draw *is* the
swoosh. It is the only skewed geometry in the capture and the swoosh is the only
diagonal element on the screen, so the inference is reasonable — but it was not
confirmed by matching the draw's texture or screen position to that element, and
should be.
## 🔴 The skewed draw is NOT the swoosh — my identification was wrong
Converting draw 2's quads from NDC to screen space settles it:
| quad | screen corners | bbox |
|---|---|---|
| A | (1088,209) (1434,7) (896,925) (550,727) | x 550…1434, **y 209…925** |
| B | (186,7) (96,292) (1120,727) (838,1012) | x 186…1120, **y 292…1012** |
These span the **full screen height and well beyond it**. The swoosh
(`ptlogo_back2`, rest `(71,126)`, 1118 × 262) is a **band** at y 126…360. Draw 2
is not it.
## ✅ It is the two `ptloop` sweeps — confirmed
The bounding box was the wrong measurement; the quads are rotated, so what
identifies them is their **edge lengths**:
| quad | size | rotation | centre |
|---|---|---|---|
| A | 400.1 × 1076.3 | **+30.26°** | (992.0, 359.1) |
| B | 400.2 × 1444.5 | **45.28°** | (467.2, 360.0) |
| quad | element | sprite × declared scale |
|---|---|---|
| A | `ptloop01.rat` | `pteff03.t32` 399×180 @ 100 %,**600 %** = 399 × **1080** |
| B | `ptloop02.rat` | `pteff03a.t32` 399×180 @ 100 %,**800 %** = 399 × **1440** |
Five independent agreements, listed in
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md): the
count (two quads, two `ptloop` elements), both widths (400 vs 399), **both
heights, which are different numbers that both land**, the direction each quad
moves between the capture's two frames (matching each record's own sweep
direction), and the vertex alphas falling inside the declared ramps.
The known-positives in the same capture pass the same test: draw 5 measures
915 × 115 (`ptlogo1.t32` 919×113), draw 7 measures 691 × 18 (`ptcopyright.t32`
694×20) and 512 × 50 (`ptbtn00.t32` 513×50).
### ✅ And "a keyframe group holds" survives
The consequence I flagged conditionally last iteration resolves the *other* way.
Our renderer parks these two sweeps at their final keyframe (`x = 1521` and
`839`, both off-screen); the game draws them across the screen. That is not a
contradiction — the capture caught them **mid-sweep**: the quad centres, x = 992
and x = 467, both fall inside the decoded `t = 150…600` / `150…720` travel, the
alphas are mid-ramp, and the sprites move in the decoded direction between
frames. The capture is of the **build-in**, not of the settled screen, and the
18 s stillness measurement (sd ≤ 0.01) still says the groups stop. No change to
[the holds-not-loops finding](#-a-keyframe-group-holds-at-its-last-keyframe--it-does-not-loop).
### What this costs
Five iterations of swoosh work — the additive-blend test, the pivot analysis, the
vertex-colour capture — were built on "the skewed draw is the swoosh", an
identification made by *elimination on one screen* and never checked against the
draw's own coordinates. The eliminations themselves stand (they were measured
against the capture, not against the identification), but the chain of reasoning
that pointed at `ptlogo_back2*` did not.

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Every BGM bank's sub-wave structure: count, bytes, PsuedoBytesPerSec, seconds.
Static, disc-only. See docs/re/structures/bgm-two-stems.md."""
import sys, struct
sys.path.insert(0,"/work/Syplheed-Reborn/tools/re-capture")
exec(open("/work/Syplheed-Reborn/tools/re-capture/slb_segment_phase.py").read().split("def cmd_phases")[0])
pak=Pak("/work/sylph_extract/dat/sound.pak")
def bps(b, riff_at):
# PsuedoBytesPerSec at RIFF+0x20 (u32 LE), SampleRate at +0x24
return struct.unpack_from("<I", b, riff_at+0x20)[0], struct.unpack_from("<I", b, riff_at+0x24)[0]
rows=[]
for n in list(range(1,60))+list(range(101,120)):
name=f"BGM_{n:03d}.slb"
i=pak.find(name)
if i is None: continue
b=pak.read(i); ws=waves(b)
ds=[]
for w in ws:
pb,sr=bps(b,w["at"])
ds.append((w["data_size"], pb, sr, w["data_size"]/pb if pb else 0))
rows.append((name,len(ws),ds))
print(f"{'bank':14s} {'waves':>5s} per-wave (bytes, bytes/s, Hz, seconds)")
same=0
for name,k,ds in rows:
txt=" | ".join(f"{d:8d} {pb:6d} {sr:5d} {sec:7.2f}s" for d,pb,sr,sec in ds)
eq = len(ds)==2 and abs(ds[0][3]-ds[1][3])<0.05
same += eq
print(f"{name:14s} {k:5d} {txt} {'EQUAL-LENGTH' if eq else ''}")
print(f"\n{len(rows)} BGM banks; {same} are exactly two waves of equal duration")

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Do the headline figures in a doc match its committed reference data?
Numbers drift between a run and the prose written about it, and nothing was
checking. This compares the two NUMERICALLY, which matters: a naive string
grep reports every figure as a mismatch, because the data files write `14709`
and the docs write `14 709` (thin space) and round `33.66` to `33.7`. That
false-positive run is why this is a script and not a grep.
doc_figure_check.py # runs the built-in case list
"""
import re, sys
CASES = [
("eff-bit-census.txt", "structures/ui-paint-order-key.md",
[14709, 2338, 2657, 1399, 8315, 0.468, 0.144]),
("plateau-census.txt", "structures/ui-resting-pose.md",
[15493, 3807, 24.57, 50.2]),
("rotation-toplevel-census.txt", "structures/ui-keyframe-rotation.md",
[2152, 13.89]),
("eff-bit-alpha-test.txt", "structures/ui-paint-order-key.md",
[55.52, 33.66, 52.52, 30.17, 76.5, 64.1]),
]
NUM = re.compile(r"\d[\d  ,]*\.?\d*")
def nums(text):
out = set()
for m in NUM.finditer(text):
try: out.add(float(re.sub(r"[  ,]", "", m.group())))
except ValueError: pass
return out
def main():
bad = 0
for dfile, mfile, figs in CASES:
D = nums(open(f"docs/re/data/{dfile}", encoding="utf-8").read())
M = nums(open(f"docs/re/{mfile}", encoding="utf-8").read())
for f in figs:
in_d = any(abs(f - x) < 0.011 for x in D)
in_m = any(abs(f - x) < 0.051 for x in M) # the doc may round
if not (in_d and in_m):
bad += 1
print(f" CHECK {dfile} / {mfile}: {f} data:{in_d} doc:{in_m}")
print(f"{sum(len(c[2]) for c in CASES)} figures checked, {bad} to look at")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Do the files the docs cite actually exist in the repo?
An answer whose evidence is not committed cannot be used by anyone without a
disc and an emulator, which is the whole point of the reference data. This walks
every markdown file under docs/ and resolves each relative link, reporting the
ones that point at nothing.
Skips external links (http, mailto) and pure anchors. Reports missing targets
and, separately, committed-but-EMPTY files, which are the sneakier failure --
a link that resolves to a zero-byte file looks fine in every listing.
doc_link_check.py [docs-root]
"""
import os, re, sys
ROOT = sys.argv[1] if len(sys.argv) > 1 else "docs"
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
missing, empty, ok = [], [], 0
for dirpath, _dirs, files in os.walk(ROOT):
for f in files:
if not f.endswith(".md"):
continue
src = os.path.join(dirpath, f)
try:
body = open(src, encoding="utf-8").read()
except Exception:
continue
for target in LINK.findall(body):
if target.startswith(("http://", "https://", "mailto:", "#")):
continue
path = os.path.normpath(os.path.join(dirpath, target.split("#")[0]))
if not path:
continue
if not os.path.exists(path):
missing.append((src, target))
elif os.path.isfile(path) and os.path.getsize(path) == 0:
empty.append((src, target))
else:
ok += 1
print(f"{ok} link(s) resolve")
if missing:
print(f"\n{len(missing)} MISSING target(s):")
for s, t in sorted(missing):
print(f" {s} -> {t}")
if empty:
print(f"\n{len(empty)} link(s) resolve to an EMPTY file:")
for s, t in sorted(empty):
print(f" {s} -> {t}")
sys.exit(1 if (missing or empty) else 0)

View File

@@ -0,0 +1,58 @@
"""Does bit 0x02 separate sprites by their RGB-vs-alpha content?
Premultiplied alpha predicts RGB <= A everywhere for the flagged group. Tested
below and refuted. What remains is a description: how often RGB exceeds A, which
is the signature of glow art (bright colour carried at low alpha).
Bit comes from the T8aD header; the name from the string immediately preceding
it (validated 17/18 on build 4 against the RATC child order).
"""
import struct, zlib, glob, re, os
import numpy as np
from PIL import Image
NAME = re.compile(rb'[A-Za-z0-9_.]{2,31}\x00')
base = "/work/sylph_extract/dat/GP_TITLE"
stub = open(base + ".pak", "rb").read()
n = struct.unpack_from(">I", stub, 4)[0]
blob = b"".join(open(s, "rb").read() for s in sorted(glob.glob(base + ".p[0-9][0-9]")))
flags = {} # (entry_hash, name, w, h) -> flags
for i in range(n):
h_, off, sz = struct.unpack_from(">III", stub, 0x10 + 12 * i)
st = blob[off:off + sz]
if len(st) < 10: continue
try: d = zlib.decompress(st[10:]) if st[:2] == b"Z1" else st
except Exception: continue
for m in re.finditer(b"T8aD", d):
o = m.start()
try:
fl = struct.unpack_from(">I", d, o + 4)[0]
w = struct.unpack_from(">I", d, o + 0x14)[0]
hh = struct.unpack_from(">I", d, o + 0x18)[0]
except Exception: continue
if not (0 < w <= 4096 and 0 < hh <= 4096): continue
ms = list(NAME.finditer(d[max(0, o - 64):o]))
if not ms: continue
flags[(f"{h_:08x}", ms[-1].group()[:-1].decode("latin1"), w, hh)] = fl
rows = []
for f in sorted(glob.glob("/tmp/tex/*.png")):
b = os.path.basename(f)
m = re.match(r"([0-9a-f]{8})_(.+)_(\d+)x(\d+)\.png$", b)
if not m: continue
key = (m.group(1), m.group(2), int(m.group(3)), int(m.group(4)))
fl = flags.get(key)
if fl is None: continue
a = np.asarray(Image.open(f).convert("RGBA")).astype(int)
rgb = a[:, :, :3].max(axis=2); al = a[:, :, 3]
rows.append((bool(fl & 2), 100 * float((rgb > al).mean()), m.group(2)))
s = [r[1] for r in rows if r[0]]; c = [r[1] for r in rows if not r[0]]
print(f"matched {len(rows)} decoded textures to a T8aD flag word")
print(f" bit SET n={len(s):3d} mean %(RGB>A) {np.mean(s):6.2f} median {np.median(s):6.2f}")
print(f" bit clear n={len(c):3d} mean %(RGB>A) {np.mean(c):6.2f} median {np.median(c):6.2f}")
print(f"\n premultiplied would require ~0% for the flagged group -> REFUTED")
# separability: what threshold best splits them, and how well?
best = (0, None)
for t in np.arange(0, 100, 0.5):
acc = (sum(x > t for x in s) + sum(x <= t for x in c)) / len(rows)
if acc > best[0]: best = (acc, t)
print(f" best single-threshold accuracy: {100*best[0]:.1f}% at %(RGB>A) > {best[1]}")
print(f" (base rate, always-guess-majority: {100*max(len(s),len(c))/len(rows):.1f}%)")

View File

@@ -0,0 +1,60 @@
"""Disc-wide: does T8aD flag bit 0x02 imply an `eff` name?
Names come from the NAME IMMEDIATELY PRECEDING each T8aD chunk -- validated
17/18 on GP_TITLE build 4 against the known RATC child order. The single
mismatch is the documented `pteff04.t32` -> registered as `8AX` case, i.e. the
preceding name is the ELEMENT's (opt) name and the child list is the SPRITE's.
This test uses the element name and says so.
"""
import struct, zlib, glob, os, re, collections
NAME = re.compile(rb'[A-Za-z0-9_.]{2,31}\x00')
def entries(base):
stub = open(base + ".pak", "rb").read()
if stub[:4] != b"IPFB": return
n = struct.unpack_from(">I", stub, 4)[0]
segs = sorted(glob.glob(base + ".p[0-9][0-9]"))
if not segs: return
blob = b"".join(open(s, "rb").read() for s in segs)
for i in range(n):
h, off, sz = struct.unpack_from(">III", stub, 0x10 + 12 * i)
st = blob[off:off + sz]
if len(st) < 10: continue
try: yield (zlib.decompress(st[10:]) if st[:2] == b"Z1" else st)
except Exception: continue
set_eff = set_noneff = clear_eff = clear_noneff = 0
examples = []
for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")):
for d in entries(pak[:-4]):
for m in re.finditer(b"T8aD", d):
o = m.start()
try:
fl = struct.unpack_from(">I", d, o + 4)[0]
w = struct.unpack_from(">I", d, o + 0x14)[0]
h = struct.unpack_from(">I", d, o + 0x18)[0]
except Exception: continue
if not (0 < w <= 4096 and 0 < h <= 4096): continue
ms = list(NAME.finditer(d[max(0, o - 64):o]))
if not ms: continue
nm = ms[-1].group()[:-1].decode("latin1")
eff = "eff" in nm.lower()
if fl & 2:
if eff: set_eff += 1
else:
set_noneff += 1
if len(examples) < 12: examples.append((os.path.basename(pak), nm, f"{fl:08x}", f"{w}x{h}"))
else:
clear_eff += 1 if eff else 0
clear_noneff += 0 if eff else 1
tot = set_eff + set_noneff + clear_eff + clear_noneff
print(f"sprites with a resolvable preceding name: {tot}")
print(f" bit SET & name has 'eff' : {set_eff}")
print(f" bit SET & name lacks 'eff': {set_noneff} <-- counterexamples to 'set => eff'")
print(f" bit clear & name has 'eff' : {clear_eff}")
print(f" bit clear & name lacks 'eff': {clear_noneff}")
if tot: print(f"\n P(name has 'eff' | bit set) = {set_eff/max(set_eff+set_noneff,1):.3f}")
if tot: print(f" P(name has 'eff' | bit clear) = {clear_eff/max(clear_eff+clear_noneff,1):.3f}")
if examples:
print("\n counterexamples (bit set, no 'eff'):")
for e in examples: print(" ", e)

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Does the running game draw the full-res `8AX` or the 640x360 `ptbase` upscaled?
Both carry the SAME artwork at two resolutions, so no pixel comparison of the
backgrounds can separate them. What CAN: the detail 8AX has and an upscale does
not. Compute `8AX - upscale(ptbase)` -- the 8AX-only detail -- and ask whether
the live capture contains it.
Both candidates are first mapped into the capture's tone domain with the
measured gamma (docs/re/structures/ui-render-tone-curve.md); without that, the
capture's residual is dominated by the tone difference and the test is blind.
CONTROLS are the point: the same correlation with the 8AX residual shifted 7 px
and flipped. Those preserve the spatial correlation structure while destroying
the alignment, so they are what "no signal" looks like here.
"""
import sys
import numpy as np
from PIL import Image
CASES = [
("main menu", 1.491, "a715f485_8AX_1280x720.png", "a715f485_ptbase.t32_640x360.png",
"docs/re/captures/title-builds/live-main-menu.png"),
("title", 1.338, "a60fcb85_8AX_1280x720.png", "a60fcb85_ptbase2.t32_640x360.png",
"docs/re/captures/title-builds/live-title-press-a.png"),
]
TEX = sys.argv[1] if len(sys.argv) > 1 else "/tmp/tex"
Y0, Y1, X0, X1 = 40, 300, 20, 600 # background, away from menu text
def main():
for label, gamma, ax_png, pb_png, cap_png in CASES:
ax = np.asarray(Image.open(f"{TEX}/{ax_png}").convert("L")).astype(float)
pb = np.asarray(Image.open(f"{TEX}/{pb_png}").convert("L")
.resize((1280, 720), Image.BILINEAR)).astype(float)
cap = np.asarray(Image.open(cap_png).convert("L")).astype(float)
h, w = cap.shape
ax, pb = ax[:h, :w], pb[:h, :w]
g = lambda a: 255 * np.power(np.clip(a, 0, 255) / 255.0, gamma)
axg, pbg = g(ax), g(pb)
E = (axg - pbg)[Y0:Y1, X0:X1] # detail only 8AX has
C = (cap - pbg)[Y0:Y1, X0:X1] # how the capture departs from the upscale
cc = lambda a, b: float(np.corrcoef(a.ravel(), b.ravel())[0, 1])
ceiling = E.std() / C.std()
r = cc(C, E)
print(f"{label}: corr {r:+.4f} controls {cc(C[:, :-7], E[:, 7:]):+.4f} (shift) "
f"{cc(C, E[::-1]):+.4f} (flip) ceiling {ceiling:.3f} -> {100*r/ceiling:.0f}% of it")
if __name__ == "__main__":
main()

52
tools/re-capture/fade_quads.py Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Dump every `.prm` primitive's keyframe group (alpha + time) for a UI build.
The screen-transition fade lives here -- see docs/re/screen-transitions.md.
Usage: PAK=<pak> fade_quads.py [build...] (default: GP_TITLE, builds 2 4 5 6)"""
import struct, sys, glob, os, zlib
sys.path.insert(0, "/work/Syplheed-Reborn/tools/re-capture")
src = open("/work/Syplheed-Reborn/tools/re-capture/regn_decode.py").read()
exec(src.split("# ── POF0")[0])
DECL_AT, DECL_ENTRY, KF = 0x20, 60, 40
def be32(b,o): return struct.unpack_from(">I", b, o)[0]
def parse(bundle):
n = be32(bundle, 0x14)
names=[]
for i in range(n):
o = DECL_AT + i*DECL_ENTRY
names.append(bundle[o:o+28].split(b"\0")[0].decode("ascii","replace"))
groups={}
pos = DECL_AT + n*DECL_ENTRY
for _ in range(n):
if pos+8 > len(bundle): break
idx = be32(bundle,pos); frames = be32(bundle,pos+4)
if idx>=n or frames==0 or frames>4096: break
first = pos+12; end = first + frames*KF - 4
g=[]
for k in range(frames):
blk = first + k*KF
if blk+36 > len(bundle) or blk+36 > end: break
g.append(dict(fade=be32(bundle,blk), sx=be32(bundle,blk+16), sy=be32(bundle,blk+20),
x=struct.unpack_from(">i",bundle,blk+28)[0], y=struct.unpack_from(">i",bundle,blk+32)[0],
t=(be32(bundle,blk+36) if blk+40<=end else None)))
groups[idx]=g; pos=end
return names, groups
pak = os.environ.get("PAK", "/work/sylph_extract/dat/GP_TITLE.pak")
E = pak_entries(pak)
E = [b for h,b in E]
# build index -> pak entry index, from `screen list`: 0..9 then 12, 15
BUILDS = {0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,10:12,11:15}
want = [int(a) for a in sys.argv[1:]] or [2,4,5,6]
for b in want:
names, groups = parse(E[BUILDS[b]])
print(f"=== build {b} ===")
for i,nm in enumerate(names):
if not nm.endswith(".prm"): continue
g = groups.get(i, [])
print(f" [{i}] {nm} {len(g)} kf")
for k in g:
a=(k['fade']>>24)&0xff
print(f" t={str(k['t']):>5} fade=0x{k['fade']:08x} (alpha {a:3d}) scale={k['sx']}x{k['sy']} pos=({k['x']},{k['y']})")

View File

@@ -0,0 +1,52 @@
"""Fast title probe: a PERIODICALLY RESTARTED x11grab stream + glyph count.
🔴 DO NOT run one x11grab stream for the whole session. Measured 2026-08-29: a
single long-lived stream degrades from 3.98 fps to 1.60 fps and then FREEZES,
reporting a stale frame indefinitely. Cross-checked at one moment against an
independent `import` grab: the stream said surface mean 5.21 while `import` said
125.65, and the stream repeated that same value for four consecutive 30 s marks.
Any negative result from a stalled stream is worthless -- an earlier "2391
frames, zero hits" claim came from exactly that failure.
So the stream is torn down and restarted every RESTART_S seconds. Startup costs
~0.3 s, which is cheap against the title's few-second window and buys a
guarantee that the frames are live.
Replaces the two-`screenshot` polling loop, which costs ~4-11 s per grab while
xenia runs and so samples every ~41 s -- slower than the title screen lasts.
Counter is byte-identical to is_title.py (controlled: 753 on the committed title
capture, 327 on the main menu).
"""
import subprocess, sys, time
import numpy as np
W,H = 1280,720
LIMIT = float(sys.argv[1]) if len(sys.argv)>1 else 120
NEED = int(sys.argv[2]) if len(sys.argv)>2 else 400
RESTART_S = 30 # tear down and restart the stream this often
def _open():
return subprocess.Popen(["ffmpeg","-loglevel","error","-f","x11grab","-draw_mouse","0",
"-video_size",f"{W}x{H}","-i",":98","-r","4","-f","rawvideo","-pix_fmt","rgb24","-"],
stdout=subprocess.PIPE, bufsize=W*H*3*2)
p=_open()
n=W*H*3; t0=time.time(); frames=0; best=0; hits=0; seg=time.time()
while time.time()-t0 < LIMIT:
if time.time()-seg > RESTART_S: # a stalled stream reports stale frames forever
p.kill(); p=_open(); seg=time.time()
buf=p.stdout.read(n)
if len(buf)<n:
p.kill(); p=_open(); seg=time.time(); continue
a=np.frombuffer(buf,np.uint8).reshape(H,W,3).astype(int)
r,g,b=a[:,:,0],a[:,:,1],a[:,:,2]
c=int(((g>130)&(g-r>45)&(g-b>45)).sum())
frames+=1
if c>best:
best=c
if c>=NEED:
from PIL import Image
Image.fromarray(a.astype(np.uint8)).save("/sylph-home/re/shots/fast-title.png")
if c>=NEED:
hits+=1
if hits==1: print(f"TITLE at t={time.time()-t0:.1f}s glyph={c} (frame {frames})", flush=True)
p.kill()
dt=time.time()-t0
print(f"{frames} frames in {dt:.1f}s = {frames/dt:.2f} fps; max glyph {best}; hits {hits}")

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Match a captured game frame against candidate .wmv files by frame signature.
Static + previously-captured frames. See docs/re/movie-binding.md."""
import numpy as np, subprocess, sys, os
from PIL import Image
W,H=32,18
def sig_img(path, crop_bottom=False):
im=Image.open(path).convert("L")
if crop_bottom: # movie frame -> what the capture can see
w,h=im.size; im=im.crop((0,0,w,int(h*675/720)))
a=np.asarray(im.resize((W,H)),dtype=np.float32).ravel()
return (a-a.mean())/(a.std()+1e-6)
def movie_sigs(path, fps=1.0):
out=f"/tmp/q9/fr_{os.path.basename(path).replace('.wmv','')}"
os.makedirs(out, exist_ok=True)
if not os.listdir(out):
subprocess.run(["ffmpeg","-v","error","-i",path,"-vf",f"fps={fps},scale=160:90",
"-y",f"{out}/f%05d.png"],check=True)
fs=sorted(os.listdir(out))
return [(int(f[1:6]), sig_img(os.path.join(out,f), crop_bottom=True)) for f in fs]

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Does HANDOFF.md still assert something its own linked doc has retracted?
This has now happened four times: a finding lands in docs/re/ and the page the
port agent actually reads keeps the superseded text. Once it was worse than
stale -- the port was told SE audio was "undecodable from the disc" while three
cues were located and decoding to PCM in the linked file.
For every HANDOFF line that makes a STRONG NEGATIVE claim and links to a doc,
check whether that doc contains retraction language. A hit is not proof the row
is wrong -- docs retract other things too -- it is a row to read.
handoff_lint.py [docs/port/HANDOFF.md]
"""
import os, re, sys
HANDOFF = sys.argv[1] if len(sys.argv) > 1 else "docs/port/HANDOFF.md"
# Resolve links the way markdown does: relative to the FILE, not to a guessed
# repo root. The first version joined a guessed root and reported every existing
# doc as missing -- which is the tool failing its own control, and is why the
# "missing doc" branch prints rather than being silently skipped.
BASE = os.path.dirname(os.path.abspath(HANDOFF))
NEGATIVE = re.compile(r"undecodable|cannot be|can not be|impossible|no .{0,24}exists|"
r"does not exist|not extractable|unreachable", re.I)
RETRACT = re.compile(r"retract|withdraw|was too strong|that was wrong|superseded|"
r"refuted\b.{0,40}\bmine|no longer", re.I)
LINK = re.compile(r"\]\(\.\./re/([^)]+)\)")
def main():
text = open(HANDOFF, encoding="utf-8").read().splitlines()
flagged = checked = 0
for n, line in enumerate(text, 1):
if not NEGATIVE.search(line):
continue
for rel in LINK.findall(line):
doc = os.path.normpath(os.path.join(BASE, "..", "re", rel.split("#")[0]))
if not os.path.exists(doc):
print(f" L{n}: link to a MISSING doc: {rel}")
flagged += 1
continue
checked += 1
body = open(doc, encoding="utf-8").read()
hits = sorted({m.group(0).lower() for m in RETRACT.finditer(body)})
if hits:
flagged += 1
claim = NEGATIVE.search(line).group(0)
print(f" L{n}: claims {claim!r}; linked {rel} contains {hits}")
print(f"\n{checked} negative claim(s) with links checked, {flagged} to read")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,88 @@
"""Structural census of the keyframe block's angle words (+4/+8/+12), disc-wide.
`+12` is the screen-plane ROTATION in degrees (see
docs/re/structures/ui-keyframe-rotation.md). This is the disc-wide check behind
that decode, and it exists because the FIRST version of it was wrong in a way
that hid the very blocks the decode rests on.
⚠️ The blocks are **not 4-byte aligned**. A nested leaf record's `RATC` blob can
start at an odd offset (`ptloop01.rat` sits at 0xbb5966), so its keyframe blocks
inherit that alignment. An earlier scan filtered candidates on `%4 == 0`, found
0/3 of its own control blocks, and under-counted the corpus by 16 341 blocks —
every one of them inside a nested record. The CONTROL below is not decoration:
it must print 3/3 with +12 = {30} and {-45} or the numbers mean nothing.
A keyframe block is 40 bytes: fade(ARGB) | w1 w2 w3 | sx sy | tint | x y | t.
The filter keys on SHAPE, not on a count: >=2 consecutive blocks whose fade is
0x??ffffff, whose tint is 0xffffffff, and whose scale words are 1..4000.
"""
import struct, zlib, glob, os, sys, collections, re
def entries(base):
stub=open(base+'.pak','rb').read()
if stub[:4]!=b'IPFB': return
n=struct.unpack_from('>I',stub,4)[0]
segs=sorted(glob.glob(base+'.p[0-9][0-9]'))
if not segs: return
blob=b''.join(open(s,'rb').read() for s in segs)
for i in range(n):
h,off,sz=struct.unpack_from('>III',stub,0x10+12*i)
st=blob[off:off+sz]
if len(st)<10: continue
try: yield h,(zlib.decompress(st[10:]) if st[:2]==b'Z1' else st)
except Exception: continue
def blocks(d):
"""Yield offsets of the first block of each run of >=2 keyframe blocks."""
n=len(d)
U=lambda p: struct.unpack_from('>I',d,p)[0]
seen=set()
cands=sorted({m.start()-24 for m in re.finditer(b'\xff\xff\xff\xff',d)
if m.start()>=24})
for o in cands:
if o in seen or o+80>n: continue
if (U(o)&0x00ffffff)==0x00ffffff and U(o+24)==0xffffffff \
and 0<U(o+16)<=4000 and 0<U(o+20)<=4000:
k=0
while o+40*(k+1)<=n and (U(o+40*k)&0x00ffffff)==0x00ffffff \
and U(o+40*k+24)==0xffffffff \
and 0<U(o+40*k+16)<=4000 and 0<U(o+40*k+20)<=4000:
k+=1
if k>=2:
for j in range(k):
seen.add(o+40*j); yield o+40*j
S=lambda d,p: struct.unpack_from('>i',d,p)[0]
# --- control: the two known ptloop blocks must be found, with 30 / -45 ---
d4=open('/tmp/build4.bin','rb').read()
found={o for o in blocks(d4)}
for name,base,want in [("ptloop01",0xbb5966,30),("ptloop02",0xbb5a82,-45)]:
hits=[b for b in (base+0x68+40*k for k in range(3)) if b in found]
vals={S(d4,b+12) for b in hits}
print(f"CONTROL {name}: {len(hits)}/3 blocks found, +12 = {vals} (want {want})")
if not all(True for _ in [0]): sys.exit(1)
hist=collections.Counter(); nz=collections.Counter(); total=0
examples=collections.defaultdict(list)
for pak in sorted(glob.glob('/work/sylph_extract/dat/GP_*.pak')):
base=pak[:-4]
for h,d in entries(base):
if b'RATC' not in d[:4] and d[:4]!=b'RATC': pass
for o in blocks(d):
total+=1
for lbl,off in (("+4",4),("+8",8),("+12",12)):
v=S(d,o+off)
if v!=0:
nz[lbl]+=1
hist[(lbl,v)]+=1
if len(examples[lbl])<8: examples[lbl].append((os.path.basename(base),f"{h:08x}",hex(o),v))
print(f"\nblocks scanned disc-wide: {total}")
for lbl in ("+4","+8","+12"):
print(f" {lbl}: non-zero in {nz[lbl]:6d} ({100*nz[lbl]/max(total,1):5.2f} %)")
print("\nvalue histogram (non-zero), top 25:")
for (lbl,v),c in hist.most_common(25):
print(f" {lbl} = {v:>8} x{c}")
print("\nexamples:")
for lbl,ex in examples.items():
for e in ex[:4]: print(" ",lbl,e)

View File

@@ -0,0 +1,11 @@
import sys
from PIL import Image
import numpy as np
names=["NEW GAME","LOAD GAME","TUTORIAL","OPTIONS","EXTRAS"]
ys=[166,241,315,390,465]
for p in sys.argv[1:]:
a=np.asarray(Image.open(p).convert("L"),dtype=float)
# the focus RING sits in the gutter left of the label; nothing else is bright there
v=[a[y-20:y+20,500:542].max() for y in ys]
i=int(np.argmax(v))
print(f"{p.split('/')[-1]:26s} -> {names[i]:10s} " + " ".join(f"{x:5.0f}" for x in v))

Some files were not shown because too many files have changed in this diff Show More