Commit Graph

463 Commits

Author SHA1 Message Date
sylph-decoder
722b8e3f4d re: the tone curve s single exponent is refuted above render 40
Raised by the port and independently reproduced here before being adopted, since
adopting their claims unchecked has misfired twice this session.

Binning matched pixels by render level rather than fitting a scalar, the implied
exponent falls monotonically and crosses 1.0:

  render          16     23     31     39     47     64
  port  (all)   1.26   1.18   1.10   1.03   0.93   0.85
  mine  (flat)  1.303  1.347  1.128  0.912  0.935  1.003

Below the crossing the capture is darker than the render, which is what the page
measured; above it the capture is brighter. A single exponent cannot express a
curve that crosses unity, so the model is valid only in the darks -- which is
exactly the reach the page already stated. The reach line was not a hedge, it was
the finding.

Where the two disagree is recorded and not resolved: the crossing is about 44 by
their binning and 35 to 40 by mine, and the darks read 1.18-1.26 theirs,
1.30-1.35 mine, 1.49 for the page s original patch fit. Three estimators on three
populations, all agreeing on direction and on gamma above 1 in the darks.

A confound in my own reproduction is stated rather than left implicit: whole-image
correlation is only 0.594 because the committed capture and the default render
differ in focus state, which the port measured as 74.1 percent of differing
pixels. My bins include that mismatch, so they are not a clean second opinion.
And a 1280x720 render against a 1279x675 capture needs a resample, which is why
the fit is restricted to flat-neighbourhood pixels.

METHOD gains the general form: a stated reach is a boundary rather than a hedge,
and the fix was printing the curve instead of a scalar, because a scalar hides
its own domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:20:41 +00:00
sylph-decoder
8841fe9c91 re: the voice dialogue is in the CENTRE channel -- measured, not inferred
The 5.1 reading of a voice cue s three concurrent streams was recorded here as a
hypothesis with counter-evidence attached. It is now answered, by the port
fitting the disc s decoded streams against a clean capture of the game s own
6-channel output, with the instrument controlled first (known-present margin
+0.248, known-absent +0.005).

Speech-band margins put streams 2 and 3 at +0.305 and +0.307 on FC, r = 0.989,
above the known-present control, while stream 1 sits in the noise on every
channel. The low band mirrors it exactly: the movie bed at 0.76 to 0.84 on the
four corners against 0.32 on FC. Dialogue in the centre, bed in the corners.

The hypothesis was right for a reason the file could never have supplied.
ChannelMask reads 0x0002 on all three streams, so the header is not merely
unhelpful, it is actively misleading -- refusing to call it 5.1 from the header
was correct, and the oracle answered what the header could not.

Three limits recorded as the measurer stated them: streams 2 and 3 are
indistinguishable to this instrument, so no rule for choosing between them is
vindicated; the one-of-three-streams warning stands, since nothing says what
streams 1 and 3 contribute; and the reach is 59.7 s of a 137 s movie, one run,
one asset.

Also records the capture that would strengthen it most and why it is not taken:
S00A rather than a longer ADV, because its second full-length stream is digital
silence where ADV s is a 0.60x copy, so a structurally different movie would
agree. Reaching it needs a driven rendered run -- S00A starts about 4.5 s after A
on the save slot -- so it cannot use --gpu=null and will carry the additive
padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:01:26 +00:00
sylph-decoder
0e03e7496e re: the three streams of a voice cue are decoded CONCURRENTLY, not alternatives
Refutes a framing of mine that two documents and the port s exporter were built
on, so it is a new page rather than an edit.

voice-region-leading-chunk.md read a long cue s three streams as three
presentations of one take, and from that came the instruction "take one stream,
do not sum", which the port implemented. The open question was which
presentation the game plays.

It has no answer. Booted with --xma_param_probe=true, the cvar whose own comment
says it is keyed to reveal which sub-wave of a movie s .slb the game actually
decodes. The guest opens three XMA contexts and decodes all three concurrently:

  ctx=0  packets=632  byte_size=1294336  ch=2  48000   ADV stream 1
  ctx=1  packets=546  byte_size=1118208  ch=2  48000   ADV stream 2
  ctx=2  packets=572  byte_size=1171456  ch=2  48000   ADV stream 3

Byte-exact against the three streams payloads taken independently off the disc
(RIFF size minus 60). Only these three contexts appear in the run.

So a consumer that picks one discards two thirds of what the game mixes. Both
"three presentations of one take" and "take one stream" are withdrawn -- and the
previous behaviour is not thereby right either, because an equal-gain 1/n sum of
channel pairs is not a downmix and the port s measured 6.02 dB loss was real.
Neither rule is established; a consumer is authoring.

Three concurrent stereo streams is six channels and N stereo streams is how XMA
carries multichannel on the 360, which would also explain the 1-or-3-never-2
span census. Recorded as a hypothesis, not a result: all three fmt chunks
declare ChannelMask 0x0002 identically, which is not what distinct channel roles
should look like.

Everything byte-level survives: the leading chunk being stream 1 clipped by our
own guard, the 70 + 8 + 17 decomposition, the bank-header discriminator.

Reach: one cue, one boot. That 28 cues are 3-stream is decoded; that all three
decode concurrently is measured on ADV alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:48:35 +00:00
sylph-decoder
aad050cb4a formats: XMA1 is not a WAVEFORMATEX -- audio info was reading three wrong fields
parse_riff_wave read every fmt chunk as a WAVEFORMATEX. XMA1 (tag 0x0165) is
not one, so audio info reported the disc s movie voices as 16 channels,
4310 Hz, 2-bit: 16 is wBitsPerSample read as a channel count and 4310 is
wEncodeOptions (0x10d6) read as a sample rate. This misled me earlier in the
session and I recorded it as a limitation before finding the cause.

XMA1 carries XMAWAVEFORMAT followed by one XMASTREAMFORMAT per stream. The
reader now branches on the tag and reads bits at +2, PsuedoBytesPerSec at +12,
SampleRate at +16 and Channels at +29. The same three files now report 2
channels, 48000 Hz, 16-bit.

The consequence worth having: this crate has no XMA decoder, and
data_bytes / PsuedoBytesPerSec is the only route to a duration. Checked against
durations decoded independently by the port:

  ADV presentation 1   137.34 s declared   137.324 s decoded   +0.012 percent
  ADV presentation 2   137.33 s declared   137.324 s decoded   +0.004 percent
  S00A presentation 1   93.71 s declared    93.694 s decoded   +0.017 percent

So the corpus can now get XMA1 durations off the disc without a decoder, which
is a capability I had written down as absent. It is a declared rate rather than
a measurement of the samples, and the CLI labels it as such.

Regression test pins the real on-disc header bytes and asserts the duration
against the independently decoded 137.324 s. 115 lib tests and 3 media disc
tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:42:52 +00:00
sylph-decoder
7a0e18889f re: the dual-mono explanation for the extra stream bytes does not generalise
The port chose a voice presentation on the argument that ADV chunk 1 is
mono-in-stereo and chunk 2 is dual-mono, so chunk 2 s extra bytes encode a
duplicated channel rather than fidelity -- which would explain its higher
declared PsuedoBytesPerSec without appealing to encode quality.

Their ADV channel measurement stands. The generalisation does not. If stream 3
were systematically the same take with its channel duplicated, its size ratio to
stream 2 would be tight across the 28 three-stream cues. Measured:

  min 0.0778 (S00A, the silent one)
  median 1.2565
  max 2.9163 (S06A)
  sd 0.5057
  within 15 percent of 1.0: 12 of 28

A 37x spread is not a duplicated channel, and the declared rates scatter with
them -- S06A is 5661 against 16513 B/s. Whatever distinguishes the three streams
varies per cue rather than being a fixed channel-configuration triple.

This does not touch the port s decision, which is to take the loudest
presentation: that is a per-asset content measurement, not a structural rule, so
a scattering ratio cannot undermine it. It touches the explanation, which should
not harden into a fact about the format.

Two curiosities recorded: S12B s three streams are byte-size identical at 14396
each, and BIRD_224 is 3-stream while being a non-movie cue, so the shape is not
exclusive to cutscenes.

Also narrows the settle-time page s own generalisation. The port measured its
boot the way this corpus measured the game and found the sequencer NOT late --
its 0.6 s discrepancy was arrival-to-arrival timestamps compared against visible
spans, the plate-delay trap in a second place. So what is supported is that
rest.t is the wrong landmark for the TITLE, not that everything paced off it is
late. And the offered re-take of the one-run menu figures is recorded as
declined, with the reason, rather than left looking unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:37:46 +00:00
sylph-decoder
ff715938f5 re: withdraw a stream-selection recommendation that contradicted itself
The port implemented "take the highest-rate stream" and reported that on ADV it
selects the QUIETER presentation -- chunk 2, 1171516 B at -8.3 dBFS, over
chunk 1, 1118268 B at 0.0. They were right to flag it rather than accept it.

My sentence was "the highest-rate, highest-gain one is chunk 1". Those two
criteria do not select the same stream and the sentence should never have joined
them; the parenthetical named chunk 1 while the rule named chunk 2. Withdrawn.

What the header does decode, read off the bytes: the fmt chunk is a 32-byte
XMAWAVEFORMAT, little-endian, and +0x20 is a declared PsuedoBytesPerSec -- 8142
and 8530 on ADV s two presentations, agreeing with the computed rates to 0.02
percent, with 48000 Hz at +0x24. So the rate is decoded rather than inferred.

What it does not decode: wEncodeOptions (0x10d6), channel count and channel mask
are byte-identical across the presentations. Nothing in the header ranks them,
so stream selection stays an authored choice and the port must know it is
authoring. Settleable in one emulator run -- a capture of the intro with the
dialogue audible says which level the game plays -- and not yet done.

Also records that sylpheed-cli audio info is misaligned for XMA1: its "16
channels / 4310 Hz / 2-bit" is wBitsPerSample, wEncodeOptions and the channel
fields read at the wrong offsets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:13:20 +00:00
sylph-decoder
801062c933 re: a long voice cue is three presentations of one take, and our guard clips the first
Closes the last open question on the voice regions: why one cue s byte span
decodes to ~2.6x the movie s length.

The port measured, with controls including a cross-movie negative, that a
region s leading chunk is the TAIL of the full-length chunk that follows it --
r = 0.998 at a lag that puts it flush against that chunk s end, residual 16.7 dB
down over 84.5 s. They withdrew their own earlier 0.768, which came from a
search that scored best on the boundary of its own lag range.

Checked it here by an independent route that needs no decoder. If the leading
chunk is the tail of a full-length first stream, the whole leading stream should
be one complete take of chunk 1 s duration. For ADV: 504464 + 808304 = 1312768 B
at chunk 0 s byte rate of 9559.7 B/s is 137.323 s, against chunk 1 s measured
137.324 s. One millisecond over 137 seconds, from byte rates rather than from
envelope correlation.

And the byte structure settles the shape disc-wide. Counting stream starts inside
every inter-descriptor span: 258 hold exactly 1 stream, 28 hold exactly 3, and
nothing holds 2 or any other number. All 20 spans over 1.5 MB are 3-stream. The
95 movie regions decompose 70 + 8 + 17, and the 8 are independently the same 8
the first census found as bank-header-with-3-chunks.

So 359 s = 84.55 + 137.32 + 137.32: three presentations of one take, the first
clipped by resolve_movie_voice_region s own 1.5 MB guard.

Consequences recorded for the port: dropping the leading chunk is removing a
duplicate rather than truncating, so the hedge is lifted; but summing chunk 1
and chunk 2 is wrong, because they are the same take at different gain, not two
stems. Take one stream.

Also flags a coincidence I nearly built on: the 504464 B constant is structural,
not proportional -- ADV s proportional prediction lands within 8 bytes of it and
S00A s is 4305 B out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:09:40 +00:00
sylph-decoder
06a1779b19 re: the voice region s leading chunk is the movie s OWN dialogue, and a guard puts it there
My own leading hypothesis -- that the leading chunk is an in-mission VOICE_D_*
line -- is refuted, on the route the port suggested: widen the enumeration past
the 95 manifest-bound movies and the byte-span test settles it without anyone
listening.

Scanning the stream for every trailer descriptor (the (id, 0x11) pair whose id
repeats at +0x800) gives the complete cue partition, mission lines included:
287 descriptors in a 116.2 MB window, all 287 carrying an id the 4280-name
registry names. Every one of the 17 leading spans is bracketed by
desc(N-1)..desc(N) where desc(N) is that movie s OWN cue id. Zero mission lines.

The mechanism is a guard in our own resolver. resolve_movie_voice_region takes
the predecessor trailer as the region start, guards it with
end - start < 1_500_000, and falls back to the .slb TOC anchor when that fails.
Cues with a true span over the guard: 17, of which 17 are stream-opening. Cues
under it: 78, of which 0. Perfect discrimination both ways. The anchor sits a
constant 504464 B after the true predecessor trailer on all 17, which is
unexplained.

Not established, and stated as such: this does NOT mean the export truncates N
seconds. The port s decode already has ADV s region at 359 s against a 137 s
movie, so it over-covers and the byte-to-time mapping is not linear. No XMA1
decoder in this container to check.

Also withdraws a claim this page had adopted from the port -- that chunks 1 and
2 are two stems of one performance. The port refuted its own claim by decoding:
S00A chunk 2 is digital silence, ADV chunk 2 is 0.60x chunk 1 with the residual
26.8 dB down. Equal duration was a shape match and Q10 s music census should not
have been carried across to voice on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:03:58 +00:00
sylph-decoder
7e12a3b1f9 re: the voice-region third chunk is a different structure from the BGM one
The port hit a 2+1 chunk signature on a resolved movie-voice region and asked
whether the bank-header explanation that closed HANDOFF Q10 also covers it,
rather than assuming it. It does not, and the discriminator is mechanical.

Disc-wide over the 95 English movie-voice regions the manifest binds:

  78 open with a bank header -- bank_header_len fires, 10240 B = 5 packets
     exactly, every time. That is the BGM case.
  17 open with a leading headerless stream -- bank_header_len is None, and all
     17 have length congruent to 1392 mod 2048, the disc s own derived data
     offset. No other residue occurs.
   0 begin at a RIFF.

Counting chunks does not discriminate: 8 bank-header regions also yield three
chunks. slb.rs already predicted this in its own doc comment -- the header
signature has "zero false positives on the 7993 mid-bank windows, where the
leading region IS real" -- and a voice region is a mid-bank window by
construction.

Also tested the obvious defence of dropping the leading chunk, that it is the
predecessor cue s audio: 0 of 17 leading spans lie inside any other resolved
region, 0.0 percent on every one. The test finds overlaps where they exist (16
overlapping pairs among the regions, 60 exactly-adjacent boundaries, 73 of 78
bank-header regions starting where another ends), so the zero is not the
instrument.

Left open, with reach: the census covers movie-voice regions only, and the same
stream carries the in-mission VOICE_D_* cues, which are not enumerated -- the
leading bytes plausibly belong to one of those. Could not be settled by
listening: no XMA1 decoder in this container, and sylpheed-cli audio info
reports these chunks as 16 channels / 4310 Hz / 2-bit, which is visibly wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:56:17 +00:00
sylph-decoder
ae42f7f850 re: the paint-order tie-break costs the port zero pixels, not 24 pairs
The census bounded WHERE a wrong tie-break could show and said outright that
nobody had measured how many of those pairs change a pixel. Measured.

`compose_with_order` renders a bundle in a caller-supplied order; the new
example renders each screen twice, once derived and once with one tied pair
swapped, and diffs. Same-key elements are contiguous under a stable sort on
(key, i), so a swap paints nothing else in between.

Controlled per entry: swapping an OVERLAPPING pair with DIFFERENT keys moves
36 305 to 771 479 px (max delta 254). Where no such pair is drawn the output
says so rather than reporting an uninterpretable zero.

* EXTRAS (entries 6/9) and the main menu (5/8): 0 px. The tied ptframe pairs
  ink ~3 600 px each and share NONE of them -- the 102x132 rect overlap was an
  artefact of approximating an element as pivot x 2. Blend-independent.
* Across all 31 drawable overlapping tied pairs in GP_TITLE, the largest change
  any of them makes to any channel is 3/255.
* Withdrawn: "a wrong tie-break can be wrong by a whole layer". That rested on
  ptlogo_back2eff5 geometrically containing two other glows. Rendered, the swap
  moves 6 390 px by max delta 2. Containment is not occlusion when the container
  is a near-transparent glow, and nobody had rendered it before asserting it.

Reach: this measures our compositor's sensitivity to order, not the game's. The
zero-shared-ink results hold under any per-pixel blend; the delta<=3 figures
assume ours.

13 disc-gated ui_paint_order_disc tests and 114 lib tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:47:19 +00:00
sylph-decoder
c5b815c5aa re: the paint-order tie-break costs 24 overlapping pairs, not one element
The port challenged HANDOFF's "costs one element's blend on one screen" with a
census of 105 elements sharing a layer key across 12 of 16 screens. The two
numbers count different things -- elements vs overlapping pairs -- so it is not
the contradiction it looked like, but the objection stands and the line was
wrong.

paint_order_audit already reports overlapping ties per entry, and over all 16
GP_TITLE entries: 5 use a measured order and carry no tie risk; of the 11 that
fall back to the derived order, 7 have overlapping ties, 24 pairs in total. The
Japanese title (entry 7) alone has 16, because it is the twin of the one build
whose measured order exists and has none of its own.

Overlap bounds where a wrong tie-break COULD show, not what it costs; nobody has
measured how many of the 24 change a pixel, and the port is right about that too.

The rule itself is unaffected: the layer key is still decoded and the derived
order still reproduces every measured order exactly except the title's eight
tied pairs.

Census committed at docs/re/data/paint-order-ties-gp_title.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
2026-08-29 14:17:40 +00:00
sylph-decoder
c1f3608b07 formats: a music bank's third sub-wave was its own header
The port hit `sound_bank_riffs("BGM_103.slb")` returning three against a census
that says two, and refused to guess which to drop. It was our reader.

`to_xma_riffs`'s hybrid branch derives a leading packet stream's start as
`first_riff % XMA1_PACKET`. That is right only when the bank header is smaller
than one 2048-byte packet -- true of the voice banks the branch was written for
(1392/1468/1600/1728), false of a music bank, whose header is exactly five
packets. The modulus returned 0 and the whole 10 240-byte header was emitted as
sub-wave 0.

The header states its own length, so the guard needs no threshold: BE u32 0x800
at +0x18 with the bank id repeated at +0x00 and +0x20, header length in blocks at
+0x24. Disc-wide over sound.pak's 9 519 entries, 28 match at offset 0 -- every
music bank, ids 1001-1023 and 1101-1105 -- and on 28/28 the declared header ends
EXACTLY at the first RIFF. Zero have a gap, so a header and a leading packet
stream never coexist here; zero false positives among the other 9 491.

Controlled rather than argued: decoding the emitted region through the same
chain, on the same bank, in the same run gives 0.009 s of PCM where the bank's
real wave 0 gives 87.744 s against a declared 87.75. The region is also 99.1%
zero bytes. And the oracle had already said two -- the XMA probe at the main menu
saw exactly two streams, at BGM_103's two declared wave sizes.

BGM_106-109 are deliberately NOT in the 28: their entries start mid-bank, so they
have no header at offset 0 and their leading region is real audio. The
VOICE_D_453 recovery is untouched and its tests still pass, 10/10 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:30:16 +00:00
Sylpheed RE agent
3491d30066 re(media): the disc ships movies in TWO audio profiles, and 28 of them are 5.1
Probed all 97 movies. 28 are wmapro 48 kHz 6-channel 5.1 -- ADV.wmv and every
S*.wmv story cutscene; the other 69 are wmav2 48 kHz stereo, every RT*.wmv and
hokyu_*.wmv. The split is cinematics vs in-mission radio chatter.

Both movies the menu milestone needs, ADV.wmv (the boot/attract intro) and
S00A.wmv (the new-game intro), are in the SURROUND group.

Why it matters: one ffmpeg command over dat/movie/ produces two different kinds
of result and records neither. The 69 stereo files pass through unchanged; the 28
surround files get downmixed 5.1 -> stereo by ffmpeg's DEFAULT matrix, folding
centre-channel dialogue into L/R at a weighting nobody chose and which is not
stable across ffmpeg versions. That is a content decision inherited by accident,
so it should be stated explicitly and recorded beside the command.

Credit where due: found by the human while checking a transcode, verified
independently here and widened from one file to the whole disc.

Two METHOD entries from the same episode, both about measurement rather than
format: don't probe a file another process is still writing (a half-written
transcode reported 33 s against a 137 s source, no error, nearly a filed bug),
and a difference-signal RMS is meaningless before cross-correlation alignment
(-34.2 dB against a -25.3 dB source looks like failure and is inconclusive).
2026-08-29 08:57:56 +00:00
Sylpheed RE agent
7eeae3006a re(ui): the focus ring SPINS, the game draws it, and the leaf owns the f record
Three things, all from parsing ptbtn0Nf.rat as a build.

1. THE RING SPINS. Its two keyframes differ in exactly one field: rotation_deg
ramps 0 -> 360 with position, scale, alpha and tint all constant. A spin in
place, the same shape as the GP_BUNK example already recorded.

2. THE ORACLE CONFIRMS THE GAME RENDERS IT. In the OPTIONS-focused capture the
ring's bright head sits in a completely different angular position from the
sprite's own -- caught mid-spin. This is a SECOND independent confirmation that
rotation_deg is drawn, now on a different screen and a different element from the
ptloop sweeps, and it raises rotation's priority: it is not a title-only concern
that sits off-screen at rest, it is the main menu's focus marker.
NO ANGLE IS QUOTED. A brightest-region centroid says ~250 deg, but the control
refuses that precision -- rotating the sprite by a known 30/90/180/270 and
re-measuring gives errors up to 19.8 deg. What survives the error bar is that a
<=20 deg error cannot manufacture a ~250 deg displacement.

3. WHICH PLACEMENT WINS -- correcting this page's own earlier caveat, which said
to use the leaf only for elements the parent does not declare. Right for a BASE
record, wrong for an f record: the parent declares NO element for ptbtn0Nf.rat at
all (zero of build 5's 16), so the f record's placement comes from its leaf for
BOTH elements, label included. The label's (-7,-7) is load-bearing -- the f
sprite is 13px larger per axis and -7 keeps them concentric (535+96/2 = 583 vs
542+83/2 = 583.5). Corroborated against the oracle: the focused-minus-unfocused
region is x 505..703, and the leaf predicts a right edge near 707 where the
parent reading predicts 714.

Also exposes UiBuild::records (name -> (offset, size) of a nested .rat leaf).
Nested records were parsed into a PRIVATE map, so a consumer holding a UiBuild
could not locate a leaf's bytes at all -- which is exactly what blocked the port
from reaching the ring.
2026-08-29 08:46:53 +00:00
Sylpheed RE agent
b21c8e4118 re(ui): the focus ring's position is decoded -- a .rat leaf parses as a build
The port needed ptbtneff01.t32's placement and was about to author it from an
eyeballed PNG measurement. It does not have to: a `.rat` leaf needs no new
reader. Its first 32 bytes have a bundle header's shape -- "RATC", 0x3c
declaration-entry size at +4, element count at +20, design 1280x720 at +24/+28 --
so ui_layout::parse_build reads it unchanged.

The control is the base record, whose position is known independently: the parent
screen reports ptbtn01.rat resting at (542,162), and parsing the leaf alone
returns ptbtn01.t32 at (542,162). It reproduces all five buttons.

Positions are absolute design-space top-left. The ring rests at (500, 156/236/
316/396/476) for buttons 1-5 -- a uniform (-42,-6) from each button's own rest,
identical in the Japanese bundle. The bright label is a uniform (-7,-7).

Two things recorded rather than smoothed over: a leaf's placement DUPLICATES the
parent's rather than being relative to it, and the two copies are not always
byte-equal (ptbtn04's parent says y=401, its leaf says 402) -- the parent is what
compose honours, so the leaf is the source only for elements the parent does not
declare, which is exactly the ring. And `screen render --focus` is blind to the
ring for the same reason the port's exporter was: el.focused is name-based on
top-level elements and neither walks into the leaf.
2026-08-29 08:36:53 +00:00
Sylpheed RE agent
9ca1eb50fd re(ui): answer four of the port's five asks -- splash, fade-out, focus, gamma
1 SPLASH ADDRESSING (was blocking P3). No content predicate exists: design size
fails (every extra composable bundle sampled is 1280x720, like every screen) and
element count fails (fragments run 2..15, the splash halves are 3 and 7). But
GP_TITLE needs none -- `--all` adds exactly four bundles there and all four are
real screens, with the --all index equal to the pak entry index 1:1. And there
are TWO splash screens: 11/14 are the developer logos, 10/13 are the SQUARE ENIX
publisher wordmark, which the port did not have and which the boot shows first.

2 FADE-OUT (was blocking P3). It is (a), and it is bigger than the fade quad.
Every element ends on exactly ONE untimed keyframe, which rules out (b); that
block is where the screen plays out -- quad to a=255, buttons/labels/glows to
a=0, frames hold. (c) is refuted by a null test that discriminates: a black quad
alone holds the button/background brightness ratio constant, and through the
fade it falls 6.50 -> 1.94, 3.4x monotonic.

3 FOCUS (saves P5 rework). Over-vs-instead is unobservable -- the focused sprite
covers the base at 100.0% of base-visible pixels on three pairs once aligned
(true offset (7,7); the centre alignment reads a misleading 78-84%), and
compositing both ways differs by RMSE 1.1 inside the button rect. The real defect
is the focus record's SECOND element: ptbtn0Nf.rat declares ptbtneff01.t32 (a
42x46 glowing ring, focus only) plus the bright label, where the base declares
one sprite. That ring is the marker the port draws nowhere.

5 GAMMA. The capture is not neutral: capture ~ 255*(render/255)^g, g ~ 1.34-1.49,
and the chain says it is a ramp the GAME installed, not a capture artefact. So
RMSE against captures has a floor. Reach stated: the flat patches are all dark
(render ~0-60), so midtones and highlights are unconstrained.

4 ROTATION is a human's call and is recorded in MISSION, not acted on -- the port
rotating while the reference renderer does not would make verify-screen report a
large diff meaning "the port is right". The RE half is answered: rotation is
about the declared pivot, measured against a GPU capture.

The focus record's +20 element-count word is marked 🟡 not  -- read on GP_TITLE's
ten button records only; the disc-wide check is written and still running.
2026-08-29 08:19:23 +00:00
Sylpheed RE agent
f817dd5939 re(ui): the 60 nameless RATC children are frames, not children -- .tan decoded
Closes the reach caveat the `opt ` name fix left behind: 60 of 18 002 RATC
children carry no `opt ` block, and it was not established whether they lack one
or sit past our 128-byte window.

Neither. They are not children. `examples/ratc_optless_children.rs` re-runs
`ratc::parse`'s own guards over the disc and reports which one fired: all 60 are
"tag beyond the window", none is rejected by length, gap or charset, none is
child #0, and all 60 live in six bundles of one archive. Within a bundle the
distances back to the nearest tag are an exact arithmetic progression, step
60 600 -- ten different records finding the SAME tag, because there is only one.

Reading a bundle directly: children 1..10 are equal-size T8aD blocks under a
single `opt ` name, `pb_f15_eg_anm.tan`. `.tan` is a FRAME SEQUENCE. One block
declares the resource; its payload is a run of T8aD frames.

Disc-wide, over all 18 718 `opt ` names in all 33 paks: a RATC bundle names
exactly six kinds of resource -- `.t32` 14 756, `.rat` 3 311, `.prm` 367,
`.tbm` 224, `.sbo` 54, `.tan` 6. Six `.tan`, ten frames each = 60, the entire
population with nothing left over. The negative is closed, not narrowed.

Consequence recorded but deliberately not fixed: `ratc::parse` over-reports
there, listing a `.tan`'s frames as anonymous children. Nothing in the menu
milestone reads a `.tan` -- it occurs only in GP_READY_ROOM, which S1 ruled
out -- so no screen the port draws changes.

Also a METHOD entry for this container OOM-killing `slb_leading_segment_disc`
under default test parallelism (SIGKILL, no assertion; 8/8 pass with
--test-threads=1).
2026-08-29 07:39:09 +00:00
Sylpheed RE agent
56cc7acfc3 re(ui): a RATC child's name is stated, not inferred -- and it was hiding every menu background
`ratc::parse` named each child by scanning backwards for the last printable run
of bytes before its magic. The format states the name explicitly instead, in an
`opt ` block: `"opt " | BE32 len | name | NUL | 3 bytes | magic` -- the same
block `ui_layout::opt_link` already read for a button's focus link.

The scan agrees with it 17 918 times out of 17 942 and is wrong 24 times, every
one the same failure: the 3 trailing payload bytes are themselves printable and
beat the real name. For `pteff05.t32` those bytes are `38 41 58` = `8AX`, so the
full-resolution background of all five menu screens registered under a name no
element declares, resolved to no sprite, and `compose` dropped it through an
early `continue` that -- unlike the two arms above it -- records nothing. The
screen lost its background and `screen render` still reported "all resolved".

`8AX` was never a name. Docs that treated it as one are corrected here.

Disc-wide, and the control is the 17 918 the scan already got right: the `opt `
reading reproduces every one of them. Effect on the five screens is the
signature of the same art at twice the resolution -- mean brightness unmoved,
high-frequency detail x1.15..x1.30 -- which is what the separately-measured
`ui-8ax-fullres-background` result said the game draws.

Also closes a long-standing dangling reference: `pmbase.t32`, recorded as "on
the disc nowhere", is the `GP_STAGE_CLEAR` child the scan called `8AX`. RATC
sibling references now resolve 10 148 of 10 148.

Verified: 114/114 sylpheed-formats unit tests (including two new ones pinning
the `8AX` case byte for byte and the no-block fallback), and every disc-gated
integration suite in sylpheed-formats/sylpheed-cli.
2026-08-29 07:26:56 +00:00
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
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
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
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
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
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