From dcaa32214633626ffca3e83e3f94477a8a443e96 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Wed, 2 Sep 2026 16:20:50 +0000 Subject: [PATCH] re: the decoder's timers are a double-tap latch on LB/LT, not key repeat Answers H1's first half with a negative, and decodes what the timing constants are for. C_PAD_DECODER's +0xB4/+0xB8 and +0xBC/+0xC0 (10 and 90, twice) are not a key-repeat delay/interval pair. They are two identical channels of a DOUBLE-TAP detector, on cfg +0x74 (LT) and +0x70 (LB), firing output bits 0x20 and 0x40. The mechanism, from the image: both timers tick down once per update; a press arms the short timer to 10; a RELEASE adds 1000 to it as a flag stored inside the counter, which is why the tick watches for exactly 1000 and clears both there; a second press while the flag is set arms the long timer to 90; and while the long timer runs the output bit is asserted on EVERY update, with the short timer re-armed to 1010 each time. So it is a latch, not a repeat: a double-tap opens a 90-update window and the bit is held for its whole duration. That is a dash or barrel-roll shape, which fits the buttons it is wired to. H1: the directions have NO timer. The D-pad and left stick reach the output word through bare mask tests -- the four left-stick literals at 0x8220C458, C474, C490, C4AC, and DPAD DOWN through cfg +0xA4 -- with no counter loaded, decremented or tested on any of those paths. On the evidence of this layer a held direction does not repeat. Reach: one layer. A menu could implement repeat on top of a held bit, and this says nothing about that -- but it does establish the repeat is not in the shared decoder, so it would have to be per-screen. "One step per deflection" stays authored for the port; this narrows rather than settles. Refutation of my own earlier note that a decoder "is where a game normally puts its repeat timing, its edge detection and its button remap": remap survives, edge detection survives, repeat timing is REFUTED. That clause was a prior about how games are written, not a reading of this one. Also commits tools/ppc-dis, the minimal PowerPC disassembler this corpus has been rebuilding in scratch and losing to container restarts three times in one session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t --- .../pad-decoder-double-tap-not-key-repeat.md | 104 ++++++++++++++++ tools/ppc-dis | 115 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 docs/re/pad-decoder-double-tap-not-key-repeat.md create mode 100755 tools/ppc-dis diff --git a/docs/re/pad-decoder-double-tap-not-key-repeat.md b/docs/re/pad-decoder-double-tap-not-key-repeat.md new file mode 100644 index 00000000..14b5f415 --- /dev/null +++ b/docs/re/pad-decoder-double-tap-not-key-repeat.md @@ -0,0 +1,104 @@ +# ✅ The decoder's timers are a **double-tap detector on LB and LT** — and directions have **no repeat** in this layer + +**Status: ✅ decoded from the image.** Instrument: ⟨image⟩ — `/image/sylpheed.pe` +via [`tools/ppc-dis`](../../tools/ppc-dis), database not consulted. 2026-09-02. + +Answers the first half of **H1** — *does a held direction repeat in the menus, and +at what rate?* — with a negative, and decodes what the timers are actually for. + +--- + +## What the timing constants really are + +`C_PAD_DECODER`'s constructor `sub_8220B610` writes five values that look like a +key-repeat pair — `+0xB4 = 10`, `+0xB8 = 90`, `+0xBC = 10`, `+0xC0 = 90`. They are +**two identical channels of a double-tap detector**, and the update +`sub_8220B8C0` consumes them like this: + +| channel | button | short timer | long timer | output bit | +|---|---|---|---|---| +| A | cfg `+0x74` = **LT** | `this+0` ← `+0xB4` = **10** | `this+4` ← `+0xB8` = **90** | `0x20` | +| B | cfg `+0x70` = **LB** | `this+8` ← `+0xBC` = **10** | `this+12` ← `+0xC0` = **90** | `0x40` | + +Buttons named in the decoder's **own** bit numbering +([`input-button-numbering-is-remapped.md`](input-button-numbering-is-remapped.md)), +not XINPUT's. + +### The mechanism, in the order the code runs it + +**1 — both timers tick down once per update** (`0x8220BAE0`): + +``` +if this+0 != 0: this+0 -= 1 ; if it lands on exactly 1000: this+0 = 0, this+4 = 0 +if this+4 != 0: this+4 -= 1 +``` + +**2 — a press arms the short timer** (`0x8220BF74`): on `PRESSED ∧ this+0 == 0`, +`this+0 = 10`. + +**3 — a release adds 1000 to it** (`0x8220BFC8`, reading `20(r6)` = RELEASED): +`this+0 += 1000` if it is still running, else `0`. **The `+1000` is a flag stored +inside the counter** — "this press has already been released" — which is why the +tick in step 1 watches for the value 1000 and clears both timers there. + +**4 — a second press while the flag is set arms the long timer** +(`0x8220BF44`): on `this+0 > 1000 ∧ this+4 == 0 ∧ PRESSED`, `this+4 = 90`. + +**5 — while the long timer runs, the output bit fires EVERY frame** +(`0x8220C0F8`): + +``` +if this+4 > 0: + this+0 = 10 + 1000 ; re-armed and flagged + this+0x24C |= 0x20 ; the output bit, set on every update +``` + +📌 **So it is not a repeat, it is a latch.** A double-tap opens a **90-update +window**, and for its whole duration the output bit is asserted continuously. That +is the shape of a **dash / barrel-roll**, not a menu cursor — which fits the +buttons it is wired to. + +## 🔴 The H1 answer: no key-repeat in this layer + +**The directions have no timer at all.** The D-pad (ring bits 12–15) and the left +stick (ring bits 4–7) reach the output word through **bare mask tests** — the four +left-stick literals at `0x8220C458`, `0x8220C474`, `0x8220C490`, `0x8220C4AC`, and +`DPAD DOWN` through cfg `+0xA4` — with **no counter loaded, decremented or tested +on any of those paths** ([`data/input-decoder-output-map.txt`](data/input-decoder-output-map.txt)). + +> **On the evidence of `C_PAD_DECODER`, a held direction does not repeat.** The +> only two timed inputs in the whole decoder are LB and LT, and what they do is +> latch, not repeat. + +⚠️ **Reach, and it matters here.** This is *one layer*. The decoder hands a word to +the menus; **a menu could implement its own repeat on top of a held bit**, and this +page says nothing about that. What it does establish is that the repeat is **not** +in the shared input decoder, so it would have to be per-screen — which also means +it cannot be answered once for all screens from this function. + +**For the port:** "one step per deflection" remains **authored**, and this narrows +rather than settles it. The next step is the layer above — the consumer of +`this+0x24C` — or a capture of a held direction on a real menu, counting cursor +moves against presents. + +## Refutation attempt, recorded per the adversarial duty + +**Target:** my own earlier note in +[`input-pad-read-path.md`](input-pad-read-path.md) that *"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."* + +**Result: two thirds SURVIVE, one third is REFUTED.** The remap is there +(24 bits, bijective) and the edge detection is there (`+12` held, `+16` pressed, +`+20` released). **The repeat timing is not.** The reasoning was "this is where it +normally goes", which is a prior about how games are written, not a reading of +this one — and it is exactly the kind of inference this corpus keeps having to +withdraw. + +## Reach + +⟨image⟩, so it holds for every screen — that is the point of doing it statically. +It says nothing about layers above the decoder, and **nothing here has been +confirmed against a running capture**: no row may be labelled *measured*. The +obvious check is to hold a direction on a real menu and count cursor moves per +present. diff --git a/tools/ppc-dis b/tools/ppc-dis new file mode 100755 index 00000000..1f085483 --- /dev/null +++ b/tools/ppc-dis @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Minimal PowerPC disassembler for /image/sylpheed.pe. + + tools/ppc-dis disassemble a range + tools/ppc-dis --find-imm [lo hi] every instruction with that immediate + +The image is a FLAT VA DUMP: file offset = VA - 0x82000000. There is no `duckdb` +and no PowerPC `objdump` in this container, so this is the working route to the +bytes -- and the bytes are primary, the database is somebody's analysis of them. + +⚠️ It lives here rather than in /tmp because the scratchpad is wiped by container +restarts, which cost this tool three times in one session. + +Covers the integer/branch/load-store forms this corpus actually reads. Floating +point is printed as `FP ... xo=` rather than decoded: no finding so far has +needed an FP mnemonic, and a wrong one would be worse than an honest placeholder. +""" +import struct, sys +BASE = 0x82000000 +IMG = "/image/sylpheed.pe" + +def _mask(mb, me): + m, i = 0, mb + while True: + m |= 1 << (31 - i) + if i == me: + break + i = (i + 1) & 31 + return m + +XO = {0:'cmpw',32:'cmplw',28:'and',444:'or',316:'xor',60:'andc',266:'add',40:'subf', + 104:'neg',24:'slw',536:'srw',824:'srawi',23:'lwzx',87:'lbzx',279:'lhzx', + 151:'stwx',339:'mfspr',467:'mtspr',235:'mullw',491:'divw',459:'divwu', + 922:'extsh',954:'extsb',407:'sthx',215:'stbx',343:'lhax',55:'lwzux', + 20:'lwarx',150:'stwcx.',412:'orc',476:'nand',124:'nor',8:'subfc',10:'addc', + 138:'adde',26:'cntlzw',792:'sraw'} +DF = {32:'lwz',33:'lwzu',34:'lbz',35:'lbzu',36:'stw',37:'stwu',38:'stb',39:'stbu', + 40:'lhz',41:'lhzu',42:'lha',44:'sth',45:'sthu',14:'addi',15:'addis', + 12:'addic',13:'addic.',7:'mulli',8:'subfic',24:'ori',25:'oris',26:'xori', + 27:'xoris',28:'andi.',29:'andis.',10:'cmpli',11:'cmpi',48:'lfs',50:'lfd', + 52:'stfs',54:'stfd'} +FPOPS = {48, 49, 50, 51, 52, 53, 54, 55} + +def dis(va, x): + op = x >> 26; rD = (x >> 21) & 31; rA = (x >> 16) & 31; rB = (x >> 11) & 31 + imm = x & 0xFFFF; s = imm - 0x10000 if imm & 0x8000 else imm + if op in DF: + m = DF[op]; r = 'f' if op in FPOPS else 'r' + if m in ('addi', 'addis') and rA == 0: + return "li r%d,%d" % (rD, s) + if m in ('ori', 'oris', 'xori', 'xoris', 'andi.', 'andis.'): + return "%s r%d,r%d,0x%X" % (m, rA, rD, imm) + if m in ('cmpli', 'cmpi'): + return "%s cr%d,r%d,%s" % (m, (x >> 23) & 7, rA, + hex(imm) if m == 'cmpli' else s) + if m in ('addi', 'addis', 'addic', 'addic.', 'mulli', 'subfic'): + return "%s r%d,r%d,%d" % (m, rD, rA, s) + return "%s %s%d,%d(r%d)" % (m, r, rD, s, rA) + if op in (20, 21): + S, A, SH, MB, ME = rD, rA, (x >> 11) & 31, (x >> 6) & 31, (x >> 1) & 31 + return "%s r%d,r%d,%d,%d,%d ; mask=0x%08X" % ( + 'rlwimi' if op == 20 else 'rlwinm', A, S, SH, MB, ME, _mask(MB, ME)) + if op == 31: + e = (x >> 1) & 0x3FF; m = XO.get(e) + if m is None: + return ".long 0x%08X ; op31 xo=%d" % (x, e) + if m in ('cmpw', 'cmplw'): + return "%s cr%d,r%d,r%d" % (m, (x >> 23) & 7, rA, rB) + if m == 'srawi': + return "srawi r%d,r%d,%d" % (rA, rD, rB) + if m in ('or', 'and', 'xor', 'andc', 'orc', 'nand', 'nor', 'slw', 'srw', 'sraw'): + return "%s r%d,r%d,r%d" % (m, rA, rD, rB) + if m in ('extsh', 'extsb', 'neg', 'cntlzw'): + return "%s r%d,r%d" % (m, rA, rD) + return "%s r%d,r%d,r%d" % (m, rD, rA, rB) + if op == 18: + li = x & 0x03FFFFFC + if li & 0x02000000: + li -= 0x04000000 + return "b%s 0x%08X" % ('l' if x & 1 else '', (va + li) & 0xFFFFFFFF) + if op == 16: + bo, bi = rD, rA + bd = x & 0xFFFC + if bd & 0x8000: + bd -= 0x10000 + cond = {(12,0):'blt',(12,1):'bgt',(12,2):'beq',(4,0):'bge',(4,1):'ble',(4,2):'bne'} + nm = cond.get((bo, bi & 3)) or cond.get((bo & 0x1E, bi & 3)) or "bc(%d,%d)" % (bo, bi) + return "%s cr%d,0x%08X" % (nm, bi >> 2, (va + bd) & 0xFFFFFFFF) + if op == 19: + return {16: 'blr', 528: 'bctr'}.get((x >> 1) & 0x3FF, ".long 0x%08X" % x) + if op in (59, 63): + return "FP%d rD=%d rA=%d rB=%d rC=%d xo=%d" % (op, rD, rA, rB, (x >> 6) & 31, + (x >> 1) & 0x1F) + return ".long 0x%08X ; op=%d" % (x, op) + +def main(argv): + d = open(IMG, 'rb').read() + if argv[0] == '--find-imm': + want = int(argv[1], 0) + lo = int(argv[2], 16) if len(argv) > 2 else BASE + hi = int(argv[3], 16) if len(argv) > 3 else BASE + len(d) + for a in range(lo, hi, 4): + x = struct.unpack_from('>I', d, a - BASE)[0] + if (x & 0xFFFF) == (want & 0xFFFF) and (x >> 26) in DF: + print("%08X %08X %s" % (a, x, dis(a, x))) + return 0 + a, e = int(argv[0], 16), int(argv[1], 16) + while a < e: + x = struct.unpack_from('>I', d, a - BASE)[0] + print("%08X %08X %s" % (a, x, dis(a, x))) + a += 4 + return 0 + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:]))