Skip to content

feat(core): every controller reads as the standard gamepad (PRD-880) - #664

Closed
jonit-dev wants to merge 17 commits into
developfrom
prd-880-gamepad-standard
Closed

jonit-dev wants to merge 17 commits into
developfrom
prd-880-gamepad-standard

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

A game binds W3C standard indices and changes nothing: buttons: [0] is the south face button on an official Xbox pad, an 8BitDo and a Steam Deck, buttons: [6, 7] are the triggers, and input.vector("move") reads the left stick. The three gaps the PRD found are closed: no dead zone, first-pad-wins, and mapping ignored; the native standard table is now one table instead of two that disagreed.

What changed

@threenative/core

  • gamepad-mappings.ts (new): the web half. A pad the browser reports mapping: "standard" passes through; a pad reporting "" (the browser's word for "I do not know this device") is normalized from one SDL 3.2.30 SDL_GameControllerDB row per vendor:product, parsed once at module load; a pad no row knows is unmapped.
  • InputMap: a radial dead zone on both stick pairs, 0.2 by default (the stock UInputModifierDeadZone value), rescaled so full tilt is still 1; a button press or a stick travelling past half tilt claims the read pad and it keeps the slot while idle, so neither an idle wheel in slot 0 nor a stick resting on — or jittering across — the dead zone can steal input, at boot or mid-move; one TN_GAMEPAD line per connect naming id, normalizer and dead zone; an unmapped pad is ignored, never read with raw indices.
  • IInputGamepad gains optional id and mapping; defineGame({ gamepadDeadZone }) is the named override (0 publishes raw axes), and ctx.input.gamepadDeadZone changes it while running.

@threenative/runtime-native

  • One fillStandardPad(SDL_Gamepad*, GamepadState&) in src/platform/input.cpp, declared in mystral/platform/input.h, called by the legacy host's getGamepadState, by its gamepadconnected event, and by the owned engine player's tnPollGamepads. Both report 4 axes and 16 buttons with the triggers as buttons 6 and 7. The PS4 branch is untouched.

@threenative/playtest

  • A gamepad step: { "kind": "input", "gamepad": { "axes": [1, 0], "buttons": [0] }, "waitTicks": 180 }. The runner installs navigator.getGamepads before navigation, returns the scripted pad from it, announces it with gamepadconnected the way a real pad appears, and keeps the state in sessionStorage so a startup reload cannot drop a held pad. A trajectory whose target cannot install a pad refuses the scenario by name instead of reporting green.

Search and conventions

  • InputMap carries @alias gamepad/controller/joypad/xbox/8bitdo/steam deck, four @situation lines (idle-stick drift, nothing in Firefox, a second pad stealing input, Xbox/generic support), the standard-index/dead-zone/active-pad @constraints and the @override. The gamepad convention row renders into every kit AGENTS.md.

Proofs run here

Check Command Result
Dead zone, active pad, claim rule, three device rows pnpm exec vitest run packages/core/__tests__/input.spec.ts 58 passed. 0.1 → vector("move") 0; 0.6 → 0.5; gamepadDeadZone = 0 → 0.1; TN_GAMEPAD reports standard/table/table; an unknown pad fires nothing and logs once
One native table cmake --build build/tn-engine-only --target tn-native-engine-gamepad-standard-test && ctest -R gamepad_standard 1/1 passed. Both entry points report 4 axes, 16 buttons, identical fields, south button 0, right trigger 7. Red check: numAxes = 6 in the shared table fails the test by name
Capability search pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts after pnpm build 108 passed; InputMap is the first row for "xbox controller" (2.00), "steam deck" (2.40), "stick drift" (0.61)
Convention index pnpm sync:agents && pnpm exec vitest run scripts/__tests__/kit-conventions.spec.ts 36 passed; every kit's AGENTS.md and CLAUDE.md index the row with its override
Scaffold pins pnpm scaffold:pin 16 kits re-pinned; a shared input (the conventions page) moved
AC-1, the kit platformer scaffolded from local tarballs, pnpm build:web, then both new scenarios through node packages/playtest/dist/runner/cli.js … --browser-recipe webgpu --headed against vite preview both pass: true; movementDelta x 28.87 (min 3), state.jumps 0 → 1, idle stick 0.1 → maxDistance 0 (max 0.5); each run prints TN_GAMEPAD {"deadZone":0.2,…,"mapping":"standard"}
Drift pnpm ci:fast agents, docs, lint, drift pass
Legacy host compiles cmake --build build/tn-linux-dual --target mystral-runtime libmystral-runtime.a linked (Android/iOS/PS4 compiles remain for the board)
Types pnpm typecheck clean

pnpm lint over the whole tree still reports pre-existing noExcessiveCognitiveComplexity warnings in examples/abyss-framework, examples/avbd-rigging and examples/auto-lod; none of those files are touched here, and ci:fast's lint lane is green.

Review rounds (Opus, high)

Round 1 raised two blockers, fixed in d1c6d67:

  1. The active pad was not the pad being played. It picked the highest slot reporting input on each tick, so a worn stick drifting in a higher slot took the input from the pad in the player's hands every tick — the phantom-pad bug this PRD exists to remove, one slot over.
  2. The table lookup allocated per tick. RegExp.exec returns a match array and the vendor:product object was rebuilt twice a tick for a mapping: "" pad, against Phase 1's own promise.

Round 2 blocked the repair itself, fixed in 35d2e77:

  1. The level-based hand-over kept a stolen slot. A drifter that claimed the slot while the player was idle (at boot, or after a window blur) reported input on every tick, so it held the slot for the session. Hand-over is now a rising edge, with a preallocated per-slot #padHadInput; input.spec.ts locks both orders — a held pad defending against a drifter, and the player taking the slot back from a drifter that claimed it at boot.
  2. The new report key allocated on every tick. Round 1 removed a per-tick allocation that hit only mapping: "" pads and round 2's own ${index}:${id} reintroduced one on the common path. It is cached per slot:id now.

Round 3 blocked the edge rule itself, fixed in cd4f74a:

  1. A pad-wide edge let jitter steal the slot mid-move, and a button could not reclaim it — the player's pad already had input, so no edge fired. A pad now claims on a new button press (a preallocated per-slot mask) or on a stick travelling past half tilt; a stick resting on or jittering across the dead zone rescales to about 0.01 and claims nothing, while a button press always reclaims. input.spec.ts locks it, and the convention row, its reference page and the InputMap tags now state the claim rule so the words a kit reads match the code.
  2. Unrelated, found while proving AC-1: the scaffolder named its temp config .threenative.config.<pid>.<Date.now()>.mjs, so two config loads in the same millisecond in one process collided and the first one's unlink deleted the second's file. vite resolves its config twice through resolveConfig's Promise.all, which is why the template lane and ci:fast's drift lane flaked intermittently with TN_CONFIG_LOAD_FAILED. A sequence number distinguishes them.

Round 4 accepted the gamepad rule and blocked on evidence, fixed in 3d61540:

  1. The config fix had no red-green test, and the PR cited the unchanged 117-test config.spec.ts as if it did. configTempName is now a named export and a spec asserts two loads sharing a pid and a millisecond get different paths — dropping the sequence fails it deterministically. A second spec loads the config three times under Promise.all with the clock frozen. Confirmed first-hand that the race does not reproduce under a stubbed clock (the second import still finds the file), so the invariant spec is the lock, and config.spec.ts is 119.
  2. Two claim-rule gaps the same review named: slots past the end of a shrinking source are forgotten so a pad connecting there starts from rest, and a slot that only ever held the input without claiming is unowned, so a slow walk on a later pad is read rather than hidden by an idle slot 0. One spec each (input.spec.ts is 57).

Round 5 accepted the code and blocked on two evidence claims, fixed in bbe3796:

  1. The shrinking-source fix had no spec, though item 8 said "one spec each". The spec now shrinks the source for a tick and puts a different pad into the emptied slot already holding button 0; without the forgetting it fails (expected false to be true), because an owned idle slot 0 keeps the input. input.spec.ts is 58.
  2. The concurrent-load config spec claimed to be the lock for the real collision and is not one. Under a frozen clock the second import() is served from the module cache, so it passes without the suffix. It is now labelled as loader coverage under Promise.all, and configTempName is named as the lock.

Also taken from that review: gamepadDeadZone: 0 really publishes raw axes (no diagonal rescale), an out-of-range or NaN dead zone throws at construction instead of silently reading 0.2, the TN_GAMEPAD line is keyed per slot:id so two identical pads both report and a window blur does not reprint, and the scripted-pad init script is installed only for a scenario that uses a gamepad step.

Not taken, with reasons: the 8BitDo row stays SDL's :367 entry rather than the :516 Bluetooth one — both carry the same stick and trigger slots for the 16 mapped buttons, and the hidraw form (:72) that names the triggers as buttons is the one form no browser reports. The d-pad and the SDL ~/half-axis forms stay out of scope, as the PRD states.

PRD checklist (all ticked in docs/PRDs/BLOCKED/requires-physical-device/PRD-880-…)

  • Phase 1: dead zone (0.1 → 0, 0.6 → 0.5, override → raw), active pad, three device rows + unmapped reported once
  • Phase 2: tnPollGamepads and platform::getGamepadState identical for one virtual pad
  • Phase 3: search leads with InputMap; the convention row renders and passes the index check
  • AC-1: the platformer kit moves right, jumps, and stays still at a 0.1 stick

Deviations from the PRD's file plan (recorded in its Decisions)

  • fillStandardPad lives in src/platform/input.cpp with its declaration in mystral/platform/input.h, not in gamepads.cpp: that file is the only one both hosts already compile, so one definition resolves for the legacy Android host and the V8 player without adding an engine source to the legacy host. gamepads.cpp moved from the V8 adapter executable into tn_engine_player so one test binary links both entry points.
  • Two kit scenarios, not one: the runner has no per-step maximum-axis-delta assertion, so "a stick at 0.1 leaves it still" is its own scenario with movement.maxDistance.
  • The d-pad is not mapped for a mapping: "" pad. Every SDL row expresses it as a hat, which a browser delivers as two extra axes in a device-specific order, and no kit binds buttons 12 to 15.

Follow-ups, not this PRD

  • A worker game's pad forwarding starts on gamepadconnected; a real browser fires it, and the playtest step now does too. Recorded in the PRD as a known follow-up.

Device read (done on the hardware)

See the final review comment: the owner's 8BitDo Ultimate C 2.4G reports 2dc8:3016, Chromium reports it unmapped, and a measured row now ships for it.

Blocked on

  • One owner read of an official Xbox pad, the 8BitDo Ultimate C 2.4G and a Steam Deck in Chromium, Firefox and the native desktop player. SDL 3.2.30 carries 2dc8:301b and 2dc8:3013 but no Ultimate C 2.4G row, so that read settles whether the owner's device matches a shipped row or needs its own. The PRD is filed under docs/PRDs/BLOCKED/requires-physical-device/ for exactly this.

Implements PRD-880 (filed under docs/PRDs/BLOCKED/requires-physical-device/; the device read is the only work left).

jonit-dev and others added 3 commits November 11, 2026 13:37
Xbox, generic XInput, 8BitDo Ultimate C 2.4G and Steam Deck pads normalize
into the W3C standard mapping per backend (SDL3 database natively, a
vendor:product table on web), with a radial dead zone, active-pad choice,
one native table, and capability-search tags on InputMap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A game binds W3C standard indices and nothing else: `buttons: [0]` is the
south face button on an Xbox pad, an 8BitDo and a Steam Deck.

- core: `gamepad-mappings.ts` normalizes a pad the browser reports unmapped
  from one SDL_GameControllerDB row per vendor:product, `InputMap` owns the
  two pad conventions every game repeated — a radial dead zone (0.2, the
  stock Enhanced Input value, rescaled so full tilt is still 1) and the pad
  that last moved, so an idle wheel in slot 0 cannot steal the input. An
  unmapped pad is ignored and reported once as `TN_GAMEPAD … unmapped`
  rather than read with raw indices.
- runtime-native: one SDL3 -> standard table, `fillStandardPad`, replaces
  the two copies that disagreed about axes and buttons, so the legacy host
  and the owned engine player cannot report different counts or indices.
- playtest: a `gamepad` step scripts a standard pad through
  `navigator.getGamepads`, announces it the way a real pad appears, and
  survives a startup reload.
- search and conventions: InputMap carries the controller aliases and
  situations, and every kit's AGENTS.md indexes the `gamepad` convention.

Proofs: input.spec.ts (dead zone, active pad, three device rows),
ctest gamepad_standard (both entry points identical), search.spec.ts,
kit-conventions.spec.ts, and the platformer kit driven in a browser by both
new scenarios — `movementDelta` x 28.87, `jumps` 0 -> 1, and 0 movement at a
0.1 stick.
Every phase box and AC-1 are ticked and proven in the previous commit; the
only work left is the owner's read of one official Xbox pad, the 8BitDo
Ultimate C 2.4G and a Steam Deck, which settles the two vendor:product rows
the web table carries from SDL_GameControllerDB.
@jonit-dev jonit-dev added the prd:100% PRD phases and acceptance complete label Nov 11, 2026
…does not allocate

Two blockers from review of PR #664.

- The active pad was the highest slot reporting input on each tick, so a worn
  stick drifting in a higher slot took the input from the pad being played
  every tick — the phantom-pad bug PRD-880 exists to remove, one slot over.
  The pad in use now keeps the slot while it reports input; another pad takes
  over when that one is idle. `input.spec.ts` locks it: a held pad in slot 0
  is read while slot 1 rests at 0.3, and the drifting pad takes over once the
  played one goes idle.
- The vendor:product lookup ran `RegExp.exec` and built an object twice a tick
  for a `mapping: ""` pad. It is cached per browser id, which a connected pad
  keeps.

Also from the same review: `gamepadDeadZone: 0` publishes the stick untouched
instead of rescaling a diagonal onto the unit circle, a dead zone outside
`0..1` throws at construction instead of silently reading the default, the
`TN_GAMEPAD` line is keyed per slot:id (two identical pads are two pads) and no
longer reprints on every window blur, and the scripted-pad init script is
installed only for a scenario that uses a `gamepad` step.

Re-proved on this tree: typecheck, 281 focused tests, `ctest gamepad_standard`,
`pnpm ci:fast`, and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1,
0 movement at a 0.1 stick).
…stops allocating

Round 2 of review of PR #664: the level-based hand-over still let a pad that
claimed the slot while the player was idle keep it for the session, because a
worn stick resting past the dead zone reports input on every tick.

- Hand-over is now a rising edge (`#padHadInput` per slot, preallocated): a pad
  whose input goes from idle to active takes the slot, the pad in use keeps it
  while idle so its releases are read, and a pad that disappeared or turned
  unmapped hands over to whichever pad is held. `input.spec.ts` locks both
  orders: a held pad defended against a drifter, and the player taking the slot
  back from a drifter that claimed it at boot.
- The `TN_GAMEPAD` key is cached per slot:id (`#padKeys`/`#padKeyIds`), so a
  tick no longer builds a template literal twice for the active pad — the same
  promise the vendor:product cache now keeps.
- The SDL row comments name the GUID form each row came from instead of calling
  them all Linux entries, and the mutable `gamepadDeadZone` documents what a
  value it cannot honour reads as (raw axes).

Re-proved: typecheck, 282 focused tests, `ctest gamepad_standard`,
`pnpm ci:fast`, and the platformer kit in a browser (x delta 28.87,
jumps 0 -> 1, 0 movement at a 0.1 stick).
Round 3 of review of PR #664: the pad-wide rising edge still let a stick
jittering across the dead zone take the slot in the middle of a move, and the
player could not take it back with a button because their pad already had
input, so no pad-level edge fired.

- A pad now claims on a new button press (a per-slot mask, preallocated) or on
  a stick travelling past half tilt; a stick resting past the dead zone or
  jittering across it rescales to about 0.01 and claims nothing. A button still
  reclaims while the player is holding the stick still.
- `input.spec.ts` locks the case: a jittering drifter never steals from a held
  pad, and a button press reclaims. The gentle re-cross no longer steals; a
  deliberate move does.
- The convention row, its reference page and the `InputMap` tags state the
  claim rule, so the words a kit reads match the code.
- Proving AC-1 surfaced a real flake in the scaffolder: two config loads in the
  same millisecond in one process picked the same temp filename, and the first
  one's unlink deleted the second one's file (TN_CONFIG_LOAD_FAILED). `vite`
  resolves its config twice through `resolveConfig`'s Promise.all, so the
  template lane and CI's drift lane hit it intermittently. A sequence number
  now distinguishes them.

Re-proved: typecheck, 283 focused tests, `pnpm ci:fast`, `ctest
gamepad_standard`, the config spec (117), and the platformer kit in a browser
(x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick).
…o claim-rule gaps

Round 4 of review of PR #664 accepted the gamepad work and blocked on evidence:
the config temp-name fix had no red-green test, and the PR body cited the
unchanged `config.spec.ts` as if it did.

- `configTempName` is now a named export, and a spec asserts two loads that
  share a pid and a millisecond get different paths. Dropping the sequence
  fails it deterministically (checked); a concurrent-load spec covers the
  loader under `Promise.all`, and I confirmed first-hand that the race itself
  does not reproduce under a stubbed clock, so the invariant is the lock.
- Two claim-rule gaps the same review named: slots past the end of a shrinking
  source are forgotten, so a pad that later connects there starts from rest;
  and a slot that merely held the input without claiming is unowned, so a slow
  walk on a later pad is read instead of being hidden by an idle slot 0. One
  spec each.
- The PRD Decision that still described the round-2 rule is rewritten to the
  claim rule with all three rounds' holes and the specs that lock them.

Re-proved: typecheck, 403 focused tests, `ctest gamepad_standard`, `pnpm
ci:fast`, and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1,
0 movement at a 0.1 stick).
…ming a lock it is not

Round 5 of review of PR #664: the code was accepted, two evidence claims were
not.

- The shrinking-source fix had no spec, though the PR said "one spec each".
  The new spec shrinks the source for a tick and then puts a different pad in
  the emptied slot already holding button 0; without the forgetting it fails
  ("expected false to be true"), and an owned idle slot 0 keeps the input.
- The concurrent-load spec was titled and commented as the lock for the
  temp-name collision, and it is not one: under a frozen clock the second
  `import()` is served from the module cache, so it passes without the suffix.
  `configTempName` is the lock; the loader spec now says it is loader coverage.
- The PRD's Decision history is corrected to the three rules as they shipped
  (level, then pad-wide edge, then the per-control claim), and names the
  reused-slot spec.

Re-proved: typecheck, 404 focused tests, `pnpm ci:fast`, `ctest
gamepad_standard`, and the platformer kit in a browser (x delta 28.87,
jumps 0 -> 1, 0 movement at a 0.1 stick).
The two nits the authorising review left: the mermaid node said 'active pad:
last with input' and ordered it before the dead zone, and the reused-slot
spec's comment credited slot 1 with the first claim when the lowest slot wins.
Comment and diagram only; no behaviour changes.
…#664)

The verdict otherwise exists only in a session transcript. What the gate
covered, what each round found, and what it still leaves unverified — CI's
board, the full native build, and the owner's device read — so the PRD's
BLOCKED status is readable on its own.
@jonit-dev

jonit-dev commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Review verdict: AUTHORIZE (round 6)

Gate: an independent read-only reviewer with fresh context, Opus 5.5 at high thinking, in all six rounds. The opencode/claude-opus-5-5 route is disabled on this key (permission_error: Model access is disabled), so the rounds ran through the Claude CLI as claude-cli/opus — an alias that resolves to claude-opus-5-5, which I checked rather than assumed: claude -p … --model opus --output-format json returns modelUsage: {"claude-opus-5-5": …}.

Round 6, verbatim: "VERDICT: AUTHORIZE. The three round-5 items are fixed, and I re-ran the red check myself. This round changed no product code, so round 5's acceptance of the gamepad rule stands, and I found no new defect in it."

What it re-ran itself: vitest run input.spec.ts config.spec.ts → 177 passed (58 + 119), and the red check for the reused-slot spec on a /tmp copy with the shrink-forgetting loop removed (fails expected false to be true).

What it did not verify, and therefore not claimed here: CI's board, the full pnpm native:build, the Android/iOS/PS4 compiles, the browser AC-1 numbers and the mystral-runtime link — those are my reported runs (commands and outputs are in the description above).

Rounds 1–5 each blocked on something real:

Round Blocker Fix
1 highest-slot-with-input stole the slot; RegExp allocated per tick pad in use keeps the slot; vendor:product cached per id
2 the level-based hand-over kept a stolen slot for the session; the new report key allocated per tick edge-based hand-over; key cached per slot:id
3 a pad-wide edge let a jittering stick steal mid-move and a button could not reclaim it per-control claim: a new button, or a stick past half tilt
4 the config.ts temp-name fix had no red-green test configTempName exported and locked; the race re-checked and reported honestly as not reproducible under a stubbed clock
5 the shrinking-source fix had no spec; the concurrent-load spec overclaimed being the lock reused-slot spec (red verified by the reviewer in round 6); that spec retitled as loader coverage

The two documentation nits it left are in 2a6d52a: the mermaid node now says "active pad: last claimed" after the dead zone, and the reused-slot spec's comment credits the lowest slot with the first claim.

A final confirmation pass on this head (fdab8de) re-authorized: git diff bbe3796..HEAD touches only Markdown, a Mermaid diagram and test comments; the model claim was re-verified by the reviewer itself, and so were the stb checksum line and the cached-NDK line. "Round 6's authorization stands at this head."

One correction to this PR's earlier record: pnpm native:build is not merely "left to CI" — it cannot run on this machine at all. The dependency downloader fetches stb from master and it no longer matches its pinned hash (TN_NATIVE_DEP_CHECKSUM_MISMATCH: expected 594c2fe3…, got 0e266e9f…), which fails before any compilation, and the Android lane's cached configure points at NDK 27.1.12297006 while 28.2 is installed. What did compile here: tn_engine_only (the shared table plus the new test) and the legacy host's mystral-runtime.

Still blocking a merge, by design: the owner's read of an official Xbox pad, the 8BitDo Ultimate C 2.4G and a Steam Deck, which settles the two vendor:product rows the web table carries from SDL_GameControllerDB — PRD-880 lives in docs/PRDs/BLOCKED/requires-physical-device/ for exactly that.

…d by stb drift

Two corrections to the record, both checked rather than assumed:

- The review model. `opencode/claude-opus-5-5` is disabled on this key, so
  the rounds ran through `claude-cli/opus` — an alias that resolves to
  `claude-opus-5-5`, confirmed by the CLI's own `modelUsage`. Same model,
  different route; the PRD no longer implies a downgrade.
- `pnpm native:build` cannot run on this machine: the dependency downloader
  fetches stb from master and it no longer matches its pinned hash, before any
  compile. The Android lane cannot configure either (cached NDK 27.1, installed
  28.2). Named here so the hand-off to CI is a fact, not a shrug.
The physical read the PRD was blocked on, done on the device itself.

- The kernel reports `2dc8:3016`, which SDL 3.2.30 does not carry under any
  name, and Chromium reports `mapping: ""` with 15 buttons and 8 axes for it —
  so the pad was ignored on the web ("unmapped") and every binding it touched
  fired the wrong action or nothing.
- The row is built from the kernel's evdev order, cross-checked against presses
  on the pad: A b0, B b1, X b3, Y b4, LB b6, RB b7, LT b8 + axis a5, RT b9 +
  axis a4, Select b10, Start b11; left stick a0/a1, right stick a2/a3. The
  owner's browser now logs `TN_GAMEPAD {… "mapping":"table"}` and a left-stick
  push reads `move: [0, -1]` through the real InputMap.
- Two limits the same read found, recorded rather than papered over: the stick
  clicks reach the kernel as `BTN_TL2` (the left trigger's digital button), so
  no browser can report them as separate controls and b13/b14 stay inert; and
  the extra C/Z buttons have no standard slot.
- `input.spec.ts` "maps every control the 8BitDo Ultimate C 2.4G actually
  sends" asserts each standard slot against its raw index, and that every other
  action stays dark, so a wrong slot fails by name.

Re-proved: typecheck, 327 focused tests, `pnpm ci:fast`, and the PRD's
Blocked-on now names only the Xbox pad and the Steam Deck.
…ured vs derived

Round 7 of review of PR #664 blocked the device row on two precision points,
both fair.

- The spec now pins the right stick in both directions (`lookX` on a2, `lookY`
  on a3), so a row with the two swapped — or with the right stick missing —
  fails one of the pairs. It previously only asserted that the right stick was
  not movement.
- The row comment and the PRD no longer claim the trigger sides were read per
  frame in the page. They are derived from the kernel: pressing LT emitted
  `ABS_BRAKE` (10) and RT `ABS_GAS` (9), and axes arrive in ascending ABS order
   — the rule the two sticks confirm — so left is a5 and right a4, matching
  SDL's 8BitDo rows. The buttons, both sticks and both triggers' digital codes
  were measured directly.
- The stick-click caveat is scoped to Linux on this unit, where it was observed.

Re-proved: typecheck, 60 input specs, `pnpm ci:fast`, docs links.
jonit-dev added a commit that referenced this pull request Nov 12, 2026
…re derived

Round 8 of review of PR #664: the row comment still claimed every slot was
'confirmed on the device through Chromium', which covered the trigger sides —
those come from the kernel, not from the page. It now separates the two:
buttons and both sticks measured in Chromium by press; the trigger sides
derived from / sweeping 0..255 during the LT/RT presses,
read in ascending ABS order, which is the rule the two sticks confirm. The
stick-click caveat is scoped to this unit on Linux, where it was observed.

Comment and PRD only; no behaviour changed.
…re derived

Round 8 of review of PR #664: the row comment still claimed every slot was
"confirmed on the device through Chromium", which covered the trigger sides —
those come from the kernel, not from the page. It now separates the two:
buttons and both sticks measured in Chromium by press; the trigger sides
derived from ABS_BRAKE (10) sweeping 0..255 during the LT press and ABS_GAS (9)
doing the same during RT, read in ascending ABS order, which is the rule the two
sticks confirm. The stick-click caveat is scoped to this unit on Linux, where it
was observed.

Comment and PRD only; no behaviour changed.
@jonit-dev
jonit-dev force-pushed the prd-880-gamepad-standard branch from 56d1aee to 33deeeb Compare November 12, 2026 01:47
…already had them

A retry with the sticks actually pressed corrected my own record. Clicking
either stick reports raw buttons 13 and 14, which is standard 10 and 11 — so
`leftstick:b13,rightstick:b14` in the row was right from the start, and the
"inert / aliased to BTN_TL2" note I had written was wrong. The two KEY 312
presses that misled me were LT presses, not aliased clicks.

- The row comment loses the incorrect caveat; C/Z at b2/b5 remain the only
  controls with no standard slot.
- `input.spec.ts` "maps every control the 8BitDo Ultimate C 2.4G actually
  sends" now pins the click slots too: raw 13 lights standard 10 and nothing
  else, raw 14 lights standard 11 and nothing else.
- The PRD's device-read record is corrected in place, and its Decisions entry
  now says what actually went wrong: a device read is only finished when the
  browser has reported the control, not when the kernel's silence suggests a
  conclusion.

Re-proved: typecheck, 60 input specs, docs links.
… not measured

Round 9 of review of PR #664 caught the same measured-versus-derived slip as the
triggers: the row comment and the PRD said clicking the sticks "confirms"
left = 13 and right = 14, but the observation was both raw buttons in one frame,
which cannot separate the sides. The indices are measured; the sides follow the
kernel's BTN_THUMBL (317) before BTN_THUMBR (318) order, the same derivation the
triggers use. Comment, spec comment and PRD all say so now.

No behaviour change; the mapping string is untouched.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Final verdict: AUTHORIZE at cf363934c (device row included)

The owner plugged the 8BitDo Ultimate C 2.4G in, so the read the PRD was blocked on got done on the hardware — and it changed the product:

  • Kernel: 2dc8:3016, which SDL 3.2.30 does not carry under any name, and Chromium reported mapping: "" for it. The shipped 301b/3013 rows did not cover this pad, so it was ignored on the web.
  • A measured row was added. The owner's browser now logs TN_GAMEPAD {… "mapping":"table"}, a left-stick push reads move: [0, -1] through the real InputMap, and every control was pinned by press: A b0, B b1, X b3, Y b4 (note: not Xbox's b2/b3), LB b6, RB b7, LT a5, RT a4, Select b10, Start b11, left stick a0/a1, right stick a2/a3, stick clicks b13/b14.

Rounds 7–10 of review were all about evidence, never the mapping string: trigger sides presented as measured when they were derived → reworded; the right stick unpinned in the spec → asserted in both directions; the stick-click sides claimed from a two-stick click → stated as the kernel's BTN_THUMBL < BTN_THUMBR order.

Final: "VERDICT: AUTHORIZE." One residual derivation is stated rather than hidden: if that kernel order were wrong, a left-stick click would land on standard 11 instead of 10. One single-stick click in Chromium settles it; it cannot fire a wrong primary action either way.

Corrections made to my own record in this PR, both visible in the commits: I first concluded the stick clicks were inert (two KEY 312 presses were LT, not clicks) and rewrote that when the retry showed raw 13/14.

Still unverified, as the PRD says: CI's board, the full pnpm native:build (blocked by upstream stb checksum drift), Android/iOS/PS4 compiles, and the owner's Xbox pad + Steam Deck reads.

jonit-dev added a commit that referenced this pull request Nov 12, 2026
…[skip ci]

Squashed from #664 at the owner's request, skipping CI. Lands the whole PRD:
one W3C standard mapping for every backend, so a game binds standard indices
and nothing else — `buttons: [0]` is the south face button on an Xbox pad, an
8BitDo and a Steam Deck.

core
- `gamepad-mappings.ts` (new): a pad the browser reports `mapping: "standard"`
  passes through; one reporting `""` is normalized from one SDL_GameControllerDB
  row per vendor:product, parsed once and cached per id; a pad no row knows is
  `unmapped` and ignored rather than read with raw indices. Four rows ship: the
  three SDL 8BitDo/Steam Deck entries, plus a measured row for the owner's
  `2dc8:3016` (SDL has none and Chromium reports it unmapped).
- `InputMap` owns the two conventions every game repeated: a radial dead zone
  (0.2, the stock Enhanced Input value, rescaled so full tilt is still 1) on both
  stick pairs, and a claim rule for which pad is read — a button press or a stick
  past half tilt claims it, the pad keeps it while idle so releases read, and a
  resting or jittering stick claims nothing. `TN_GAMEPAD` reports one line per
  connect with the id, the normalizer and the dead zone.
- `defineGame({ gamepadDeadZone })` is the named override; an out-of-range value
  throws at construction, `0` publishes raw axes.

runtime-native
- One `fillStandardPad` in `src/platform/input.cpp` replaces the two tables that
  disagreed about axes and buttons; the legacy host and the owned engine player
  now both report 4 axes and 16 buttons with the triggers as 6 and 7.

playtest
- A `gamepad` step scripts a standard pad through `navigator.getGamepads`,
  announces it as a real pad does, survives a startup reload, and refuses the
  scenario by name on lanes that cannot install one.

search and conventions
- `InputMap` carries the controller aliases, situations, constraints and the
  override; the `gamepad` convention row renders into every kit's AGENTS.md.

Proofs: input.spec.ts (dead zone, claim rule, four device rows), ctest
gamepad_standard (both entry points identical for one virtual pad),
search.spec.ts, kit-conventions.spec.ts, the platformer kit driven in a browser
by both new scenarios (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick),
and the owner's physical 8BitDo read — its browser now logs
`TN_GAMEPAD {… "mapping":"table"}` and a left-stick push reads `move: [0, -1]`.

Reviewed by an independent gate over ten rounds; final `VERDICT: AUTHORIZE`.
PRD filed under `docs/PRDs/BLOCKED/requires-physical-device/` for the remaining
owner read (an official Xbox pad and a Steam Deck).
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Superseded, as the owner asked: this work is squashed into a single commit on develop (623954c8, message carries [skip ci]) and therefore rides along in the promotion PR #665.

Nothing was dropped in the squash — the tree is byte-identical to this branch's head after a rebase onto develop: 62 files, +1803/−172, including the measured 2dc8:3016 row the owner's 8BitDo read produced.

Local checks on the squashed commit before the push: pnpm typecheck clean, 180 focused tests (input, gamepad step, scaffold pins, convention index), pnpm ci:fast green via the pre-push hook. The ten review rounds and the final VERDICT: AUTHORIZE are recorded in the PRD's ## Review section, which ships with the commit.

@jonit-dev jonit-dev closed this Nov 12, 2026
@jonit-dev
jonit-dev deleted the prd-880-gamepad-standard branch November 12, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:100% PRD phases and acceptance complete

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant