handoff+index: H3 answered, the settle anchor, and the pad's full field set

Three rows the port is blocked on:

H3 -- 2 units per guest frame, measured on the plate's own ramp against a
pre-registration. Their 5 excluded by >2x, and the reason named: an alpha
step is not a clock rate without the element's declared T, and my own
splash-quad-timeline.txt published alpha against frame with no T column.
That file now warns at its head.

The title settle anchor is t~160, not t=118 -- it is ptcopyright, the last
build-in element and the only glyph one. Said plainly that this is the
reading under which their clock:'shared' collapses, and that a consequence
is not a counter-argument.

Told them NOT to change their 60 units/s yet: units/second is untouched by
this and two of my own captures disagree ~2.9x on frames->seconds.

The pad: sub_82457038 reads every field of XINPUT_GAMEPAD, 14/14 loads
verified against the image, plus a second keystroke-queue path. Flagged as
the superset the game can SEE, not the per-screen set, so it is used to
check a binding table and not to write one.
This commit is contained in:
sylph-decoder
2026-09-01 16:50:52 +00:00
parent 6aab30253a
commit 0565098fa2
4 changed files with 300 additions and 0 deletions

View File

@@ -46,6 +46,128 @@ silent. A blank output is a failure, not an empty ask list.
## ✅✅ 2026-09-01 (later) — **H3: it is 2 units per guest frame. The 5 was an artefact, and it was mine**
Answered against a [pre-registration](../re/h3-units-per-frame-preregistration.md)
committed **before** the capture was read.
[`../re/h3-units-per-frame-measured.md`](../re/h3-units-per-frame-measured.md) ·
series at [`../re/data/title-plate-ramp.txt`](../re/data/title-plate-ramp.txt).
Measured on **`ptbtn00`'s own declared ramp** — the plate you are asking about,
on the title, not on a splash. Predicted 11 frames at 2 units/frame and 4.4 at
your inferred 5, ±1.
```
label 5372 5373 5374 5375 [5376] 5377 5378 [5379] 5380
alpha 46 69 92 115 — 197 220 — 255
step +23 +23 +23 (+82) +23 (+35, clamped)
```
**Three consecutive gap-free steps of exactly 23**, and `255 × 2 / 22 = 23.18`.
Ramp span 10 labels against a predicted 11. **2 units/frame. 5 is excluded by
more than a factor of two.**
### Why your 5 came out, and the fault is in what I published
**An alpha step is not a clock rate.** For a linear segment
`Δα/frame = 255 × (units/frame) / T`, so two elements with different declared
segment lengths `T` show different steps at an *identical* clock:
| element | Δα/frame measured | implied `T` at 2 units/frame | declared `T` |
|---|---|---|---|
| splash B's six quads | **34** | 15.0 | **15** |
| `ptbtn00` | **23** | 22.2 | **22** (`t=214→236`) |
One clock, two steps 1.5× apart. Reading either as a rate is where the 2.7 came
from. And the anchor compounds it: your intervals start at each quad's **first
submission**, and on splash A `Q7` and `Q0` are both already at **α = 85** when
first submitted — that is not their `t` at α=0, and the bias differs per element
because `T` differs.
⚠️ `splash-quad-timeline.txt` published alpha against frame with **no `T`
column**, which is the one thing that makes the conversion possible. That is my
defect, not your arithmetic. The file now carries the warning at its head.
### Your other H3 question: the anchor is **t ≈ 160**, not t = 118
The draw stream names the elements, so this is direct. **`ptcopyright` is the last
element to finish building in and the only glyph element on the screen** — a
glyph counter settling *is* that element reaching full alpha:
```
label 5341 5342 5343 5344 5345 [5346] 5347 5348 5349 5350 → 255 thereafter
alpha 23 57 81 104 139 — 208 231 243 255
```
Calibrating on the plate's own ramp: `ptcopyright` full at label 5350 →
**t ≈ 168** (t ≈ 176 at a flat 2.0/label). Your candidates are 42 units apart:
that is **816 units from t=160** and **5058 from t=118**.
**It is t=160.** I note that you say this is the reading under which
`clock: "shared"` collapses. That is a consequence, not a counter-argument, and
it is yours to take.
📌 Free with it: **the sweep leaves never settle.** They translate monotonically
through every label examined and are still moving when the plate arrives. "The
title has settled" can only mean *the build-in elements have finished*.
### 🔴 What this does NOT give you, and it is the number you need
**Units per *second* is still open**, and it is now the only place the
disagreement lives.
`units/s = (units/frame) × (guest frames/s)`. This pins the first at **2**. The
second is untouched: your **60 units/s** is `2 × 30 fps`; `2 × 60 fps` is
**120 units/s**, which puts the plate at **1.97 s instead of 3.93 s** — and *"about
two seconds early"* is the size of what the human reported. This capture ran
**6 565 labels in ~241 s ≈ 27.2 labels/s**, which is Canary's presentation rate
and cannot tell a 30 Hz guest at full speed from a 60 Hz guest at half.
⚠️ **And two of my own captures disagree here by ~2.9×.** Settled → plate onset is
**20 labels ≈ 0.73 s** in this capture and **2.13 s** in
`title-plate-delay-measured.md`, on the same two anchors. That number is a
wall-clock duration off a screenshot stream; this one is a count. I am not
reconciling them by argument. **Do not change your 60 on my account yet** — the
experiment that settles it is reading the guest's own frame counter, which
neither capture did, and it is my next item.
---
## ✅ 2026-09-01 — the input set, half of it, decoded from the image
[`../re/input-pad-read-path.md`](../re/input-pad-read-path.md) ·
[`../re/data/input-pad-fields.txt`](../re/data/input-pad-fields.txt)
**The game's pad poll reads every field of `XINPUT_GAMEPAD`.** `sub_82457038` is
the only function that reads controller *data* (the other `XamInputGetState`
caller only compares the result to `ERROR_DEVICE_NOT_CONNECTED`), and it compares
all seven fields of the new state against the previous one:
`dwPacketNumber` · **`wButtons` (the full 16-bit word)** · **`bLeftTrigger`** ·
**`bRightTrigger`** · **`sThumbLX`** · **`sThumbLY`** · **`sThumbRX`** ·
**`sThumbRY`**.
Verified against `/image/sylpheed.pe`, not the database: all **14/14** loads
re-encoded from their operands match the image byte-for-byte.
**And there are two input paths, not one.** The same function drains
`XamInputGetKeystrokeEx` (flags = 3) into an 8-byte-record ring — the size of
`XINPUT_KEYSTROKE`. A menu that responds to a *press* is likely reading the
queue; anything responding to a *hold* must read the polled state. Which one each
action uses is **not decoded**.
⚠️ **This is the superset the game can see, NOT the per-screen set.** Reading a
field is not acting on it. Do not turn this into a binding table — use it to
check one: any binding you have that is *outside* this set is certainly wrong,
and one inside it is merely not excluded.
**Still open: which bits each screen tests.** Two named footholds, from the
image's own Shift-JIS trace strings: **`C_PAD_DECODER`** and **`C_PAD_RINGBUF`**,
constructed in `sub_8220B610` and released in `sub_821A6470`. A *decoder* between
`wButtons` and the menus is where repeat timing, edge detection and remapping
live. That is the next read.
---
## ✅✅ 2026-09-01 — **THE SPLASHES HAVE NO POST-PROCESS.** One pass, source-over, alpha in the vertex stream
Play-test finding 4 — *"the splash fade/blur is more pronounced in the game"*

View File

@@ -137,6 +137,9 @@ files, which is how the same ground got covered twice.
| [`structures/stage-mission-tables.md`](structures/stage-mission-tables.md) | The stage table set — phases, routes, sub-objectives and AI parameters | ✅ the table set and how the stage record reaches it, validated across; **`AIParams` disc-wide: 23 objects, one shared 34-profile roster (782 records), loader `sub_8233C368`; `Type`→field-count holds except the two `_Test` templates** |
| [`structures/texture-color-k8888.md`](structures/texture-color-k8888.md) | Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures) | — |
| [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) | What a keyframe time is worth, and what shape the ramp has | ✅ CONFIRMED from the running game's own draw stream — the ramp is **linear** (a declared 15-unit fade lands on `round(255·k/15)` for all seven samples) and the animation clock advances **2 time units per submitted frame**. 🟡 the seconds conversion (`1 unit = 1/60 s`) rests on a measured 27.6 present-frames/second |
| [`h3-units-per-frame-measured.md`](h3-units-per-frame-measured.md) | How many keyframe units elapse per guest frame, and which declared time the title's settle anchor is | ✅ **measured against a pre-registration, 2026-09-01. 2 units per guest frame**, on `ptbtn00`'s own declared ramp (`t=214→236`, T=22): three consecutive gap-free steps of **exactly 23** = 255×2/22, ramp span 10 labels against a predicted 11 (±1). The Port's inferred **5 is excluded by >2×**. 🔴 Why 5 appeared: **an alpha step is not a clock rate**`Δα/frame = 255·(units/frame)/T`, so splash B's 34-with-T=15 and the plate's 23-with-T=22 are ONE clock. Also: the **title settle anchor is t≈160**, not t=118 — it is `ptcopyright`, the last build-in element and the only glyph one, full at label 5350 → t≈168176. 🔴 **units/SECOND is NOT settled** and two of my own captures disagree ~2.9× on it; the gap is in frames→seconds, not units→frames. |
| [`h3-units-per-frame-preregistration.md`](h3-units-per-frame-preregistration.md) | The prediction, committed before the capture was read | ✅ kept as the control on the row above |
| [`input-pad-read-path.md`](input-pad-read-path.md) | What the game asks the console for, on the pad | ✅ **decoded from the image, 2026-09-01.** `sub_82457038` is the only function that reads controller *data*, and it compares **every** field of `XINPUT_GAMEPAD``wButtons` (full 16 bits), both triggers, all four stick axes — against the previous state. **14/14** loads verified byte-for-byte against `/image/sylpheed.pe`, database used as an index only. **Two input paths**: the polled state and an 8-byte-record `XamInputGetKeystrokeEx` ring. ⚠️ This is the **superset the game can see, not the per-screen set** — reading a field is not acting on it. ❔ Which bits each screen tests is open; footholds are the image's own `C_PAD_DECODER` / `C_PAD_RINGBUF` trace strings (`sub_8220B610` / `sub_821A6470`). |
| [`ui-splash-draw-pass.md`](ui-splash-draw-pass.md) | The splash draw pass — is there a post-process, and where does the fade come from | ✅ **decoded from GPU state, 2026-09-01.** **No post-process pass exists**: over all 1 048 draws of both splashes, one render target (`rt0=[tile=0…]` 1 048/1 048), one pitch, no MSAA, only kColorDepth/kCopy modes, resolve destinations only the two front buffers, and **no texture base anywhere equals a resolve destination**. The only texture bound is the sprite page. Three trivial pixel shaders; the sprite shader **premultiplies**, so `ONE/ONE_MINUS_SRC_ALPHA` is algebraically **source-over**. Parameters come from **per-vertex `k_8_8_8_8` colour** in a per-frame vertex buffer — the shaders read **zero** float constants (`ps_c[n=0]` 1 048/1 048). Ramp reproduces the ✅ 34/frame law. ⚠️ Censusing the whole 600-frame log instead finds the attract movie's 640×360 chroma planes and reads as a half-res blur chain — the frame window is what avoids that. |
| [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md) | A keyframe's time word comes **before** its pose — the placement record, decoded | ✅ CONFIRMED, **decoded**. A group is an 8-byte header then `frames` records of `{u32 time; 36-byte pose}`, so the time precedes the pose; the group's lead-in word at `header+8` is pose 0's time and **every** pose is timed. Disc-wide over 13 991 groups in 33 archives, each test with a control: lead-in prepended is non-decreasing **13 991/13 991**; a non-zero lead-in is strictly below the next time **5 058/5 058** (control 70.9 %); a multi-segment alpha ramp runs at a constant `dα/dt` **857/1 540** against **0/1 042** under the old reading. 🔴 Retires two long-standing corpus claims — *"a group's data stops 4 bytes short of its final block's time slot"* and *"the last keyframe carries no time"* — both of which were this off-by-one. Adoption is free: all 12 `GP_TITLE` builds render byte-identically, and over 217 builds only two elements pick a different `rest()` pose, both between equally invisible ones. ❔ the executable's own parser was **not** found (the 40/60 stride query is weak, not negative) |
| [`structures/ui-composable-bundles.md`](structures/ui-composable-bundles.md) | A screen build is not the only thing `compose` can draw | ✅ CONFIRMED by measurement over the disc, with the artifact to |

View File

@@ -0,0 +1,57 @@
# The pad poll sub_82457038: every XINPUT_GAMEPAD field it reads, and the
# image bytes that prove it. VA -> file offset is VA - 0x82000000.
# Database used as an index only; the image is the authority.
## disassembly of the field-compare block, image bytes beside each line
0x82457220 817E0000 lwz r11, 0(r30)
0x82457224 815F0034 lwz r10, 52(r31)
0x82457228 7F0B5040 cmpl cr6, 0, r11, r10
0x8245722C 419A0074 bc 12, 4*cr6+eq, 0x824572A0
0x82457230 A17F0038 lhz r11, 56(r31)
0x82457234 A15F0028 lhz r10, 40(r31)
0x82457238 7F0A5840 cmpl cr6, 0, r10, r11
0x8245723C 409AFFCC bc 4, 4*cr6+eq, 0x82457208
0x82457240 897F003A lbz r11, 58(r31)
0x82457244 895F002A lbz r10, 42(r31)
0x82457248 7F0A5840 cmpl cr6, 0, r10, r11
0x8245724C 409AFFBC bc 4, 4*cr6+eq, 0x82457208
0x82457250 897F003B lbz r11, 59(r31)
0x82457254 895F002B lbz r10, 43(r31)
0x82457258 7F0A5840 cmpl cr6, 0, r10, r11
0x8245725C 409AFFAC bc 4, 4*cr6+eq, 0x82457208
0x82457260 A17F003C lhz r11, 60(r31)
0x82457264 A15F002C lhz r10, 44(r31)
0x82457268 7F0A5840 cmpl cr6, 0, r10, r11
0x8245726C 409AFF9C bc 4, 4*cr6+eq, 0x82457208
0x82457270 A17F003E lhz r11, 62(r31)
0x82457274 A15F002E lhz r10, 46(r31)
0x82457278 7F0A5840 cmpl cr6, 0, r10, r11
0x8245727C 409AFF8C bc 4, 4*cr6+eq, 0x82457208
0x82457280 A17F0040 lhz r11, 64(r31)
0x82457284 A15F0030 lhz r10, 48(r31)
0x82457288 7F0A5840 cmpl cr6, 0, r10, r11
0x8245728C 409AFF7C bc 4, 4*cr6+eq, 0x82457208
0x82457290 A17F0042 lhz r11, 66(r31)
0x82457294 A15F0032 lhz r10, 50(r31)
0x82457298 7F0A5840 cmpl cr6, 0, r10, r11
0x8245729C 409AFF6C bc 4, 4*cr6+eq, 0x82457208
0x824572A0 38600000 addi r3, r0, 0
0x824572A4 38210090 addi r1, r1, 144
## independent re-encoding check (opcode|rD|rA|d rebuilt from the operands)
0x82457230 image=0xA17F0038 expect=0xA17F0038 OK lhz r11,56(r31) prev wButtons
0x82457234 image=0xA15F0028 expect=0xA15F0028 OK lhz r10,40(r31) new wButtons
0x82457240 image=0x897F003A expect=0x897F003A OK lbz r11,58(r31) prev bLeftTrigger
0x82457244 image=0x895F002A expect=0x895F002A OK lbz r10,42(r31) new bLeftTrigger
0x82457250 image=0x897F003B expect=0x897F003B OK lbz r11,59(r31) prev bRightTrigger
0x82457254 image=0x895F002B expect=0x895F002B OK lbz r10,43(r31) new bRightTrigger
0x82457260 image=0xA17F003C expect=0xA17F003C OK lhz r11,60(r31) prev sThumbLX
0x82457264 image=0xA15F002C expect=0xA15F002C OK lhz r10,44(r31) new sThumbLX
0x82457270 image=0xA17F003E expect=0xA17F003E OK lhz r11,62(r31) prev sThumbLY
0x82457274 image=0xA15F002E expect=0xA15F002E OK lhz r10,46(r31) new sThumbLY
0x82457280 image=0xA17F0040 expect=0xA17F0040 OK lhz r11,64(r31) prev sThumbRX
0x82457284 image=0xA15F0030 expect=0xA15F0030 OK lhz r10,48(r31) new sThumbRX
0x82457290 image=0xA17F0042 expect=0xA17F0042 OK lhz r11,66(r31) prev sThumbRY
0x82457294 image=0xA15F0032 expect=0xA15F0032 OK lhz r10,50(r31) new sThumbRY
14/14 agree. The state buffer is at r31+36; the previous copy at r31+52.

View File

@@ -0,0 +1,118 @@
# The pad read path — what the game asks the console for
**Status: ✅ decoded from the image** for the *driver* half; ❔ **not yet decoded**
for which button bits the menus test. Instrument: ⟨image⟩ — the executable's own
bytes, with the database used only as an index and every load verified against
the file. 2026-09-01.
Asked by the 2026-09-01 play-test: the port shipped a milestone with **no joypad
binding for Ⓐ or Ⓑ** and nothing caught it. The other half of that is knowing
what the game itself reads, so a binding table can be checked against a fact
rather than against whoever pressed which button.
---
## The three entry points, and there are only three
Every import the game has for controller input, and every caller, from `xrefs`:
| import | thunk | called from |
|---|---|---|
| `XamInputGetCapabilities` | `0x824AA840` | `sub_82456F58` |
| `XamInputGetState` | `0x824AA848` | `sub_82173DC8`, `sub_82456F58`, `sub_82457038` |
| `XamInputGetKeystrokeEx` | `0x824AA870` | `sub_82457038` (×3) |
| `XamInputSetState` | — | no caller found (rumble is imported and unused, or reached indirectly) |
`sub_82173DC8` is **not** a button reader: it calls `XamInputGetState` only to
compare the result against **1167** (`ERROR_DEVICE_NOT_CONNECTED`) and raise a
flag. It is the controller-disconnected watcher.
**`sub_82457038` is the pad poll.** It is the only function that reads controller
*data*.
## ✅ What the game reads: the whole of `XINPUT_GAMEPAD`
`sub_82457038` calls `XamInputGetState` with the output buffer at `r31+36`, which
lays `XINPUT_STATE` over the pad object. It then compares **every field** of the
new state against a 16-byte copy of the previous one at `r31+52`, and reports "no
change" only if all seven match:
| offset (new / prev) | load | field |
|---|---|---|
| `+36` / `+52` | `lwz` | `dwPacketNumber` |
| `+40` / `+56` | `lhz` | **`wButtons`** — the full 16-bit word |
| `+42` / `+58` | `lbz` | **`bLeftTrigger`** |
| `+43` / `+59` | `lbz` | **`bRightTrigger`** |
| `+44` / `+60` | `lhz` | **`sThumbLX`** |
| `+46` / `+62` | `lhz` | **`sThumbLY`** |
| `+48` / `+64` | `lhz` | **`sThumbRX`** |
| `+50` / `+66` | `lhz` | **`sThumbRY`** |
**Verified against the image, not the database.** All fourteen loads
re-encoded from their operands and compared byte-for-byte with
`/image/sylpheed.pe` at `VA 0x82000000`:
```
0x82457230 image=0xA17F0038 expect=0xA17F0038 OK lhz r11,56(r31)
0x82457234 image=0xA15F0028 expect=0xA15F0028 OK lhz r10,40(r31)
14/14 instructions in the image agree with the database
```
Full listing: [`data/input-pad-fields.txt`](data/input-pad-fields.txt).
**So the answer to "does the game read the triggers / the right stick / both
axes" is yes, all of them, and it is decoded rather than observed.** There is no
field of `XINPUT_GAMEPAD` the poll ignores.
⚠️ **What this does not say.** Reading a field is not using it. The poll's job is
to detect *any* change; a screen may test only two bits of `wButtons`. This
establishes the **superset** the game can see, which is exactly what a binding
table needs to be checked against, and **not** the per-screen set.
## The second path: a keystroke queue
The same function calls `XamInputGetKeystrokeEx` three times with flags `= 3`,
draining into a ring at `r31+68` (`{ptr, count, capacity}`) in **8-byte** records
— the size of `XINPUT_KEYSTROKE`. So the game runs **two** input paths at once:
* the polled `XINPUT_GAMEPAD` state above, and
* an **event queue** of keystrokes.
📌 This matters for the port and is already half-recorded elsewhere:
`run-canary`'s own header notes that *"360 menus poll `XamInputGetKeystrokeEx`,
not `GetState`, so a stubbed `GetKeystroke` looks like a completely dead pad"*,
and the capture corpus counts hundreds of `XamInputGetKeystrokeEx` calls on the
title. A menu that responds to a **press** is likely reading the queue; anything
that responds to a **hold** must be reading the polled state. Which of the two
each menu action uses is not decoded.
## ❔ Not decoded — which bits each screen tests
The consumer side is open. Two named footholds exist for it, from the image's own
strings:
```
0x820A5550 'C_PAD_DECODER 初期化' referenced from sub_8220B610
0x820A5568 'C_PAD_DECODER 開放' referenced from sub_821A6470
0x820A55BC 'C_PAD_RINGBUF 初期化' referenced from sub_8220B610
0x820A55D4 'C_PAD_RINGBUF 開放' referenced from sub_821A6470
```
`C_PAD_DECODER` and `C_PAD_RINGBUF` are the game's **own names** for this
subsystem — construction and release traces, Shift-JIS, in one constructor
(`sub_8220B610`) and one destructor (`sub_821A6470`). A *decoder* between the raw
`wButtons` and the menus is where a game normally puts its repeat timing, its
edge detection and its button remap, and it is the next place to read.
**Next experiment:** disassemble `sub_8220B610` for the pad object's layout, then
find where `wButtons` at `+40` is consumed and what masks are tested against it.
Report per-screen only if the code is per-screen; otherwise report the game-wide
set and say so.
## Reach
⟨image⟩, so it is a fact about the shipped executable and holds for every screen
— that is the point of doing it statically. It says nothing about which of these
inputs any particular screen acts on, and nothing has been measured in a capture
yet, so no row here may be labelled *measured*.