# INPUT MAPPING — keyboard, gamepad, remapping, and the latency budget

**Phase 2 deliverable. A design contract.** The simulation's input model is fixed by the hardware and
is specified in `research/CONTROL_SYSTEM_SPEC.md §1–§3`. This document specifies everything on the
*other* side of that boundary: which physical keys and buttons produce which PIA bits, why those
choices and not others, how they are remapped, and how much latency the chain may add.

Nothing here may change the control *grammar*. The grammar is: a **2-way vertical stick**, and five
**discrete** actions — Fire, Thrust, Reverse, Smart Bomb, Hyperspace. Any scheme that adds a
horizontal axis, an analog thrust magnitude, or an auto-aim is a **mechanical deviation** and must
be labeled as one (`ARCHITECTURE.md §7`, mission acceptance condition 5).

---

## 1. The handoff to the core

The input module's entire output is **two bytes per frame**. It produces nothing else, and the core
accepts nothing else.

```js
/**
 * @returns {{pia2: number, pia3: number}}  two uint8 bitfields, 1 = pressed (active high)
 *
 * pia2  bit 0  Fire
 *       bit 1  Thrust
 *       bit 2  Smart Bomb
 *       bit 3  Hyperspace
 *       bit 4  Start 2
 *       bit 5  Start 1
 *       bit 6  Reverse
 *       bit 7  Stick Down
 *
 * pia3  bit 0  Stick Up
 *       bit 7  cabinet type (0 = upright; the reconstruction always reports upright)
 */
function sampleInput()
```

Bit assignments are **MULTI_SOURCE_CONFIRMED** (L-088): `phr6.src:120–146` (Williams' own comment block)
and `mame-williams/williams.cpp:762–786`, which agree bit for bit and confirm active-high.

The core calls `sampleInput()` exactly once per `step()`, at phase C1, and immediately writes the
result into its `pia21`/`pia31` shadows. **The input module must never call into the core and never
buffer.** It is a keyboard-state-to-bitfield function.

> **AMENDED when the laptop scheme landed (§2.5).** The original clause also said "never consult the
> simulation state", and for the arcade scheme that still holds absolutely. The laptop scheme cannot
> obey it: "hold ← to fly left" is *undefined* without knowing which way the ship currently points,
> because the machine's turn is an edge-triggered `REV` and its thrust is a level line pointing
> wherever `PLADIR` already points. The exception is therefore scoped as narrowly as it can be:
>
> - **one field, read-only** — the facing, taken from `NPLAD` (falling back to `PLADIR`), through a
>   host-supplied `readFacing()` callback. The input module holds no reference to the core.
> - **read once per `sample()`**, on the same tick the bytes are produced. Nothing is remembered
>   across a `releaseAll()`.
> - **the arcade scheme never calls it at all**, which is what keeps that scheme byte-identical.
> - the module still writes nothing, anywhere, ever. `IM-28` asserts a frozen state object survives
>   a sample untouched.
>
> The output is still exactly two bytes and the core still knows nothing about any of this.

---

## 2. What the ergonomics have to solve

Four constraints come out of the hardware, not out of taste.

1. **Thrust is held for seconds at a time.** In a wave you spend most of your time on it. It needs a
   key a finger can rest on without fatigue.
2. **Fire is hammered.** The mechanical ceiling is one shot per three frames = **20.03 shots/second**
   (`CONTROL_SYSTEM_SPEC.md §5.2`), and holding the key gives you exactly **one** shot. Real play is
   sustained hammering at whatever rate the player can manage. It needs the fastest, most durable
   finger.
3. **Reverse is a discrete flick, and it must not collide with anything.** Because only the
   **lowest-numbered PIA bit** with a new edge is serviced each frame and the losers are *destroyed,
   not queued* (`CONTROL_SYSTEM_SPEC.md §3.5`), a Reverse pressed on the same frame as Fire or
   Thrust is silently lost. On the real cabinet all five buttons are under one hand and the player
   learns to separate them. On a keyboard we can make that separation physical: **put Reverse under
   a finger that is neither the Fire finger nor the Thrust finger.**
4. **Six inputs must be simultaneously reachable** — Up or Down, Thrust, Fire, Reverse, and, in an
   emergency, Smart Bomb or Hyperspace, without the hand leaving its position.

And one constraint comes from the cabinet, which we should honor because it is what "authentic
feel" actually means here:

5. **The cabinet divides the work between the hands: one hand does nothing but the stick; the other
   hand does all five buttons.** That division is why Defender's controls feel the way they do — the
   stick hand is never interrupted. The keyboard layout preserves it exactly, with the hands
   **swapped**, because a keyboard's arrow cluster is on the right and the mission specifies arrows.

---

## 2.5 Two schemes: `laptop` (default) and `arcade`

**A scheme is not a key layout. It is a statement about the control grammar the layout speaks.**
Everything in §3, §4 and §4.1 below is a layout *within the `arcade` scheme* and is unchanged.

| | `laptop` — **the default** | `arcade` — the faithful one |
|---|---|---|
| Turning | `←` / `→` mean *face and thrust that way*. One gesture. | `Reverse` is its own discrete key. |
| Thrusting | implied by holding a direction | its own discrete key, held |
| Lines produced | **synthesized** per tick by `src/input/flight.js` | one key, one line, directly |
| Reads core state | the facing, read-only, once per tick | never |
| Deviation | **`D-INP-3`, Class M, labeled** | none — this *is* the cabinet |

`arcade` is selected by any of the `default` / `lefty` / `cabinet` presets and is **byte-identical to
what this module produced before the laptop scheme existed** (`IM-29` runs a held Reverse key
through the real core and asserts the one-turn behavior). It is never modified by anything in this
section. Switching back is one click, or `applyScheme(profile, 'arcade')`.

### 2.5.1 The laptop layout

```
              ┌───┐ ┌───┐                              ┌───┐
              │ B │ │ H │                              │ ↑ │
              └───┘ └───┘                         ┌───┐└───┘┌───┐
               BOMB HYPER                         │ ← │ │ ↓ │ │ → │
                                                  └───┘└───┘└───┘
            ▁▁▁▁▁▁▁▁▁▁▁▁▁▁                      thrust  stick  thrust
                SPACE                            LEFT          RIGHT
                FIRE
```

| Action | Key | Note |
|---|---|---|
| **Thrust Left** | `ArrowLeft` | held: face left and keep thrusting left |
| **Thrust Right** | `ArrowRight` | held: face right and keep thrusting right |
| **Stick Up / Down** | `ArrowUp` / `ArrowDown` | unchanged — the same 2-way stick, the same two bits |
| **Fire** | `Space` | the reflexive shoot key, free now that thrust needs no hold key |
| **Smart Bomb** | `KeyB` | mnemonic, and far from the arrow cluster |
| **Hyperspace** | `KeyH` | mnemonic, and far from the arrow cluster |
| Start 1 / 2 | `Enter` / `Digit1`, `Digit2` | unchanged |

`thrust` and `reverse` carry **no binding** in this scheme, and the controls card does not offer
them: they are outputs, not keys. The gamepad is unaffected and keeps the cabinet grammar in both
schemes (§6.1), so a pad-sourced Thrust passes straight through the synthesiser.

### 2.5.2 What the synthesiser has to get right

`src/input/flight.js`, per tick, from the arrow keys and the facing:

| Situation | Emitted |
|---|---|
| arrow **agrees** with facing | `THRUST` high. No edge anywhere. |
| arrow **opposes** facing | `REVERSE` high for **exactly one tick**, with **no thrust on that tick**; then `REVERSE` low and `THRUST` high from the next tick |
| arrow **released** | both low. **The facing is left alone** — a release is not a turn |
| **both** arrows held | *last press wins*; a same-tick tie keeps the current heading |

Four properties of `SSCAN` make this harder than it looks, and each is a way to get it silently
wrong (`CONTROL_SYSTEM_SPEC.md §3`, `src/core/input.js`):

1. **Reverse is edge-triggered through a two-sample detect** (`~(pia21 | pia22) & pia2`). Hold the
   line and reverse fires once and never again; re-assert it every tick and it never fires after the
   first. Hence the one-tick pulse.
2. **Exactly one edge is serviced per frame — the lowest PIA bit — and the losers are destroyed, not
   queued.** Thrust is bit 1, Reverse is bit 6, and thrust's `SWTAB` entry has address 0 but still
   wins the dispatch (`CTL-06`). **Raising Thrust and Reverse on the same tick therefore eats the
   Reverse**, and `pia21` then carries a 1 on bit 6 so no edge is ever produced for that press.
   The pulse tick raises nothing on a lower bit.
3. Fire (0), Smart Bomb (2), Hyperspace (3) and the Start lines (4, 5) are lower than Reverse too,
   and they are the *player's* presses — not ours to suppress. The pulse is **deferred one tick**
   when one of them is about to edge, and **retried** after 8 ticks if it is lost anyway.
4. `REV` holds `REVFLG` for 2 + 5 frames after the line reads low, swallowing a second reverse
   inside that window. The retry window is longer than the lock-out for exactly that reason.

**One deliberate imperfection.** For the one or two ticks between the pulse and `NPLAD` flipping,
Thrust is asserted while the ship still points the old way — 16 to 33 ms of backwards nudge. It is
taken knowingly, in exchange for not being dead in the water on every lost pulse. Suppressing thrust
until the facing agrees is a one-line change in `flight.js` if a critic prefers it.

---

## 3. The default layout — right-handed

> **REVISED in iteration 4 (SYS-10).** The previous default put **Thrust on `KeyD` (middle, held)**
> and **Reverse on `KeyS` (ring, flicked)**. Middle-and-ring is the worst finger-independence pair
> on the human hand: the ring finger's flexor tendons are interconnected with the middle finger's,
> and ring-finger independence is at its minimum precisely when the middle finger is held in
> flexion. That layout put the game's signature maneuver — *flick Reverse while thrusting*, the
> move you make every single time you turn around — on the one finger combination the hand is least
> able to perform independently, while Fire was being hammered at up to 20 Hz two keys away. §3's
> old justification for `KeyS` reasoned only about **accidental** co-depression and never tested
> **deliberate** actuation under load. Thrust moves to the thumb.

```
        LEFT HAND — the four buttons                RIGHT HAND — the 2-way stick

           ┌───┐ ┌───┐ ┌───┐ ┌───┐                           ┌───┐
           │ A │ │ S │ │ D │ │ F │                           │ ↑ │
           └───┘ └───┘ └───┘ └───┘                      ┌───┐└───┘┌───┐
            SB  HYPER  REV  FIRE                        │ ← │ │ ↓ │ │ → │
                                                        └───┘└───┘└───┘
              ▁▁▁▁▁▁▁▁▁▁▁▁▁▁                             (← and → unused)
                  SPACE
                  THRUST
```

| Action | Key (`KeyboardEvent.code`) | Hand / finger | Why this key |
|---|---|---|---|
| **Stick Up** | `ArrowUp` | right, index or middle | The mission specifies arrows. `↑`/`↓` sit in an inverted T, so **one finger rocks between them** — exactly the motion of a 2-way stick, and it is impossible to hold both by accident with one finger. |
| **Stick Down** | `ArrowDown` | right, same finger | as above |
| **Thrust** | `Space` | left **thumb** | **The only sustained hold in the game.** The thumb is the only digit that can hold indefinitely at zero cost to the other four — it rests below the home row on its own key and its flexors are not coupled to any finger above it. Putting Thrust here removes the middle/ring conflict entirely and frees the strongest remaining finger for Reverse. |
| **Fire** | `KeyF` | left **index**, home position | Hammered at up to 20 Hz. The index finger is the fastest and most fatigue-resistant, and `F` is a home-row anchor key with a tactile bump on most keyboards, so the hand re-finds it without looking. |
| **Reverse** | `KeyD` | left **middle**, home position | A discrete flick, performed constantly *while thrusting*. **Index/middle is the strongest independence pair on the hand**, and with Thrust on the thumb neither neighbour is held down. It is still a different finger from Fire, which is what constraint 3 asks for: a Reverse pressed on the same frame as Fire is silently destroyed (`CONTROL_SYSTEM_SPEC.md §3.5`), so the two must be separable *deliberately*, at speed — not merely hard to hit by accident. |
| **Hyperspace** | `KeyS` | left **ring** | Rare, and always a panic move. It gets the ring finger rather than the pinky because panic presses need a digit that can actually reach under load; it is never held and never repeated, so ring-finger independence does not matter here. |
| **Smart Bomb** | `KeyA` | left **pinky** | Rare and expensive. The weakest finger at the edge of the cluster makes accidental use nearly impossible, which matters because a wasted bomb is unrecoverable. |
| Start 1 | `Enter` *(also `Digit1`)* | either | menu-level, never during play |
| Start 2 | `Digit2` | either | menu-level |
| Pause | `Escape` | either | not a cabinet control; see §7.2 |

**Rollover, as a consequence.** The worst realistic case is now **Thrust + Fire + Reverse + Up** =
`Space` + `KeyF` + `KeyD` + `ArrowUp`, which is **three keys on the `ASDF` row** plus one on the
space bar and one in the arrow cluster — three physical regions instead of four keys crammed into
one row. The previous layout's worst case was `A S D F` + `Space` + `ArrowUp`, four adjacent keys in
a single row, which §11's open item concedes was **never tested on any laptop**. This revision
materially improves the untested case; it does not excuse leaving it untested. See §11.

### 3.1 Why not the obvious alternatives

| Alternative | Why not |
|---|---|
| Fire on `Space` | Space is the reflexive "shoot" key, but the thumb is the *slowest* digit for sustained **hammering** and the space bar has the longest travel on a laptop. At 20 Hz that is the wrong joint. **Note the asymmetry: this is an argument about repeated actuation and it does not apply to a hold.** Thrust is a hold, so the thumb is exactly right for it — which is why the two swapped in iteration 4. |
| Thrust on `KeyD`, Reverse on `KeyS` (the iteration 1–3 default) | Middle-held plus ring-flicked is the worst finger-independence pair on the hand, and it is loaded on the game's most frequent maneuver. SYS-10. |
| Hyperspace on `Space` | It was there through iteration 3, and it worked — but Thrust needs the thumb more, and Hyperspace is pressed a handful of times per game against Thrust's near-continuous hold. |
| Thrust on `Shift` | Big and holdable, but it is a pinky key, it triggers OS sticky-keys prompts after five presses, and it is a common browser/OS modifier. |
| WASD for the stick plus a right-hand button cluster | Ergonomically fine, and it is offered as the **left-handed** layout (§4). It is not the default because the mission specifies arrows. |
| All five buttons split across both hands | Breaks constraint 5. The stick hand must stay free. |
| Fire on the right hand, next to the arrows | Puts the hammered key and the held stick on one hand. On a laptop the keys adjacent to the arrow cluster (`/`, `RightShift`, `Fn`) are also inconsistent across machines. |

---

## 4. Left-handed alternate — an exact mirror

Same finger roles, opposite hands. Selectable from the options screen as **"Left-handed"**.

```
        LEFT HAND — the 2-way stick              RIGHT HAND — the four buttons

                 ┌───┐                          ┌───┐ ┌───┐ ┌───┐ ┌───┐
                 │ W │                          │ J │ │ K │ │ L │ │ ; │
            ┌───┐└───┘┌───┐                     └───┘ └───┘ └───┘ └───┘
            │ A │ │ S │ │ D │                    FIRE  REV  HYPER  SB
            └───┘└───┘└───┘
             (A and D unused)                        ▁▁▁▁▁▁▁▁▁▁▁▁▁▁
                                                          SPACE
                                                         THRUST
```

Mirrored to match §3 as revised in iteration 4: Thrust is on the thumb, Reverse on the middle
finger beside Fire.

| Action | Key | Finger |
|---|---|---|
| Stick Up | `KeyW` | left index/middle, rocking on the `W`/`S` column |
| Stick Down | `KeyS` | left, same finger |
| Thrust | `Space` | right **thumb** (the only sustained hold) |
| Fire | `KeyJ` | right index (home row anchor, tactile bump) |
| Reverse | `KeyK` | right middle — index/middle, the strongest independence pair |
| Hyperspace | `KeyL` | right ring |
| Smart Bomb | `Semicolon` | right pinky |

The finger assignment is identical to §3 — **thumb holds, index hammers, middle flicks, ring panics,
pinky is the expensive button**. Only the hands swap.

## 4.1 "Cabinet" preset — an optional third layout

For players who want the original hand division without the arrow keys:

| Action | Key |
|---|---|
| Stick Up / Down | `KeyW` / `KeyS` (left hand, the stick) |
| Reverse / Thrust | `KeyI` / `KeyO` (right hand, upper row — the cabinet's top two buttons) |
| Smart Bomb / Hyperspace | `KeyK` / `KeyL` |
| Fire | `Space` |

Offered as a preset, not a default. It reproduces the physical arrangement of the upright panel
(Reverse and Thrust side by side above, Smart Bomb and Hyperspace below, Fire under the thumb) at
the cost of the arrow keys the mission asked for.

---

## 5. Keyboard implementation rules — these are requirements, not suggestions

### 5.1 Use physical key codes

Bind on **`KeyboardEvent.code`**, never `KeyboardEvent.key`. `code` is the physical position, so
`KeyF` is the same physical key on QWERTY, AZERTY, QWERTZ and Dvorak. Using `key` would move Fire to
a different finger on a French keyboard.

### 5.2 Ignore OS auto-repeat, absolutely

```js
window.addEventListener('keydown', e => {
    if (e.repeat) return;          // MANDATORY
    ...
});
```

The OS auto-repeat generates a stream of `keydown` events while a key is held. The core's edge
detector consumes **level**, not events, so a repeat that toggled the bitfield would manufacture
edges the hardware could never produce — turning "hold Fire" into a machine gun and destroying the
20 Hz ceiling that is the whole feel of Defender's trigger.

Maintain a `Set` of currently-down `code`s. `keydown` adds, `keyup` removes. The bitfield is derived
from the set, not from events.

### 5.3 Sample-and-hold, exactly like the PIA — no buffering

`sampleInput()` reports the state of the key set **at the instant the core asks**. It does not
remember that a key went down and up between two calls.

This is deliberate and it is authentic: the original PIA was also read once per frame, and a contact
closure shorter than 16.64 ms could also be missed. **Do not add a press buffer, a "sticky one
frame" latch, or input coalescing.** They would make the trigger easier to abuse than the original's
and would break the acceptance tests in `CONTROL_SYSTEM_SPEC.md §12`.

Real keyboards close for 40–120 ms per deliberate press, so in practice nothing is ever missed.

### 5.4 `preventDefault` on every bound key

`ArrowUp`/`ArrowDown` scroll the page. `Space` scrolls the page and activates focused buttons.
`Enter` activates focused buttons. Call `preventDefault()` on `keydown` **and** `keyup` for any code
that is currently bound, and only for those codes — so `Cmd+R`, `F12`, `Cmd+Tab` and browser
shortcuts keep working.

### 5.5 Focus loss must release everything

```js
for (const ev of ['blur', 'visibilitychange', 'pagehide'])
    window.addEventListener(ev, () => downKeys.clear());
```

If the tab loses focus mid-thrust, the `keyup` never arrives and the ship thrusts forever. Clearing
the set produces a clean release, which — importantly — also lets the two-zero edge detector re-arm,
so the first Fire after refocusing works.

**Pair this with a pause.** On `blur`, stop calling `step()` as well. Do not let the simulation run
unattended, because a replay recorded across a focus loss would contain phantom frames.

### 5.6 Fullscreen

Fullscreen is a mission requirement (acceptance condition 2). Request it from a user gesture,
and re-attach the key handlers to the fullscreen element. Do **not** request Keyboard Lock
(`navigator.keyboard.lock()`) — capturing `Escape` traps the user, and no bound key needs it.

### 5.7 Never sample the DOM inside `step()`

`step()` is pure and deterministic. The input module writes its two bytes into a plain object; the
render loop passes that object to `step()`. Those two bytes are exactly what the replay records:
one `uint16` per tick, packed `pia2 | (pia3 << 8)`.

**The replay tuple is `{seed, cmos, inputSequence, tickCount}`, defined in
`CLASSIC_MODE_CONTRACT.md` §8.** Do not restate it here — the `{rngState, inputSequence, tickCount}`
spelling that stood in iterations 1–3 was one of four incompatible versions in the corpus (C-30);
`ARCHITECTURE.md` §6 has been amended to match §8, and §8 is now the only place the tuple is
written down.

---

## 6. Gamepad

Supported as **additive**, never as the default, and never in a way that changes the grammar.

### 6.1 Mapping (Standard Gamepad layout)

| Action | Button | Index | Hand / digit | Why |
|---|---|---:|---|---|
| Stick Up | D-pad Up | 12 | left thumb | discrete, exactly 2-way |
| Stick Down | D-pad Down | 13 | left thumb | |
| **Fire** | A / Cross | 0 | right **thumb** | hammered; the face button is the fastest thumb target |
| **Thrust** | RB / R1 | 5 | right **index** | held; a **digital** shoulder, so it is on a different digit from Fire and can be held indefinitely while Fire is hammered |
| **Reverse** | LB / L1 | 4 | left **index** | a flick, on the **opposite hand** from both Fire and Thrust — the strongest possible separation for constraint 3 |
| **Smart Bomb** | X / Square | 2 | right thumb | deliberate |
| **Hyperspace** | B / Circle | 1 | right thumb | panic |
| Start 1 | Start | 9 | — | |
| Pause | Select / Back | 8 | — | |

Left stick Y and the analog triggers are **optionally** accepted as duplicates; see §6.3.

### 6.2 Discreteness is mandatory

- **Thrust is on a digital button.** It is on/off. There is no partial thrust in Defender — the
  thrust term is a fixed `±$0300` per frame (`MOVEMENT_PHYSICS_SPEC.md §3.1`) — so an analog trigger
  can only ever be a worse switch.
- **Reverse is on a digital button.** It is a discrete state flip with its own debounce
  (`CONTROL_SYSTEM_SPEC.md §8`).
- **The stick is discrete.** D-pad Up/Down map straight to the PIA bits.

### 6.3 If analog inputs are enabled (options, off by default)

| Analog source | Digitisation | Deviation? |
|---|---|---|
| Left stick Y | 3-state: `y < −0.5` → Up, `y > +0.5` → Down, else center. **Hysteresis: once engaged, stay engaged until \|y\| < 0.35.** | No — the grammar is preserved; log it as an accessibility affordance. |
| RT / R2 (axis or button 7) | threshold 0.5, hysteresis 0.35, as a duplicate of Thrust | No — but note that a trigger cannot be held as steadily as a bumper. |
| Left stick X | **Refused.** Binding a horizontal axis to anything is a control-grammar change. | Would be a **mechanical deviation**; not offered. |

Without hysteresis, a stick resting near the threshold chatters and the edge detector fires
repeatedly — which for Fire would exceed the 20 Hz ceiling. Hysteresis is not polish, it is
correctness.

### 6.4 Polling

The Gamepad API is polled, not evented. Poll `navigator.getGamepads()` **once at the top of each
render frame**, immediately before deciding how many `step()`s to run, and merge the result into the
same key-set-derived bitfield with a bitwise OR. Keyboard and gamepad are always both live; there is
no "input mode".

Gamepad polling adds up to one host frame of latency on top of the device's own 4–16 ms. This is
disclosed in §8 and is the reason keyboard is the default.

### 6.5 Vibration

Permitted in **Enhanced Mode only**, driven by the core's event stream (player death, smart bomb,
laser hit). Forbidden in Classic Mode: the cabinet had no haptics and the mission's Classic Mode is
a preservation baseline. Never gate a simulation decision on rumble.

---

## 7. Trackpad, touch, and non-controls

### 7.1 Trackpad

The mission mentions the trackpad as an optional input. **It is not offered for gameplay in Classic
Mode**, and this is a considered refusal, not an omission: a trackpad produces a continuous 2-D
position, and every way of turning that into Defender's controls requires inventing a mechanic —
either a horizontal axis (grammar change) or a "ship follows the pointer" mode (grammar change and a
different game). The trackpad is used for menus, the options screen and the lab overlays.

If a pointer-driven mode is ever built for Enhanced Mode, it must be labeled **"Pointer Flight —
mechanical deviation"** in the UI and excluded from the regression corpus.

### 7.2 Pause, and what is not a cabinet control

`Escape` pauses. There is no pause on a Defender cabinet, so pause is a host-level feature: it stops
the render loop from calling `step()` and it clears the key set. It must never be visible to the
core, and a paused frame must never appear in a replay.

### 7.3 Operator controls

Advance, Auto-Up, High-Score-Reset, Slam and the three coin switches exist on PIA0 and are dispatched
through `SWTAB1` (`defb6.src:1861–1872`). Classic Mode's preservation value includes them, but they
are **not** bound by default. They live behind a "Service Panel" overlay in the lab build, mapped to
on-screen buttons. Coin insert is unnecessary — the mission requires no coins (acceptance
condition 2) — so the game starts in free-play (`FREEPL`) and `Enter` sends Start 1.

---

## 8. The latency budget

### 8.1 The chain, measured end to end

```
  physical key travel and contact bounce        ~5–20 ms   (keyboard hardware, not ours)
→ OS / browser keydown dispatch                  ~1–5 ms
→ input bitfield write                            ~0 ms    (synchronous, on the event)
→ waiting for the next step() boundary            0–16.64 ms   (mean 8.32 ms)
→ in-simulation latency                           see CONTROL_SYSTEM_SPEC.md §2.3
→ render of the resulting state                  ~1 host frame
→ display scan-out and panel response            ~5–20 ms  (display, not ours)
```

### 8.2 The part we own, in frames

| Action | Sim latency (frames) | Our contribution, mean | Our contribution, worst |
|---|---:|---:|---:|
| Thrust on/off | 0 | 8.3 ms + 16.6 ms present = **24.9 ms** | 16.6 + 16.6 = 33.3 ms |
| Stick Up/Down | 0 | **24.9 ms** | 33.3 ms |
| Fire | 1 | 8.3 + 16.6 + 16.6 = **41.5 ms** | 49.9 ms |
| Smart Bomb, Hyperspace | 1 | **41.5 ms** | 49.9 ms |
| Reverse (image, laser direction) | 1 | **41.5 ms** | 49.9 ms |
| Reverse (thrust direction, camera) | 2 | **58.2 ms** | 66.6 ms |

**The 1- and 2-frame simulation latencies are the original's** — they come from the switch-scan →
executive-pass → redraw pipeline (`CONTROL_SYSTEM_SPEC.md §2.3`) and reproducing them is required,
not optional. The original cabinet had the same numbers.

### 8.3 Rules

1. **Never smooth, never interpolate, never predict.** No input smoothing, no "coyote frames", no
   look-ahead. Defender's feel is in the exact latencies above.
2. **Write the bitfield on the event, not on a poll.** A keyboard event handler that only sets a flag
   costs nothing; polling `document.activeElement` or reading state in `rAF` adds a frame.
3. **Run `step()` before rendering, not after.** In the render loop: poll gamepads → run the correct
   integral number of `step()`s → render the resulting state. Rendering last removes one frame of
   display latency.
4. **A dropped host frame must never change the simulation.** The loop accumulates real time and
   calls `step()` the right number of times; the input bitfield used for a catch-up burst is the
   same for every step in that burst. This is a stated consequence of `ARCHITECTURE.md §3` and is
   tested by MP-50.
5. **Do not use `requestAnimationFrame`'s timestamp inside `step()`.** The core takes no time.
6. Target: on a 60 Hz display with the sim at 60.09615 Hz, the accumulator drifts by one frame every
   ~11 minutes and silently absorbs it. On a 120 Hz display, render twice per `step()`; do not
   double-step.

---

## 9. Remapping

### 9.1 Rules

| Rule | Reason |
|---|---|
| Any action may be bound to any number of physical keys or buttons; they are OR'd. | Lets a player put Thrust on both `D` and `RB`. |
| **One physical key may not be bound to two actions.** | Two simultaneous edges from one key would be resolved by the lowest-bit rule and one would be silently destroyed (`CONTROL_SYSTEM_SPEC.md §3.5`). That is confusing rather than authentic. The binder rejects the second assignment and says why. |
| Modifier keys (`Shift`, `Control`, `Alt`, `Meta`) may not be bound, alone or in combination. | OS-level interception, sticky-keys prompts, and no chorded input exists on the cabinet. |
| `Escape`, `Tab` and `F1`–`F12` may not be bound. | Browser and accessibility escape hatches must stay available. |
| Every action must have at least one binding before the dialog can be closed. | An unbound Hyperspace is an unwinnable game. |
| Bindings persist in `localStorage` under a versioned key, e.g. `defender.bindings.v1`. | An unrecognised version falls back to the default layout rather than half-applying. **The record is `version: 2` since the laptop scheme landed; a v1 record falls back rather than pinning a returning player to the old grammar silently.** |
| The options screen offers four presets — **Laptop** (default), **Arcade right-handed**, **Arcade left-handed**, **Arcade cabinet** — plus "Reset to default". | §2.5, §3, §4, §4.1. |
| **A binding map carries the scheme it speaks** (`"scheme": "laptop" \| "arcade"`), and a partial stored map is completed from *that scheme's* preset. | Filling a laptop map in from the arcade preset would inherit `thrust: ["Space"]` while Space is Fire, and one key would drive two lines. |
| **Which actions are *required* depends on the scheme.** `laptop` requires the two directional actions and never `thrust`/`reverse`; `arcade` the reverse. | Warning that "Thrust is unbound" in a scheme that has no thrust key is telling the player to bind something that does nothing. |

### 9.2 Storage format

> **The example below is the ARCADE scheme and it still carries the iteration 1-3 key spellings**
> (`thrust: KeyD`, `reverse: KeyS`, `hyperspace: Space`), which §3's iteration-4 revision replaced —
> see the note in `src/input/bindings.js`. It is left as written because it is quoted elsewhere; the
> shipped values are §3's table. A laptop record looks like the second block.

```json
{
  "version": 2,
  "preset": "default",
  "scheme": "arcade",
  "keyboard": {
    "fire":       ["KeyF"],
    "thrust":     ["KeyD"],
    "reverse":    ["KeyS"],
    "smartBomb":  ["KeyA"],
    "hyperspace": ["Space"],
    "up":         ["ArrowUp"],
    "down":       ["ArrowDown"],
    "start1":     ["Enter", "Digit1"],
    "start2":     ["Digit2"]
  },
  "gamepad": {
    "fire": [0], "hyperspace": [1], "smartBomb": [2],
    "reverse": [4], "thrust": [5],
    "up": [12], "down": [13], "start1": [9]
  },
  "analog": { "leftStickY": false, "triggers": false }
}
```

The default record — the laptop scheme — differs in the `scheme` field, the two directional actions,
and the deliberately empty `thrust` / `reverse`:

```json
{
  "version": 2,
  "preset": "laptop",
  "scheme": "laptop",
  "keyboard": {
    "thrustLeft":  ["ArrowLeft"],
    "thrustRight": ["ArrowRight"],
    "up":          ["ArrowUp"],
    "down":        ["ArrowDown"],
    "fire":        ["Space"],
    "smartBomb":   ["KeyB"],
    "hyperspace":  ["KeyH"],
    "thrust":      [],
    "reverse":     [],
    "start1":      ["Enter", "Digit1"],
    "start2":      ["Digit2"]
  }
}
```

`thrustLeft` and `thrustRight` are the only two actions in the whole module with **no `PIA_BITS`
entry**. They cannot reach the core: `flight.js` consumes them, and `packPia` drops them on the
floor if one ever gets past it (`IM-19`).

### 9.3 Accessibility options — all of them are labeled deviations

| Option | What it does | Classification |
|---|---|---|
| **Thrust toggle** | Thrust becomes a toggle rather than a hold. | **Mechanical deviation.** The PIA bit is synthesized by the input layer, so the core is unchanged, but the player's relationship to the control is not the original's. Excluded from the regression corpus; flagged in the replay header. |
| **Auto-fire** | Holds Fire at the exact 3-frame cadence. | **Skill deviation, not a mechanical one** — it produces a bit-sequence a human could produce. Still flagged in the replay header, still excluded from leaderboard-style comparisons. |
| **Reverse repeat guard** | Suppresses a Reverse press that lands on the same frame as Fire or Thrust and re-issues it on the next clean frame. | **Mechanical deviation, and a significant one** — it removes the lost-edge behavior of `CONTROL_SYSTEM_SPEC.md §3.5`, which is one of Defender's real tells. Offer it if you must, default off, loudly labeled. |
| **Slower simulation (75 % / 50 %)** | The render loop calls `step()` less often. The simulation is untouched and remains bit-exact. | Not a mechanical deviation — the *game* is identical, only wall-clock time changes. Flag in the replay header so timings are comparable. |

None of these may be on by default, and Classic Mode's `CLASSIC_INVARIANTS` check must fail if any
is enabled during a preservation run.

**One interaction with the laptop scheme, recorded rather than discovered later.** The converters run
*before* the directional synthesis — they see what the player did, never the lines the module
invents — which is what stops a sticky-thrust latch from toggling on a synthesized edge or a reverse
pulse being mistaken for a press. The cost is that **A-02 (sticky / toggle Thrust) does nothing in
the laptop scheme**: there is no Thrust key to make sticky. A-02b (sticky stick), A-03 (auto-fire)
and A-29 (confirm hyperspace) are unaffected and work identically in both schemes. A directional
equivalent of A-02 — "sticky heading", where tapping `→` keeps thrusting right until you tap `←` —
is the obvious fix and is not built; it belongs to whoever next opens `assist.js`.

### 9.4 Input visualisation (lab feature)

The lab overlay may show, per frame: the raw `pia2`/`pia3` bytes, the two debounce shadows, the
computed edge byte, which bit won the dispatch, **which edges were destroyed**, and the current
`STATUS` gate. The destroyed-edge readout is the single most useful teaching artifact in the whole
project — it makes an invisible original behavior visible without changing it.

---

## 10. Acceptance tests

Prefix `IM-`. These test the input module in isolation; the core is mocked.

**IM-01 — physical codes, not layout characters.**
Dispatch a `keydown` with `code = 'KeyF'` and `key = ';'` (a Dvorak-like combination). Assert
`pia2 & 0x01` is set.

**IM-02 — auto-repeat is ignored.**
Dispatch `keydown{code:'KeyF'}`, then 30 × `keydown{code:'KeyF', repeat:true}`, then
`keyup{code:'KeyF'}`. Assert the bitfield was `0x01` continuously and returned to `0` exactly once.
Then feed those 32 frames to the real core and **count laser-creation events: assert exactly 1** —
one shot, not 31. *(Corrected in iteration 4, F-10: the old `lflg === 1` form is unrunnable at
frame 32. From the right-facing rest home column the laser head starts at byte-column 39 and
advances 4 columns/frame, so it first fails `head >= 0x9800` at `k = 29` and `LRDIE` — which does
`lflg -= 1` — runs on frame 30. `lflg` is already 0 by frame 32. Counting creations gives the same
guarantee without depending on the laser's lifetime.)*

**IM-03 — sample-and-hold, no buffering.**
Dispatch `keydown{code:'KeyF'}` and `keyup{code:'KeyF'}` between two `sampleInput()` calls. Assert
both samples read `0x00`. The press is legitimately lost.

**IM-04 — the default bitfield assembly.** *(Updated for the §3 layout revision, SYS-10.)*
Hold `Space`, `KeyF`, `ArrowUp`. Assert `pia2 === 0x03` (Fire bit 0 + Thrust bit 1) and
`pia3 === 0x01`. Then hold `Space`, `KeyF`, `KeyD`, `ArrowUp` — the realistic worst case — and
assert `pia2 === 0x43` (Reverse is bit 6) and `pia3 === 0x01`.

**IM-05 — Reverse is on a different finger, and the bit is right.**
Hold `KeyS`. Assert `pia2 === 0x40`.

**IM-06 — blur clears everything and re-arms the edge detector.**
Hold `KeyD` and `KeyF`. Dispatch `blur`. Assert `pia2 === 0x00`. Then re-press `KeyF` and feed three
frames to the core; assert an edge fires (the two-zero history was satisfied by the blur).

**IM-07 — `preventDefault` on bound keys only.**
Dispatch `keydown{code:'ArrowUp'}` and assert `defaultPrevented`. Dispatch `keydown{code:'KeyZ'}`
(unbound) and assert it is not prevented.

**IM-08 — one key cannot serve two actions.**
Attempt to bind `KeyF` to both Fire and Reverse. Assert the second binding is rejected with a reason
string, and that the stored bindings are unchanged.

**IM-09 — modifiers and escape hatches are refusable.**
Attempt to bind `ShiftLeft`, `Escape`, `Tab`, `F5`. Assert all four are rejected.

**IM-10 — the left-handed preset is an exact mirror.**
Apply the left-handed preset. Assert the finger-role mapping matches §4 exactly and that every one
of the nine actions has a binding.

**IM-11 — analog stick digitisation with hysteresis.**
Feed `leftStickY = −0.6, −0.4, −0.36, −0.34, −0.2, +0.6`. Assert Up is engaged at −0.6, remains
engaged through −0.4 and −0.36, disengages at −0.34, stays disengaged at −0.2, and Down engages at
+0.6. Assert the bitfield never chatters when `y` oscillates within ±0.02 of 0.5.

**IM-12 — keyboard and gamepad are OR'd, not exclusive.**
Hold `KeyD` on the keyboard and press RB on the gamepad. Assert `pia2 & 0x02` is set once, and that
releasing only one of them keeps the bit set.

**IM-13 — gamepad triggers are digital when enabled.**
With `analog.triggers = true`, feed trigger values `0.49, 0.51, 0.4, 0.34`. Assert Thrust engages at
0.51, holds at 0.4, releases at 0.34.

**IM-14 — a dropped host frame does not multiply input.**
Simulate a 200 ms stall with `KeyF` held throughout. Assert the catch-up burst runs
`floor(accumulator / 16.640) = 12` `step()`s (with `200 − 12 × 16.640 = 0.32 ms` left in the
accumulator), that all 12 receive the **identical** bitfield, and that exactly **one** laser was
created — the edge fired on the first step of the burst and the two-zero history was never
re-established.

**IM-15 — latency, measured.**
Instrument the loop. Assert that the interval from the synthetic `keydown` to the first `step()`
that observes the bit is ≤ 16.64 ms + 2 ms of scheduling slop, over 1000 trials, and report the mean
(target ≤ 10 ms).

**IM-16 — replay purity.**
Record 2000 frames of play including a `blur`/`focus` cycle and a pause. Assert the recorded
`inputSequence` contains exactly 2000 entries, that the paused frames are absent rather than
zero-filled, and that replaying it reproduces the final state bit for bit.

**IM-17 — accessibility flags reach the replay header.**
Enable Thrust-toggle and record a replay. Assert the header carries
`deviations: ["thrust-toggle"]` and that the Classic Mode invariants check fails for that replay.

### 10.1 The laptop scheme — IM-18 … IM-29

All twelve are implemented in `tests/input.test.js` and run under `node --test tests/`.

**IM-18 — the laptop scheme is the default, and the arcade scheme is untouched.**
`defaultProfile()` reports `scheme: 'laptop'`, `←`/`→` on the two directional actions, Fire on
`Space`, Smart Bomb on `KeyB`, Hyperspace on `KeyH`, and `thrust`/`reverse` empty. `arcadeProfile()`
reports the §3 map exactly, and `applyPreset(…, 'default')` reproduces it from a laptop profile.

**IM-19 — the two synthetic actions can never reach the core.**
`PIA_BITS.thrustLeft` and `PIA_BITS.thrustRight` are `undefined`, and
`packPia(['thrustLeft','thrustRight'])` is `{pia2: 0, pia3: 0}` — a leak past the synthesiser is
dropped, never aliased onto a real line.

**IM-20 — agreeing direction is pure level thrust.**
Facing right, hold `→` for 40 ticks. Assert bit 1 high on every tick and bit 6 **never** rises.
Mirror for `←` while facing left.

**IM-21 — the reverse pulse is exactly one tick and carries no thrust.**
Facing right, hold `←`. Assert tick 0 has bit 6 set and bit 1 **clear** (bit 1 would win the
lowest-bit dispatch and destroy the bit-6 edge); ticks 1..n have bit 6 clear and bit 1 set. Once the
facing flips, assert 30 further ticks of plain thrust with no second pulse.

**IM-22 — the pulse is a real edge under `SSCAN`'s own detector, and only one.**
Feed the emitted `pia2` words through `~(pia21 | pia22) & pia2` for 40 ticks. Assert exactly **one**
reverse edge is produced.

**IM-23 — a destroyed pulse is retried, not silently lost.**
Hold the opposing arrow with the facing pinned (simulating an eaten or `REVFLG`-swallowed pulse).
Assert 3 to 4 edges over `3 × retryFrames + 2` ticks — retried, but never one per frame, which would
never edge at all. Assert `retryFrames > 7`, i.e. longer than `REV`'s 2 + 5 frame lock-out.

**IM-24 — the pulse stands off rather than eat the player's shot.**
Press Fire and the opposing arrow together with Fire armed. Assert Fire goes out on that tick and
the reverse is **deferred** to the next one, rather than either being destroyed or the shot being
swallowed.

**IM-25 — releasing an arrow never auto-reverses.**
Hold `→`, release. Assert 20 ticks of `pia2 === 0`, and that re-pressing `→` resumes thrust with
zero pulses. A tap must not spin the ship.

**IM-26 — roll-over and both-held.**
Holding `→` then pressing `←` without releasing turns the ship (last press wins) and releasing `←`
turns it back: two pulses, two deliberate turns. Pressing both from a standing start is a **tie** —
20 ticks of thrust on the current heading and **no** pulse — and releasing one hands over by the
ordinary rule.

**IM-27 — the 2-way vertical stick is untouched.**
Up is still PIA3 bit 0, Down is still PIA2 bit 7, and Down composes with a turn on the same tick
without deferral — bit 7 is *higher* than bit 6 and cannot destroy the pulse.

**IM-28 — the input layer reads the facing and never writes.**
`facingFromState` prefers `NPLAD` over `PLADIR`. A `Object.freeze`d state object is handed to
`readFacing` and survives a `sample()` deep-equal unchanged.

**IM-29 — the arcade scheme through the real core is what it always was.**
Hold the Reverse key for 120 ticks against a live core. Assert `pia2 === 0x40` on every one of them
(a held key is a held line, exactly as the cabinet's was) and that the ship turned **once**.

---

## 11. Open items

| Item | Interim behavior for the builder | What would settle it |
|---|---|---|
| **Keyboard n-key rollover.** Many laptop keyboards ghost or block when 4+ keys in the same matrix row are held. **Improved but still untested in iteration 4 (SYS-10).** The revised default (§3) holds `Space` for Thrust, so the realistic worst case is `KeyF` + `KeyD` + `ArrowUp` + `Space` — **three** `ASDF`-row keys across three physical regions, down from the previous layout's four (`A S D F`) plus `Space` plus `ArrowUp`. | Add the **Rollover Test** panel to the options screen (lights up each action as it is detected, so a player can discover their own keyboard's limit and rebind around it) — **but the panel is not the answer, it is the fallback.** | **This test must run before Phase 3 closes, not after.** Test the revised default on three reference machines (MacBook internal, a common USB membrane board, a mechanical board) and record which 4- and 5-key combinations survive. If the internal MacBook keyboard fails on a realistic combination, move Smart Bomb off the `ASDF` row — `KeyQ` or `KeyG` are the candidates. Tracked as a carried risk in `PHASE2_GATE.md`. |
| **Whether `Space` for Hyperspace violates player expectation** badly enough to hurt first-impression quality. The mission's "wow the critic" bar is a real constraint. | Ship as specified and put a one-line control legend on the attract screen. | Watch three first-time players. If more than one reflexively presses Space expecting to shoot and reads it as broken, swap Fire↔Hyperspace and re-justify. |
| **Gamepad button indices on non-standard pads.** The Standard Gamepad mapping is not universal; some pads report `mapping: ""`. | If `gamepad.mapping !== 'standard'`, do not guess — open the rebinding dialog with a "press the button for Fire…" flow. | Nothing to settle; this is the correct behavior. Listed so it is not forgotten. |
| **The exact contribution of display scan-out** to §8.1's budget is machine-dependent and was estimated, not measured. | Use the table as a design target, not as a claim. Only the "our contribution" columns are asserted by IM-15. | A high-speed-camera measurement on the reference machine, if anyone ever wants the end-to-end number. |
| **Whether the operator Service Panel belongs in the shipping build at all.** It is preservation-valuable and mission-irrelevant. | Build it in the lab overlay only, behind a keystroke that is not bound by default. | A product call by the lead orchestrator at the Phase 5 review. |
