diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index ad1b7bb7..e2941818 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -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 **8–16 units from t=160** and **50–58 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"* — diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 59c6e97a..2f8e40a8 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -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≈168–176. 🔴 **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 | diff --git a/docs/re/data/input-pad-fields.txt b/docs/re/data/input-pad-fields.txt new file mode 100644 index 00000000..c2f257e3 --- /dev/null +++ b/docs/re/data/input-pad-fields.txt @@ -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. diff --git a/docs/re/input-pad-read-path.md b/docs/re/input-pad-read-path.md new file mode 100644 index 00000000..7e73dd88 --- /dev/null +++ b/docs/re/input-pad-read-path.md @@ -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*.