# CONTROL SYSTEM SPECIFICATION — player input, weapons, and the frame contract

**Phase 2 deliverable. This is a contract, not research.** A Phase 3 builder implements from this
document alone. Where a number appears, it is the number; where an order appears, it is the order.
Every load-bearing value carries its ROM address and the `rom_peek.py` command that re-checks it,
or its `file:line` in the recovered source, or its `L-nnn` ledger id.

Companion documents:

| Document | Owns |
|---|---|
| `MOVEMENT_PHYSICS_SPEC.md` | ship motion, camera, world topology, the collision primitive |
| `design/INPUT_MAPPING.md` | keyboard / gamepad bindings, remap rules, latency budget |
| `HUMANOID_RESCUE_SPEC.md` *(sibling agent)* | humanoid state machine, catch/carry/deposit |

Runtime contract: `design/ARCHITECTURE.md`. The core is integer-only, `step()` is exactly one
original video frame of **16.640 ms**, and there is no delta-time anywhere.

---

## 0. The eight things a builder most often gets wrong

Read these first. Each is proved below.

1. **Thrust is level-triggered and read inside the physics routine. Fire, Reverse, Smart Bomb and
   Hyperspace are edge-triggered on a two-sample debounce.** They are not the same mechanism and
   they do not have the same latency.
2. **At most ONE edge-triggered action can be dispatched per frame, and it is the one on the lowest
   PIA bit.** Fire beats Smart Bomb beats Hyperspace beats Reverse. A losing edge is **discarded,
   not queued**. Pressing Fire and Reverse on the same frame loses the Reverse permanently.
3. **Thrust occupies a switch-table slot that points at address 0.** A Thrust edge therefore wins
   the dispatch and does nothing — so pressing Thrust and Reverse on the same frame also loses the
   Reverse.
4. **Reverse writes exactly one variable: `NPLAD = −PLADIR`.** Velocity is untouched. Position is
   untouched. There is no rotation, no impulse, no snap.
5. **The laser lives in screen space, not world space.** It does not scroll with the world, it
   does not wrap, and it dies at a fixed framebuffer column.
6. **Hyperspace has a ~24.6 % chance of killing you**, taken from the RNG *after* the 55-frame
   sequence completes, not before.
7. **The player has no terrain collision** (`L-144`) and **does not die on contact with a
   humanoid** — the humanoid kill vector unwinds the stack to report "no collision".
8. **Sustained Fire yields one shot every three frames maximum**, because the edge detector needs
   two consecutive released samples.

---

## 1. The physical control panel

**MULTI_SOURCE_CONFIRMED** (L-088) — `phr6.src:120–146` (Williams' own PIA comment block) and
`mame-williams/williams.cpp:762–786` (`INPUT_PORTS_START(defender)`), which agree bit for bit.

### 1.1 PIA2 (`$CC04`) — the main control port, active high

| Bit | Mask | Control | Trigger model | Consumer |
|---:|---|---|---|---|
| 0 | `$01` | **Fire** | edge | `SWTAB[0] → LFIRE` |
| 1 | `$02` | **Thrust** | **level** | `PLAYER` (`BITA #$02` at `$E28A`), `THOUT`, `SNDSEQ` |
| 2 | `$04` | **Smart Bomb** | edge (+ level, for the release gate) | `SWTAB[2] → SBOMB` |
| 3 | `$08` | **Hyperspace** | edge | `SWTAB[3] → HYPER` |
| 4 | `$10` | Start 2 | edge | `SWTAB[4] → ST2` |
| 5 | `$20` | Start 1 | edge | `SWTAB[5] → ST1` |
| 6 | `$40` | **Reverse** | edge (+ level, for the release gate) | `SWTAB[6] → REV` |
| 7 | `$80` | **Stick Down** | **level** | `PLAYER` (`BMI PLADN` at `$E330`) |

### 1.2 PIA3 (`$CC06`)

| Bit | Mask | Control | Trigger model | Consumer |
|---:|---|---|---|---|
| 0 | `$01` | **Stick Up** | **level** | `PLAYER` (`LSRA / BCS PLAUP` at `$E32B`) |
| 7 | `$80` | cabinet type input (1 = cocktail) | level | `PLSTR3` |

PIA3 is **not** scanned for edges. Up therefore has no debounce and no switch process.

### 1.3 The stick is 2-way

There is no left/right axis anywhere in the hardware or the code. Horizontal intent is expressed
only through **Thrust** (accelerate along the current facing) and **Reverse** (flip the facing).
Any implementation offering an analog horizontal axis is an alternate control mode and a recorded
mechanical deviation (`ARCHITECTURE.md §7`).

### 1.4 Controls not in scope for gameplay

Start 1 / Start 2, the coin switches on PIA0, Advance, Auto-Up, High-Score-Reset and Slam are
cabinet/operator functions. They are specified only to the extent that Classic Mode's attract →
game transition needs them; see `INPUT_MAPPING.md §7`.

---

## 2. The frame contract — what happens, in what order

Everything in this document depends on this ordering. Getting it wrong is a silent fidelity bug.

### 2.1 What the hardware actually does

**SOURCE_CONFIRMED** (`defa7.src:1931–2004`), **MULTI_SOURCE_CONFIRMED** for the raster timing
(`williams.cpp:1531,1556`; `williams_m.cpp:19–29`; `L-005`, `L-147`).

Four IRQs fire per frame, at scanlines 0, 64, 128 and 192. `IFLG` gates them down to two working
halves — the scanline-192 and scanline-64 entries do nothing. The main loop (`EXEC`) spins on
`TIMER`, which is incremented once per frame by the scanline-0 half.

The real-time sequence for one video frame *f* is therefore:

```
scanline 0   of frame f    "PHASE A"   INC TIMER; palette upload; CSCAN;
                                       BGOUT (terrain scroll); PRDISP+OPROC lower band; VELO
main loop    of frame f    "PHASE B"   EXEC: overload governor; COLCHK; XUVCT; RAND;
                                       switch dispatch (SWP); process dispatch (DISP)
scanline 128 of frame f    "PHASE C"   SNDSEQ (+SSCAN input sampling); PLAYER (physics);
                                       STOUT; OPROC upper band; PRDISP upper band; SHELL
```

### 2.2 The `step()` order — **not owned here**

> **Two documents must not both publish a canonical frame order.**
> **`CLASSIC_MODE_CONTRACT.md` §2 is the single normative frame order.** Build from it.
> The paraphrase that stood here in iterations 1–3 was rival, authoritative-sounding, and wrong in
> three ways: it recomputed "every active object's screen position" at A3 and again at C4 (C-25
> withdraws the one-pass simplification and C-24 fixes the bands), and it omitted the palette
> upload, `CSCAN`, `BGOUT` and both `PRDISP` passes entirely.

What this document owns, and what the contract's §2 defers to it for, is the **input** half of the
frame: `SSCAN`'s edge detect at C1 (§3), the `SWTAB` gating masks (§4), the latency table in §2.3
below, and `commitPlayerDraw()`'s equivalence proof in §2.4. Those are unchanged and were verified
correct on this pass.

For orientation only — not normative, and deliberately abbreviated so it cannot be mistaken for the
contract:

```
PHASE A   A1 tick   A2 palette upload (gated)   A3 CSCAN   A4 BGOUT (writes bglx)
          A5 PRDISP band A   A6 OPROC band A   A7 VELO
PHASE B   B1 governor   B2 COLCHK   B3 XUVCT   B4 rand()   B5 switch queue   B6 dispatch
PHASE C   C0 xxx2 recompute   C1 SNDSEQ + SSCAN   C2 PLAYER (writes bglx, advances bgl)
          C3 STOUT   C4 OPROC band C   C5 PRDISP band C → commitPlayerDraw()   C6 SHELL
```

Band A is rows 121..255 (`OPROC`) / 120..254 (`PRDISP`); band C is rows 1..120 / 0..119. They are
complementary — no row is drawn twice (C-24).

`step()` takes the raw input word `{pia2, pia3}` for this frame (`CLASSIC_MODE_CONTRACT` §7) and
returns nothing; the caller reads a state snapshot afterwards.

### 2.3 Latency, exactly

| Action | Sampled | Takes effect | Frames of latency |
|---|---|---|---:|
| Thrust on/off | C1 of frame *n* | C2 of frame *n* (same phase) | **0** |
| Stick Up / Down | C1 of frame *n* | C2 of frame *n* | **0** |
| Fire | C1 of frame *n* | `LFIRE` runs at B6 of frame *n+1*; first laser segment drawn same pass | **1** |
| Smart Bomb | C1 of frame *n* | `SBOMB` runs at B6 of frame *n+1*; enemies die same pass | **1** |
| Hyperspace | C1 of frame *n* | `HYPER` starts at B6 of frame *n+1* | **1** |
| Reverse — ship image and laser direction | C1 of frame *n* | `REV` sets `NPLAD` at B6 of frame *n+1*; image flips at C5 of frame *n+1* | **1** |
| Reverse — thrust direction and camera home | as above | `PLADIR` is read by `PLAYER` at C2 of frame *n+2* | **2** |

### 2.4 `commitPlayerDraw()` — and why one commit point is exact

The original commits the pending facing inside the *draw* routine:

```
PRDISP  STA  TEMP48
        LDA  STATUS
        BITA #$10            ; player image/collision inactive?
        BNE  PRDX            ; yes -> draw nothing, commit nothing
        ... band test against PLAYC ...
        (erase old sprite using the OLD PLADIR)
PRDSP2  LDD  NPLAD
        STD  PLADIR          ; <-- the commit
        (draw new sprite)
POUT    LDD  NPLAXC
        STD  PLAXC           ; <-- PLAXC:PLAYC <- NPLAXC:NPLAYC, 16-bit
```
`defa7.src:2294–2331`; ROM `$E20D`–`$E24A`.

`PRDISP` is called twice per frame with two disjoint row bands, so the commit happens at **C5 for a
player at rows 0..119 and at A5 of the next frame for a player at rows 120..254** (C-24; the bands
are complementary, and `PRDISP`'s predicate is `lo ≤ playc < hi`). A single
commit at C5 is exactly equivalent, because:

- `NPLAD` is written only in phase B (`REV`, `HYPER`) — never in A or C. Both call sites therefore
  see the same value when `PLAYER` next runs at C2.
- `NPLAXC` / `NPLAYC` are written only by `PLAYER` (C2) and by `HYPER` (phase B, with `STATUS`
  bit 4 set, which suppresses the commit entirely). Both call sites therefore see the same value
  when `COLCHK` next runs at B2.

**Therefore, and this is a decision, not an observation: the core performs exactly one commit per
step, at C5, gated on `(status & 0x10) === 0`.** The raster split is a rendering concern.

```
commitPlayerDraw():
    if (status & 0x10) return           // player image + collision inactive
    pladir = nplad
    plaxc  = nplaxc
    playc  = nplayc
```

### 2.5 The overload governor is real, visible behavior — keep it

`EXEC00`–`EXEC03`, `defa7.src:3051–3090`. When a pass overruns its frame, the game cuts the
starfield from 16 stars to 3 and teleports one random `OTYP = 0` object away
(`;HYPER HIM OUT OF THERE`). In a browser reconstruction nothing overruns, so `OVCNT` stays 0 and
the governor never fires. **Implement the counter, keep it at 0, and expose it to the lab overlay.**
Do not delete it — Enhanced Mode may want to reproduce it deliberately.

---

## 3. Input sampling and edge detection — `SSCAN`

`defa7.src:762–808`. Called from `SNDSEQ`, which the scanline-128 IRQ calls immediately before
`PLAYER`. **Exactly one sample per frame.**

### 3.1 State

```js
pia21 : uint8   // PIA2 sample from THIS frame   (1 = pressed)
pia22 : uint8   // PIA2 sample from the PREVIOUS frame
pia31 : uint8   // PIA3 sample from THIS frame
swproc: [ {addr:uint16, type:uint8, mask:uint8},   // slot 0
          {addr:uint16, type:uint8, mask:uint8} ]  // slot 1
```
Initial values: `pia21 = pia22 = pia31 = 0`. Both `swproc` slots `{0,0,0}`.
(`PLSTR4`, `defa7.src:1238–1240`, forces `pia21 = pia22 = 0xFF` on the cocktail-mux path only; the
upright reconstruction never takes that branch. See §8.4.)

### 3.2 The algorithm, verbatim

```
scanSwitches(rawPIA2, rawPIA3):
    edges  = (~(pia21 | pia22)) & 0xFF     // bits that read 0 in BOTH previous samples
    pia22  = pia21                          // shift the history
    pia21  = rawPIA2                        // this frame's sample
    pia31  = rawPIA3
    edges &= pia21                          // ...and read 1 now
    if edges == 0: return

    bit = lowestSetBit(edges)               // 0..7 — ONLY ONE IS SERVICED
    e   = SWTAB[bit]
    if swproc[0].addr == 0:  swproc[0] = e
    else:                    swproc[1] = e
```

The "lowest set bit only" is not an approximation. The ROM computes it with
`CLRB / SW0 ADDB #4 / LSRA / BCC SW0`, a 4-byte-stride index built by shifting right until the
first carry. Every higher set bit in `edges` is **discarded without being remembered**, and because
`pia21` now holds a 1 in that position, no edge will ever be generated for that press.

Re-check: `rom_peek.py find "5F CB 04 44 24 FC"` — the `CLRB / ADDB #4 / LSRA / BCC` loop.

> **Note the assignment test.** The slot is considered empty when its **address** is zero. Thrust
> (bit 1) and Down (bit 7) have address-0 table entries, so they consume the frame's single
> dispatch but leave the slot readable as empty. This is why they still block Reverse.

### 3.3 `SWTAB` — the switch dispatch table, inline

`defb6.src:1845–1860`. Four bytes per entry: `FDB routine` then `FCB type, rejectMask`.

| Bit | Routine | `type` | `rejectMask` | Reject when `STATUS & mask ≠ 0` |
|---:|---|---|---|---|
| 0 | `LFIRE` `$E591` | `$00` STYPE | `$E8` | game over, controls inactive, objects disabled, player dead |
| 1 | *(null — address 0)* | `$00` | `$00` | — |
| 2 | `SBOMB` `$E8BF` | `$00` | `$F8` | the above, plus player image/collision inactive |
| 3 | `HYPER` `$E91F` | `$00` | `$F8` | as `SBOMB` (plus `HYPER`'s own stricter test, §7) |
| 4 | `ST2` | `$00` | `$00` | — |
| 5 | `ST1` | `$00` | `$00` | — |
| 6 | `REV` `$E897` | `$00` | `$E8` | as `LFIRE` |
| 7 | *(null — address 0)* | `$00` | `$00` | — |

### 3.4 Draining the queue — `SWP`

`defa7.src:3097–3114`, immediately before `DISP`.

```
drainSwitchQueue():
    for slot in [0, 1]:                      // slot 0 first
        e = swproc[slot]
        if e.addr == 0: continue
        swproc[slot] = {0,0,0}
        if (status & e.mask) != 0: continue  // rejected — silently dropped
        makeProcess(e.addr, e.type)
```

**Process link order.** `MKPROC` (`defa7.src:72–90`) links the new process at the **head** of the
active list (`LDX [CRPROC] / STU [CRPROC] / STX ,U`, with `CRPROC = &ACTIVE` during `EXEC`) and
gives it `PTIME = 1`, so it runs in the *same* dispatch pass. If both slots are occupied, the
slot-1 process becomes the head and therefore **runs before** the slot-0 process. Two slots can
only both be occupied when an `EXEC` pass overruns a frame, which cannot happen in the
reconstruction — but implement the ordering anyway so replays stay bit-exact if the governor is
ever exercised.

### 3.5 Simultaneous-input priority — the full truth table

Given a set of *new* edges in one frame, exactly one is serviced:

| Edges present | Serviced | Lost forever |
|---|---|---|
| Fire + Reverse | Fire | Reverse |
| Fire + Smart Bomb | Fire | Smart Bomb |
| Fire + Hyperspace | Fire | Hyperspace |
| **Thrust + Reverse** | Thrust (no-op) | **Reverse** |
| Smart Bomb + Hyperspace | Smart Bomb | Hyperspace |
| Smart Bomb + Reverse | Smart Bomb | Reverse |
| Hyperspace + Reverse | Hyperspace | Reverse |
| Reverse + Down | Reverse | Down (no-op) |

Thrust *level* is unaffected — losing the Thrust *edge* costs nothing, because thrust is read from
`pia21` directly. Up/Down are unaffected for the same reason. This table is the single richest
source of "that isn't real Defender" tells, and it is why `INPUT_MAPPING.md` puts Reverse under a
different hand from Fire.

---

## 4. Thrust

### 4.1 Mechanism

Thrust is **not** a switch process. `SWTAB[1]` is a null entry. The whole of Thrust is:

- **Physics** — `PLAYER` reads the level: `LDA PIA21 / BITA #$02 / BEQ PLAY1` (`$E288`–`$E28C`).
  When set, `±$0300` is added to the 24-bit velocity accumulator at its least-significant end. Full
  arithmetic in `MOVEMENT_PHYSICS_SPEC.md §3`.
- **Flame** — `THOUT` / `THOUT1` re-read the same level at draw time and extend the exhaust plume
  by three extra columns when it is set (`defa7.src:2214–2262`).
- **Sound** — `SNDSEQ` maintains the `THFLG` latch, §4.2.

There is no cooldown, no ramp gate, no minimum hold. Tapping thrust for one frame adds exactly
`+3` to `V16` (before that frame's drag) and nothing else.

### 4.2 The thrust sound latch — `SNDS00` / `SNDS01`

`defa7.src:740–758`. Runs at C1, **before** `PLAYER`, and only when no sequenced sound is playing
(`SNDTMR` has reached 0).

```
if thrustLevel == 0:
      if thflg != 0: thflg = 0; emitSound(0x0F)     // BG1 — the background drone resumes
else:
      if thflg != 0: /* already roaring */
      else if (status & 0x98) != 0: /* player not alive — stay silent */
      else: thflg = 0x16; emitSound(0x16)           // THRUST
```

`$98` = game over | player-image-inactive | player-dead. Note `SNDLD` clears `THFLG`
(`defa7.src:711`), so any sequenced sound (laser, smart bomb, death) silences the drone and it
re-arms on the next frame in which the sequence has finished.

**Consequence worth testing:** during hyperspace `STATUS = $77` has bit 4 set, so a *new* thrust
press makes no sound — but an *already latched* drone keeps playing through the whole teleport.

---

## 5. Fire and the laser

### 5.1 `LFIRE` — the gate

`defa7.src:2763–2777`; ROM `$E591`. Verified: `rom_peek.py addr E58F 48`.

```
LFIRE:
    if lflg >= 4: suicide()                 // no shot, no sound, process ends
    lflg += 1
    soundLoad(LASSND)                       // $C0 $01 $30 $14 $00
    x = (nplaxc << 8) | nplayc              // framebuffer address of the ship's top-left
    if nplad >= 0: goto LASR(x)  else: goto LASL(x)
```

Three facts hide in there:

- The cap is on **concurrent lasers**, held in `LFLG`, incremented at fire and decremented when the
  laser dies (`LASD DEC LFLG`). It is **4**, not 4-per-second and not 4-on-screen-by-position.
- The origin uses `NPLAXC`/`NPLAYC` — the ship's *new* position, this frame's physics result, not
  the drawn position.
- The direction uses `NPLAD` — the *pending* facing. A laser fired the frame after a Reverse
  already travels the new way, one frame before `PLADIR` catches up.

### 5.2 Firing cadence

Fire is edge-triggered by `SSCAN`'s two-zero rule, so the minimum interval between shots is
**3 frames**:

```
frame n   : sampled 1, previous two were 0  -> EDGE, laser 1
frame n+1 : sampled 0
frame n+2 : sampled 0
frame n+3 : sampled 1, previous two were 0  -> EDGE, laser 2
```

= 60.09615 / 3 = **20.032 shots per second**, and holding the button gives exactly **one** shot.
With a ~28-frame laser lifetime (§5.5) a player hammering at the maximum rate reaches the `LFLG`
cap of 4 and then misses shots until one expires.

### 5.3 Laser state

Each laser is a process with three 16-bit framebuffer pointers in its process data block:

```js
{ head: uint16,   // PD    — leading edge, the bright $99 pixel pair
  mid:  uint16,   // PD+2  — the fizz/noise segment front
  tail: uint16 }  // PD+4  — trailing edge, erased one column per frame
```

A framebuffer address encodes position directly: `addr = column * 0x100 + row`, where one column is
**2 px** and one row is **1 px** (`L-009`). High byte = column, low byte = row.

### 5.4 Per-frame update — right-facing (`LASR`, ROM `$E5C1`)

Verified byte for byte: `rom_peek.py addr E5C1 112`.

```
LASR(x):                                  // once, at creation
    head = mid = tail = x + 0x0704        // column +7, row +4

LASR0:                                    // once per frame, forever, until death
    if (status & 0x40) != 0: goto LRDIE                 // controls inactive -> vanish
    if head >= 0x9800:      goto LRDIE                  // right framebuffer edge, column 152

    p = head
    for i in 0..3: poke(p, 0x11); p += 0x0100           // 4 columns of colour index 1
    poke(p, 0x99)                                       // bright head, colour index 9
    head = p                                            // head advanced 4 columns = 8 px

    if fisx >= FISTAB + 29: fisx = FISTAB               // 32-byte ring, 3 bytes consumed
    p = mid
    for i in 0..2: poke(p, fistab[fisx++]); p += 0x0100
    mid = p                                             // mid advanced 3 columns = 6 px

    poke(tail, 0x00)                                    // erase one trailing column
    tail += 0x0100                                      // tail advanced 1 column = 2 px

    if laserCollide((head >> 8) - 6, head & 0xFF): goto LRDIE
    sleep(1, LASR0)

LRDIE:
    for (p = tail; p <= head; p += 0x0100) poke(p, 0)   // erase the whole streak
    lflg -= 1
    suicide()
```

Left-facing (`LASL`, ROM `$E630`) is the mirror, with these exact differences:

| | Right | Left |
|---|---|---|
| origin offset from ship top-left | `+0x0704` (column +7, row +4) | `+0x0004` (column +0, row +4) |
| column step | `+0x0100` | `−0x0100` |
| death bound | `head >= 0x9800` (column ≥ 152) | `head <= 0x0500` (column ≤ 5) |
| collision box origin | `headColumn − 6` | `headColumn` |
| erase sweep | `tail` up to `head` | `tail` down to `head` |

### 5.5 Speed, length, lifetime

| Quantity | Value | Derivation |
|---|---|---|
| head speed | **4 byte-columns = 8.000 px/frame = 480.77 px/s** | 4 × `LEAX $100,X` per tick |
| mid speed | 3 byte-columns = 6.000 px/frame | 3 × `LEAX $100,X` |
| tail speed | 1 byte-column = 2.000 px/frame | `INC PD+4,U` |
| growth | 3 byte-columns = 6 px per frame | head − tail |
| lifetime, right-facing from the rest home column | 152 − (32 + 7) = 113 columns ÷ 4 = **28.25 → 29 frames** | first frame that fails `head < 0x9800` |
| lifetime, left-facing from the rest home column | (112 − 5) ÷ 4 = 26.75 → **27 frames** | home column `$70` |
| concurrent cap | **4** | `LFLG` |

Laser speed is 8/6 = **1.333× the ship's terminal speed**. A laser fired forward at terminal speed
pulls away at only 2 px/frame.

### 5.6 Laser collision — `LCOL`

`defa7.src:2778–2790`; ROM `$E5AB`. Verified: the three bytes at **`$E5B6`** are `12 12 12`
(three `NOP`s), i.e. **the "3 wide" patch is NOT applied in Red Label** — the box is the full
`LASP1`.

> **Citation corrected in iteration 4 (F-09).** The previous text said "`$E5A8+3`", which is
> `$E5AB` = `34 46 86` (`PSHS A,B,U / LDA #$02`) — off by 11 bytes, and the stated verification did
> not reproduce. `rom_peek.py addr E58F 48` gives
> `$E5A8  7E D0 0A | 34 46 86 02 | 97 36 B7 D0 00 35 06 | 12 12 12 | CE F9 6F BD E6 BA`.
> The substantive conclusion was and is correct. Added to `tools/claims.tsv` as
> `check E5B6 "12 12 12"`.

```
LASP1 = { w: 8 byte-columns, h: 1 row }        // ROM $F96F = 08 01 → 16 px × 1 px
```

`LCOL` calls the shared `COLIDE` primitive (`MOVEMENT_PHYSICS_SPEC.md §7`) with
`U = LASP1` and `D = (column, row)` as computed in §5.4. `COLIDE` walks the **active object list
only** — not the shell list — so **player lasers cannot destroy enemy shells**. It returns
"hit" and kills the struck object via its `OCVECT`, which awards the score and starts the
explosion.

The laser's fine-grained bitmap for the intersection test is `LASP1`'s data at ROM `$F973`. Because
the AABB is 8 columns wide but the drawn head advances 4 columns per frame, the collision box
**overlaps the previous frame's box by 2–4 columns**, which is why fast-crossing enemies are not
tunnelled through. Do not "optimize" this to a point test.

### 5.7 Interaction with Reverse

`LFIRE` reads `NPLAD`, `PRDISP` commits `PLADIR ← NPLAD`. So:

- Reverse edge on frame *n* → `NPLAD` flips at B6 of *n+1*.
- A Fire edge on frame *n+1* runs at B6 of *n+2* and reads the already-flipped `NPLAD`. The laser
  goes the new way, correctly.
- Fire and Reverse edges on the *same* frame *n*: only Fire is serviced (§3.5). The Reverse is
  lost. The laser goes the **old** way. This is the authentic behavior and it must not be
  "fixed".
- Lasers already in flight are unaffected by Reverse. They are independent processes with their own
  captured direction.

### 5.8 Interaction with the world seam

None. The laser is screen-space, so the world seam does not exist for it. As the world scrolls
under it the laser stays at fixed framebuffer columns, which means a laser fired while the ship is
accelerating appears to drift backwards relative to the terrain. That is correct.

An enemy near the seam is still hit normally, because collision uses the enemy's *screen* position,
which is derived by 16-bit wrapping subtraction (`MOVEMENT_PHYSICS_SPEC.md §6.3`).

### 5.9 The fizz table `FISTAB`

32 bytes, regenerated by `FISS` (`defa7.src:2889–2901`) whenever `INIT20` runs — at power-up and at
**every player spawn**. Each byte is built from two bits of one `rand()` call:

```
FISS():
    fisx = 0
    for i in 0..31:
        a = rand()
        b = 0
        if (a & 1): b |= 0x01
        if (a & 2): b |= 0x10
        fistab[i] = b
```
32 `rand()` calls. Values are one of `$00, $01, $10, $11`. This is a **visual** table only —
it never affects collision — but it consumes RNG, so a replay-exact implementation must run it.

---

## 6. Smart Bomb

`SBOMB`, `defa7.src:3175–3212`; ROM `$E8BF`. Verified: `rom_peek.py addr E8C1 64`.

### 6.1 State

```js
sbflg : uint8          // 0 = ready, non-zero = in progress / debouncing
psbc  : uint8          // per-player smart-bomb inventory, player record offset 9
```

### 6.2 Inventory

| Event | Effect | Evidence |
|---|---|---|
| Game start | `psbc = NSHIP` (the CMOS "ships per game" setting; factory default 3) | `START`, `defa7.src:1124` — "loads `NSHIP` into **both** ships and smart bombs" |
| Replay threshold crossed | **+1 ship and +1 smart bomb together** | `SCRX`, `defa7.src:511` |
| Smart bomb used | `psbc -= 1` | `DEC PSBC,X` at `$E8CD` |
| Player death | **not reset** — inventory persists across lives | no write in `PLEND`/`PLSTRT` |
| HUD | displays `min(psbc, 3)` icons | `SBDSP`, `defa7.src:888–890` — `CMPA #3 / BLS / LDA #3` |

The HUD cap of 3 is display-only. The inventory itself is not clamped.

### 6.3 The algorithm

```
SBOMB:
    if sbflg != 0:      suicide()               // already running / debouncing
    if psbc == 0:       suicide()               // empty — no flash, no sound, no effect
    sbflg = 1
    psbc -= 1
    redrawSmartBombHUD()
    soundLoad(SBSND)                            // $E8 $06 $04 $11 $01 $10 $17 $00

SBMB00:                                         // the kill sweep, restarted after every kill
    for obj in activeObjectList:                // OPTR order
        if obj.objx == 0 and obj.objy == 0:     // LDD OBJX,X / BEQ — a SIXTEEN-BIT test
            continue                            // not on screen this frame
        if obj.otyp >= 0x02:   continue         // EXEMPT
        call obj.ocvect(obj)                    // kill it — scores, explodes, plays its sound
        goto SBMB00                             // restart from the head; the list just changed

SBMBX:                                          // screen flash
    repeat 4 times:
        pcram[0] ^= 0xFF                        // invert palette slot 0 (the background)
        sleep(2)

SBX10:                                          // release gate
    sleep(10)
    while (pia21 & 0x04) != 0: sleep(10)        // hold the flag while the button is still down
    sleep(10)
    sbflg = 0
    suicide()
```

### 6.4 Exactly which classes die

The test is `OTYP < 2`. `OTYP` values are defined at `phr6.src:494–499`:

| `OTYP` | Meaning | Smart-bombed? |
|---|---|---|
| `$00` | normal, hyperable — Lander, Mutant, Bomber, Pod, Swarmer, Baiter, **and enemy shells/mines that are on the object list** | **YES** |
| `$01` | Lander currently carrying a humanoid — non-hyperable | **YES** (and the humanoid is released to fall, via `LKIL1`) |
| `$02` | object mid-*appear* animation | **NO** |
| `$10` | humanoid (`AST`) | **NO** |
| `$11` | signpost | **NO** |

**Do not reduce this to "destroy everything on screen".** Three exemptions matter in play:

1. **Enemies still materialising are immune.** `APST` sets `OTYP = 2` for the duration of the
   47-frame appear animation. Smart-bombing the instant a wave spawns wastes the bomb.
2. **Humanoids survive**, standing or falling.
3. A Pod that is smart-bombed bursts into Swarmers by its own `PRBKIL` vector — and the Swarmers it
   creates are `OTYP = 2` for their appear animation, so **they survive the same bomb**. The
   restart-from-head loop does re-scan the list, but the new Swarmers are still in appear state on
   that pass.

### 6.5 Screen-limited, not world-limited

> **CORRECTED — `CLASSIC_MODE_CONTRACT` C-32.** The sentinel is the **16-bit `OBJX:OBJY` pair**,
> not `OBJX` alone. `OPROC` clears it with `CLRD / STD OBJX,X` (`defa7.src:2517`); `SBOMB` tests it
> with `LDD OBJX,X / BEQ` (ROM `$E8DC  EC 04  27 0C`, `defa7.src` `SBMB0`); `COLIDE` likewise
> (`defa7.src:2907`). Byte-column 0 is reachable — `col = ((ox16 − bgl) & 0xFFFF) >> 6`, so any
> `dx ∈ [0,63]` gives `OBJX = 0` on a fully on-screen object, whose `OBJY` is then its row
> (42..240), so `D ≠ 0`. Testing the byte alone gives every enemy a 2-px-wide immunity strip at
> framebuffer column 0, against both the smart bomb and collision. `CTL-57` guards it.

The `(OBJX, OBJY) == (0, 0)` test means "the object has no valid screen position this frame".
`OBJX`/`OBJY` are written only by `OPROC`'s world→screen pass, which requires
`((ox16 − bgl) & 0xFFFF) < 150*64 = 9600` (300 px) **and** `column + spriteWidth ≤ 0x9C`
(`defa7.src:2523–2540`). Objects on the **inactive** list (`IPTR`, i.e. more than ~100 px left or
~400 px right of the camera) are never visited at all.

So the smart bomb's area is: **the current 304-px framebuffer window, plus whatever hangs off the
left edge with `OBJX` still valid**. It is not "everything within N pixels of the player" and it is
not the whole world.

### 6.6 Projectiles are *not* cleared, with one exception

`SBOMB` walks `OPTR` (objects). It never touches `SPTR` (the shell list) and it never touches
`BMBCNT`. **Enemy shells in flight survive a smart bomb.** Only Hyperspace clears them (§7.3).

The exception is only apparent: mines laid by Bombers are created as *objects* with `OTYP = 0`
(`BOMBST`, `defb6.src:1136`), not as shells, so they do die.

### 6.7 Scoring

`JSR [OCVECT,X]` is the same vector a laser hit uses, and `PCFLG` is **0** during `SBOMB` (it is
set only inside `COLCHK`). Kills therefore score identically to laser kills: Lander 150, Mutant
150, Bomber 250, Pod 1000, Swarmer 150, Baiter 200, mine 25. `KILPOS`/`KILOS`
(`defb6.src:1171–1183`) read the score as an inline `FDB exponent:BCDmantissa` after the call site
and pass it to `SCORE` (`defa7.src:477`).

### 6.8 Timing and rate limit

| Segment | Frames | Seconds |
|---|---:|---|
| kill sweep + sound start | 0 (same pass) | 0 |
| screen flash: 4 × `sleep(2)` | 8 | 0.1331 |
| first release gate `sleep(10)` | 10 | 0.1664 |
| additional `sleep(10)` per frame-group the button stays down | 10 each | 0.1664 each |
| final `sleep(10)` before `sbflg = 0` | 10 | 0.1664 |
| **minimum `sbflg` hold (button tapped)** | **28** | **0.4659** |

So the maximum smart-bomb rate is one per 28 frames **plus** the ≥3-frame edge interval — in
practice one per 28 frames, ≈ 2.15/s, and the inventory runs out long before that matters.

### 6.9 Behavior during death and transitions

- The `SWTAB` reject mask `$F8` blocks the switch entirely when `STATUS` has bits 7,6,5,4 or 3 set —
  i.e. during death, hyperspace, attract and the wave transition. **You cannot spend a bomb you
  cannot use.**
- An **in-flight** `SBOMB` process is *not* killed by the mask. If the player dies during the flash,
  `GNCIDE` in `PDTH5` (`defa7.src:1384`) kills every process except the death process, so the
  `SBOMB` process dies with `SBFLG` still set — and `PLSTR5` clears `SBFLG` on respawn
  (`defa7.src:1256`). The bomb is spent; the enemies it already killed stay dead.
- `SBOMB` does not check `STATUS` inside its own loop, so a bomb that lands on the same frame as a
  fatal collision does complete its kill sweep. `COLCHK` (B2) runs before `DISP` (B6), so on that
  frame the death is registered first and the bomb still fires.

---

## 7. Hyperspace

`HYPER`, `defa7.src:3213–3271`; ROM `$E91F`. Verified byte for byte:
`rom_peek.py addr E921 120`.

### 7.1 Trigger conditions — two gates, both required

1. `SWTAB[3]` reject mask `$F8`: `STATUS & $F8` must be 0.
2. `HYPER`'s own first instruction: **`LDA STATUS / BITA #$FD / LBNE HYPX`**.
   `$FD = %11111101`, so the only bit permitted to be set is **bit 1** (terrain inactive).
   `STATUS` must be `$00` or `$02`.

Gate 2 is strictly stronger. Its practical consequences:

- **Bit 0 = "player start delay — no hyper".** `PLS01` sets `STATUS = $05` (or `$07`) and sleeps
  **96 frames** before `PLRES` starts the wave (`defa7.src:1305–1313`). **Hyperspace is dead for the
  first 96 frames of every life and every wave.** Fire, Reverse and Smart Bomb all work during that
  window — only Hyperspace is blocked. This is a real and testable Defender behavior.
- **Bit 2 = "appears/explosions disabled"** also blocks it, for the same 96 frames.
- Pressing Hyperspace while it is blocked is silently discarded — no sound, no inventory, nothing.

### 7.2 The sequence, frame by frame

`sleep(n)` means "run again `n` dispatch passes later". Passes are frames.

| Pass | Action |
|---:|---|
| **0** | `status = 0x77`; clear the screen (`SCLR1`); `sleep(15)` |
| **15** | kill every shell on `SPTR` (`KILSHL` in a loop); `bmbcnt = 0`; **teleport (§7.3)**; `bgi()` re-seeds the terrain ring buffer; `status = 0x50` (or `$52` if no humanoids remain); create the phony appear object; start the 47-frame appear animation (`APVCT`); `sleep(0x28 = 40)` |
| **55** | destroy the phony object (`KILOFF`); `status = 0x00` (or `$02`); **death roll (§7.4)** |

Total: **55 frames = 0.9152 s** of no control.

`STATUS = $77` = `%01110111`: game-over 0, controls-inactive **1**, objects/stars/shells **1**,
player-image-and-collision-inactive **1**, player-dead 0, appears/explosions-disabled **1**,
terrain-inactive **1**, start-delay **1**.

`STATUS = $50` = `%01010000`: controls still inactive, player still invulnerable, but objects,
shells, explosions and terrain are all back on. So the last 40 frames of hyperspace happen with the
world running around a frozen, untouchable ship.

### 7.3 The teleport — exact arithmetic

```
HYP02:
    for each shell on SPTR: killShell()          // ALL enemy shells destroyed
    bmbcnt = 0

    d = (seed << 8) | hseed                      // LDD SEED  -> A=SEED, B=HSEED
    bgl  = d
    bglx = d                                     // no terrain scroll this frame

    if (hseed & 1) != 0:                         // LSRB / BCC HYP0 -> carry = HSEED bit 0
          plax16 = 0x2000 ; nplad = +0x0300      // emerge FACING RIGHT at home column $20
    else:
          plax16 = 0x7000 ; nplad = -0x0300      // emerge FACING LEFT  at home column $70

    row = ((hseed >> 1) + 42) & 0xFF             // YMIN = 42 ; range 42..169
    play16 = (row << 8) | (play16 & 0xFF)        // LOW BYTE IS NOT CLEARED
    nplaxc = plax16 >> 8                         // $20 or $70
    nplayc = row

    plaxv  = 0                                   // all 24 bits
    playv  = 0
```

Note carefully:

- **Destination X is the RNG's `SEED:HSEED` pair, used raw as a 16-bit `BGL`.** The world is
  exactly 65536 `BGL` units wide, so every 16-bit value is a legal world position and no modulus is
  needed. The distribution is whatever `RAND`'s state pair happens to be — **it is not uniform** and
  it is not independent of the direction bit, which is drawn from the same `HSEED`.
- **Destination row is 42..169** — the upper two-thirds of the playfield. You never emerge in the
  bottom quarter, and you never emerge inside the terrain in the sense of "below the ground line",
  because the player has no terrain collision at all (`L-144`). **There is no terrain constraint
  and no terrain retry loop. Do not add one.**
- **Velocity is zeroed, all 24 bits including the fractional residue byte** (`STA PLAXV+2` at
  `$E967`). You always emerge at a dead stop.
- **`PLAY16`'s low byte survives.** The sub-pixel vertical residue from before the jump carries
  through. It is invisible but it is state, and a bit-exact replay must reproduce it.
- **`PLADIR` is not written** — only `NPLAD`. `PRDISP` is suppressed throughout (bit 4 of `STATUS`),
  so the commit `PLADIR ← NPLAD` happens on the first draw after `STATUS` clears at pass 55. See
  §7.6 for the one-frame consequence.

### 7.4 The death roll

```
HYP2:
    killPhonyObject()
    status = (astcnt != 0) ? 0x00 : 0x02
    if lseed > 192: goto PLEND          // LDA LSEED / CMPA #192 / LBHI PLEND
    suicide()
```

`LSEED` is a byte, so the fatal set is `193..255` = **63 of 256 = 24.609 %**, *if* `LSEED` were
uniform. It is not exactly uniform — `LSEED` is the low half of `RAND`'s 16-bit shift register — but
24.6 % is the right headline number and the exact outcome is fully determined by the seed.

Three things make this behaviorally distinctive and must be reproduced:

1. **The roll happens at the END, 55 frames after the button.** You watch yourself rematerialise and
   *then* explode. It is not decided when you press the key.
2. **The roll reads `LSEED`, not a fresh `rand()` return.** `LSEED` advances every time `rand()` is
   called — once unconditionally per frame at B4, plus every enemy-AI call. So the outcome depends
   on total RNG traffic during those 55 frames, which depends on how many enemies are alive.
3. **Death by hyperspace goes through `PLEND`** — the normal death path, with the normal explosion,
   the normal ship loss, and the normal wave-complete check.

### 7.5 Invulnerability and enemy interaction

`STATUS` bit 4 ("player image collision inactive") is set from pass 0 through pass 54 inclusive.
`COLCHK`'s first instruction is `LDA STATUS / BITA #$10 / BNE COLCX`, so the player is
**invulnerable for the whole 55 frames**, and becomes vulnerable again on the same pass as the
death roll.

Enemies are frozen for the first 15 passes (`STATUS` bit 5 disables `VELO` and `OPROC`) and run
normally for the last 40 — but they are running at the *new* world position, so what you emerge
into is whatever was already there, plus whatever the wave manager spawns.

### 7.6 The one-frame stale-facing artifact — reproduce it

At pass 55, `HYP2` clears `STATUS` during phase B. `PLAYER` runs at C2 of the *same* frame, before
`commitPlayerDraw()` at C5. So for exactly one frame:

- `PLADIR` still holds the **pre-hyperspace** facing.
- If Thrust is held, `±$0300` is added in the **old** direction — a `V16` change of ±3, i.e.
  0.094 px/frame. Negligible in play, but it is state.
- The camera target uses the **old** home column, so `PLAX16` slews one byte-column (2 px) toward
  the wrong side and `BGDELT` is set to ±`$40` for that frame. The following frame corrects it.

This is a consequence of the phase order, not a special case. If your implementation does not show
it, your phase order is wrong.

### 7.7 Sound

**`HYPER` issues no `SNDLD` call.** There is no hyperspace sound command in the game. The sound
engine's routine named `HYPER` at `$F9D4` is the *coin accepted* sound (`SOUND_EVENT_MATRIX.md`
row `$19`) and is unrelated. What the player hears during a jump is: whatever sequenced sound was
already running, then the thrust drone if `THFLG` is latched (§4.2), then the appear/explosion
sounds of whatever is nearby.

### 7.8 Revision notes

`HYPER` is behaviorally identical in White, Blue, Green and Red. The Green→Red delta in this region
is direct-page operand renumbering only (`L-149`, `REVISION_CODE_DIFF.md §3`). The
`CMPA #192` threshold, the `#$2000`/`#$7000` home columns, the `±$0300` direction constants and the
`ADDB #YMIN` row formula all occur exactly once per revision set and are byte-identical. Re-check:

```
rom_peek.py --set whitelabel find "D6 E0 54 CB 2A"      # LDB HSEED / LSRB / ADDB #42
rom_peek.py --set bluelabel  find "CC 20 00 8E 03 00"   # LDD #$2000 / LDX #$0300
rom_peek.py --set greenlabel find "81 C0 10 22"         # CMPA #192 / LBHI
```

---

## 8. Reverse — as its own system

`REV`, `defa7.src:3157–3172`; ROM `$E897`. Verified: `rom_peek.py addr E899 40`.

### 8.1 The whole routine

```
REV:
    if revflg != 0: suicide()          // debounce: a reverse is already in flight
    revflg = 1
    nplad = (-pladir) & 0xFFFF         // COMB / COMA / ADDD #1 — two's-complement negate
REV1:
    sleep(2, REV2)
REV2:
    if (pia21 & 0x40) != 0: goto REV1  // still held -> keep waiting, 2 frames at a time
    sleep(5, REVX1)
REVX1:
    revflg = 0
REVX:
    suicide()
```

Twenty-two bytes. That is the entire Reverse mechanic.

### 8.2 What changes, and what does not

| Quantity | Changes on Reverse? |
|---|---|
| `NPLAD` | **YES** — negated. `+$0300 ↔ −$0300`. |
| `PLADIR` | Not directly. Committed from `NPLAD` at the next player redraw (§2.4). |
| `PLAXV` (horizontal velocity, all 24 bits) | **NO. Untouched. The ship keeps every unit of momentum.** |
| `PLAYV` (vertical velocity) | **NO.** |
| `PLAX16` (screen X) | **NO** — but its *target* changes, so it begins slewing (§8.5). |
| `PLAY16` (row) | **NO.** |
| `BGL` (world scroll) | **NO** — only indirectly, through `BGDELT` during the slew. |
| `PLABX` (world X) | **NO.** The ship does not move in the world. |
| Ship sprite | `PLAPIC` ↔ `PLBPIC`, at the next redraw. |
| Thrust exhaust plume | Moves to the other side of the ship (`THOUT` ↔ `THOUT1`). |
| Direction of *new* lasers | **YES**, immediately — `LFIRE` reads `NPLAD`. |
| Direction of lasers already in flight | **NO.** |
| Scanner blip | Unchanged — the scanner draws world position, not facing. |
| Sound | **None.** `REV` issues no `SNDLD`. Reverse is silent. |

> **A "rotate the ship 180°" model is rejected.** There is no rotation state, no angle, no
> interpolation, and — decisively — **no change to velocity**. Reverse at full speed leaves you
> traveling at full speed in the direction you were already going, now pointing backwards. The
> only way to change direction is Thrust, and Thrust now opposes your motion.

### 8.3 The debounce, and rapid double-reverse

`REVFLG` is the lock. Its lifetime:

```
frame n     REV runs, revflg = 1, NPLAD flipped
frame n+2   REV2 polls the Reverse level
   ...      while the key is held, poll every 2 frames
frame r     first poll at which the level reads 0
frame r+5   revflg = 0 — a new Reverse is possible again
```

Consequences a Defender player will feel immediately:

- **You cannot double-reverse by mashing.** After a reverse, the earliest a second reverse can be
  *dispatched* is `r + 5` where `r` is the first even-numbered poll after release. Tap-and-release
  as fast as physically possible and the floor is 5–6 frames (83–100 ms) plus the 3-frame edge
  interval — call it **8 frames ≈ 133 ms** between reverses.
- **Holding Reverse down does nothing but extend the lock.** The flip already happened on frame *n*.
- A second `REV` process created while `REVFLG` is set does nothing at all — it suicides on its
  first instruction. The input is consumed and lost.
- **Reverse is not blocked by anything else.** There is no interaction with Fire, Thrust or Smart
  Bomb, other than the single-edge-per-frame rule of §3.5.

### 8.4 Reverse at terminal velocity, exactly

From `MOVEMENT_PHYSICS_SPEC.md §3`, with thrust held and `V16 = +192` at the moment the facing
commits:

| Event | Frames after commit | Note |
|---:|---|---|
| first frame of opposed thrust | 1 | `ΔV16 = −3 − 192/64 = −6`, twice the from-rest acceleration |
| velocity reaches 0 | **44** | 0.732 s |
| velocity reaches −192 (new terminal) | **415** | 6.906 s |

The ship therefore travels roughly 132 px *forwards* after the reverse before it stops. This
overshoot is Defender's signature and is the thing a fake most often gets wrong.

### 8.5 Camera lean during a reverse

The camera target snaps in one step from `home(old) + lean` to `home(new)`, but `PLAX16` is
rate-limited to **one byte-column (2 px) per frame** (`MOVEMENT_PHYSICS_SPEC.md §5`). The transition
from the right-facing home column `$20` to the left-facing home column `$70` is 80 byte-columns, so
the ship slides **80 frames = 1.331 s** across the screen. With the lean included the extreme case
is 80 + 24 = **104 frames = 1.730 s**.

While `PLAX16` is sliding, `BGDELT = ±$40` is subtracted from `BGL` so the ship's **world** position
does not move. The screen slide is purely a camera pan.

Note the interaction with §8.2: because `PCX`'s lean term is forced to 0 whenever the velocity sign
opposes the facing (`PV1A`), the moment you reverse at speed the lean collapses to 0 and the target
becomes the bare home column. The lean only reappears once the velocity has actually crossed zero
and picked up the new sign — 44 frames later, at terminal.

### 8.6 Reverse + Fire on the same tick

Covered by §3.5 and §5.7, restated here because it is an explicit acceptance test:

- Same **frame**: only Fire is serviced. The laser fires in the **old** direction. The Reverse edge
  is destroyed and the player must release and re-press.
- Fire on frame *n*, Reverse on frame *n+1*: both work; the laser goes the old way (it was created
  before the flip).
- Reverse on frame *n*, Fire on frame *n+1*: both work; the laser goes the **new** way, one frame
  before `PLADIR` itself has flipped.

---

## 9. The random number generator

`RAND`, `defa7.src:945–962`; ROM `$D715`. Verified: `rom_peek.py addr D710 32`.
This is the **only** source of nondeterminism in the core (`ARCHITECTURE.md §3`).

### 9.1 State

```js
seed  : uint8      // $A0DF
hseed : uint8      // $A0E0
lseed : uint8      // $A0E1
```

**Power-up initial value: `seed = 0x00`, `hseed = 0xA5`, `lseed = 0x5A`.**
`SINIT` executes `LDD #$A55A / STD HSEED` (`defa7.src:1006–1007`, ROM `$D9xx`); `SEED` is left at
zero by the RAM clear at `INIT10`. It is **not** re-seeded at game start, at wave start, or at
spawn. A replay is therefore `{startingRngState, inputSequence, tickCount}` and the default starting
state is `(0x00, 0xA5, 0x5A)`.

### 9.2 The algorithm

```
rand():
    b = (3 * seed) & 0xFF                    // LDA #3 / MUL — only the low byte survives
    b = (b + 17) & 0xFF                      // ADDB #17

    a  = ((lseed >> 3) ^ lseed) & 0xFF       // LSRA×3 / EORA LSEED
    c  = a & 1                               // LSRA  — carry out
    c2 = hseed & 1                           // carry out of ROR HSEED
    hseed = ((c  << 7) | (hseed >> 1)) & 0xFF
    lseed = ((c2 << 7) | (lseed >> 1)) & 0xFF

    t  = b + lseed                           // ADDB LSEED
    c3 = (t > 0xFF) ? 1 : 0
    b  = t & 0xFF
    b  = (b + hseed + c3) & 0xFF             // ADCB HSEED — uses the carry from the line above

    seed = b
    return seed                              // LDA SEED — the return value IS the new seed
```

The `MUL` produces a 16-bit product but the high byte is immediately clobbered by `LDA LSEED`, so
only `(3·seed) mod 256` participates. Do not carry the high byte.

### 9.3 Call sites that matter for control determinism

| Caller | Calls per invocation | When |
|---|---:|---|
| `EXEC` (B4) | **1** | every frame, unconditionally |
| `FISS` (laser fizz table) | **32** | at power-up and **at every player spawn** (`INIT20`) |
| `FBINIT` (fireball table) | **24** | same |
| `THINIT` (thrust flame table) | **33** | same |
| `STINIT` (starfield) | **variable** — 16 stars, each with two rejection loops | same |
| enemy AI, wave manager | many | per `SOURCE_MAP_ENEMIES.md` |

`STINIT` (`defa7.src:2073–2091`) rejects X ≥ `$9C` and rejects Y outside `(42, 168]`, looping until
accepted. The number of `rand()` calls it makes is data-dependent. **A replay-exact implementation
must run the rejection loops as written**, not sample-and-clamp.

`SBLNK` (`defa7.src:2159–2192`) *reads* `SEED`, `HSEED` and `LSEED` without advancing them.

---

## 10. `STATUS` — the master gating byte

`phr6.src:307–316`. One byte at `$A0BA`. Every control decision in this document consults it.

| Bit | Mask | Name | Set by |
|---:|---|---|---|
| 7 | `$80` | game over | `PLE2`, attract |
| 6 | `$40` | **player controls inactive** | `PLEND` (`$58`), `HYPER` (`$77`, `$50`), attract (`$7F`) |
| 5 | `$20` | stars, objects and shells frozen | `HYPER` (`$77`), `PLSTR0` (`$7F`) |
| 4 | `$10` | **player image + collision inactive** | `PLEND`, `HYPER` |
| 3 | `$08` | player dead | `COLCHK` on a fatal hit |
| 2 | `$04` | appears / explosions disabled | `PLS01` (`$05`) |
| 1 | `$02` | terrain inactive | `STCHK*` when `ASTCNT == 0`; `TERBLO` |
| 0 | `$01` | **player start delay — no hyperspace** | `PLS01` (`$05`) |

The canonical values, and what each one means for the player:

| `STATUS` | Where | Controls | Collision | Hyper allowed |
|---|---|---|---|---|
| `$00` / `$02` | normal play | yes | yes | **yes** |
| `$05` / `$07` | 96-frame start delay after every spawn and wave | **yes** | **yes** | **no** |
| `$77` | hyperspace, passes 0–14 | no | no | no |
| `$50` / `$52` | hyperspace, passes 15–54 | no | no | no |
| `$58` / `$5A` | death sequence | no | no | no |
| `$7F` | spawn setup, attract | no | no | no |
| `$FF` | game over | no | no | no |

`STCHK` / `STCHK0` / `STCHKA` (`defa7.src:1319–1325`) are the setters: they take a base value in
`B`, OR in bit 1 if `ASTCNT == 0` (no humanoids left → terrain inactive), and store.

---

## 11. Death, respawn and transitions

Only the control-relevant slice; the full death sequence belongs to the presentation and
wave subsystems.

### 11.1 Death

`PLEND` (`defa7.src:1328–1340`) runs as a fresh process created by `COLCHK`:

```
PLEND:
    status = 0x58 | (astcnt == 0 ? 0x02 : 0)      // controls off, collision off, dead
    bglx = bgl                                     // terrain scroll stops dead this frame
    eraseSprite(8 columns × 6 rows at plaxc:playc)
    savePlayerState()
    soundLoad(PDSND)
```

- **Velocity is frozen, not decayed.** `PLAYER`'s first instruction is
  `LDA STATUS / BITA #$40 / LBNE PLAYX`, so drag does not run either. `PLAXV` keeps whatever it held
  at the instant of death, all the way through the explosion, until `PLSTR5` zeroes it.
- **Lasers in flight die on their next tick**, because `LASR0`/`LASL0` test `STATUS & $40` first.
  They erase themselves cleanly and decrement `LFLG`.
- **A `REV` or `SBOMB` process in flight survives** until `GNCIDE` runs at `PDTH5`. Their debounce
  flags are cleared by `PLSTR5`.

`PDEATH` then runs the 8-step glow ramp (`PXCTB = 7,7,7,$F,$3F,$7F,$FF,$FF,0`) at 4 frames per
step = **32 frames**, a 2-frame white flash, then `GNCIDE`, the 128-particle explosion (`PXVCT`,
`blk71.src:564–670`, `PXCOL` = 112 frames) and the screen transition.

### 11.2 Respawn — `PLSTR5`, the exact initial state

`defa7.src:1241–1276`; ROM `$D9xx`. Verified constants: `rom_peek.py check D980 "CC 03 00 DD BD"`.

```
bgl = 0 ; bglx = 0
regenerateTerrain()                       // ALINIT, BGINIT
clearScreen()
pladir = 0x0300 ; nplad = 0x0300          // FACING RIGHT, always
thflg = 0 ; lflg = 0 ; scrflg = 0 ; revflg = 0 ; sbflg = 0 ; bmbcnt = 0
tptr = &TLIST
plas -= 1                                 // spend a life
nplaxc = 0x20 ; nplayc = 0x80             // STD NPLAXC with D = $2080
plaxc  = 0x20 ; playc  = 0x80
plax16 = 0x2000                           // screen column 32 = pixel 64
plabx  = (0x2000 >> 2) + bgl = 0x0800     // world X = 0x0800 units = 64 px
play16 = 0x8000                           // row 128, exact centre
plaxv  = 0                                // all 24 bits, including the fractional residue
playv  = 0
```

`INIT20` runs just before this and re-seeds `FISTAB`, the starfield, the fireball table and the
thrust table — 89 `rand()` calls plus the starfield's variable count (§9.3).

Then:

```
PLS01: status = 0x05 | (astcnt == 0 ? 0x02 : 0)   // controls ON, hyperspace OFF
       sleep(0x60 = 96)
PLS1:  restoreWave(); status = 0x00 | (astcnt == 0 ? 0x02 : 0)
       goto GEXEC
```

**The player has full control — thrust, fire, reverse, smart bomb — for 96 frames (1.597 s) before
the wave starts, and cannot hyperspace during them.**

### 11.3 Attract mode

`STATUS = $FF` or `$7F`. Controls inactive, so `PLAYER` returns immediately and every switch is
rejected by its mask. Only Start 1, Start 2 and the coin switches (mask `$00`) get through.

---

## 12. Acceptance tests

Deterministic and executable. Each test names the exact initial state, the exact input sequence, and
the exact assertion. `step()` is one frame. Input is given as the raw PIA2/PIA3 bitfields for that
frame; `.` means all zero. These become the Phase 3 suite verbatim.

Shared fixture unless a test says otherwise:

```
FIXTURE "fresh life"
  rng = (0x00, 0xA5, 0x5A)
  after PLSTR5 + the 96-frame start delay has elapsed:
  status=0x00  pladir=nplad=0x0300  plaxv=0  playv=0
  plax16=0x2000  play16=0x8000  bgl=0  bglx=0  bgdelt=0
  plaxc=nplaxc=0x20  playc=nplayc=0x80  plabx=0x0800
  lflg=0 revflg=0 sbflg=0 psbc=3  pia21=0 pia22=0 pia31=0
  no objects, no shells
```

### Input model

**CS-01 — thrust is level-triggered, zero latency.**
From fixture. Frame 1 input `THRUST`. After `step()` once: `plaxv >>> 8 === 3` (i.e. `V16 = 3`).
*(Drag runs first on `V16 = 0` and contributes nothing, then thrust adds `$0300` at the 24-bit LSB.)*

**CS-02 — thrust with no edge history still works.**
From fixture with `pia21 = 0x02, pia22 = 0x02` (button already held from before). Frame 1 input
`THRUST`. After one `step()`: `V16 === 3`. Assert also `swproc[0].addr === 0` — no edge, no
dispatch.

**CS-03 — Fire is edge-triggered on two zeros.**
From fixture. Inputs: `FIRE, FIRE, FIRE, FIRE, FIRE` for 5 frames.
After 5 `step()`s: exactly **one** laser process exists and `lflg === 1`.

**CS-04 — minimum fire period is 3 frames.**
From fixture. Inputs: `FIRE, ., ., FIRE, ., ., FIRE` (7 frames).
After 7 `step()`s: `lflg === 3`. Now run `FIRE, ., FIRE` (3 more frames): `lflg` is still `3` — the
second press had only one zero sample behind it.

**CS-05 — one edge per frame, lowest bit wins, the loser is destroyed.**
From fixture. Frame 1 input `FIRE|REVERSE`. Frames 2–10 input `.`.
After 10 `step()`s: `lflg === 1` **and** `nplad === 0x0300` (unchanged) **and** `revflg === 0`.
The Reverse never happened and was never queued.

**CS-06 — Thrust's null table entry steals the dispatch from Reverse.**
From fixture. Frame 1 input `THRUST|REVERSE`. Frames 2–10 input `.`.
After 10 `step()`s: `nplad === 0x0300` (Reverse lost) and `V16 === 3` on frame 1 (thrust still
applied). Assert `revflg === 0`.

**CS-07 — Reverse survives when it is the lowest edge present.**
From fixture. Frame 1 input `REVERSE|DOWN` (bit 6 + bit 7). Frames 2–10 `.`.
After 2 `step()`s: `nplad === 0xFD00` (= `−0x0300`). After 3 `step()`s: `pladir === 0xFD00`.

**CS-08 — Up beats Down.**
From fixture. Frame 1 input `UP|DOWN` (PIA3 bit 0 and PIA2 bit 7).
After one `step()`: `playv === 0xFF00` (`−0x0100`, upward kick), not `+0x0100`.

### Reverse

**CS-09 — Reverse writes exactly one variable.**
From fixture, first accelerate: 200 frames of `THRUST`. Snapshot every field. Then
`REVERSE` for 1 frame, then `.` for 1 frame.
Assert after the `REV` process has run: `nplad` negated; and `plaxv`, `playv`, `play16`, `plabx`,
`bgl` are **bit-identical to the snapshot** except for the changes the two intervening `PLAYER`
calls would have made with the *old* `pladir`. Concretely: run the same 2 frames without the
Reverse press and diff — only `nplad` may differ.

**CS-10 — Reverse at rest.**
From fixture. Frame 1 `REVERSE`, then `.` ×200.
After frame 3: `pladir === 0xFD00`. `plaxv === 0` throughout. `plax16` slews from `0x2000` toward
`0x7000` at exactly `0x0100` per frame; at frame 3+80 = 83, `plax16 === 0x7000` and
`bgdelt === 0`. `plabx === 0x0800` at every frame — the world position never moved.

**CS-11 — Reverse at terminal velocity: velocity is preserved and the ship overshoots.**
From fixture. 400 frames of `THRUST` (reaches `V16 = 192` at frame 371; assert
`plaxv === 0x00C000` at frame 400). Then release for 3 frames (`.`) — Reverse must not be pressed on
the same frame as Thrust, because Thrust's null table entry would steal the dispatch (CS-06). Then
1 frame of `REVERSE`, then hold `THRUST` continuously.
Assert `V16 === 183` on the frame the `REVERSE` key goes down (three frames of drag only, no thrust).
Let `c` be the frame at which `pladir` becomes `0xFD00`. Assert `V16` is still positive at `c`, first
reaches ≤ 0 at `c + 43`, and first reaches −192 at `c + 414`.

**CS-11b — the overshoot distance, from exact terminal.**
Set `plaxv = 0x00C000` and `pladir = nplad = 0xFD00` directly (bypassing the input path), then hold
`THRUST`. Assert:
- `V16` reaches exactly 0 at frame **44**.
- the sum of `V16` over frames 1..44 is exactly **3631 world units = 113.469 px** of *forward*
  travel after the reverse.
- `V16` reaches exactly −192 at frame **415**, with `plaxv === 0xFF40FC`.

**CS-12 — rapid double-reverse is refused.**
From fixture. Inputs: `REVERSE`, `.`, `.`, `REVERSE`, then `.` ×200.
After 200 `step()`s: `pladir === 0xFD00` — the ship reversed **once**. Assert the second `REV`
process suicided: at no point does `nplad` return to `0x0300`.

**CS-13 — the reverse lock releases 5 frames after the button is released.**
From fixture. Hold `REVERSE` for 20 frames, then release. Assert `revflg === 1` throughout the hold,
and `revflg === 0` no earlier than 5 frames and no later than 7 frames after the first frame with
the key up (the `sleep(2)` poll granularity gives the 2-frame window). Then a `REVERSE` edge is
accepted again.

**CS-14 — holding Reverse does not repeat.**
From fixture. Hold `REVERSE` for 300 frames. `pladir` flips exactly once and stays `0xFD00`.

### Laser

**CS-15 — laser origin, right-facing.**
From fixture. Frame 1 `FIRE`, frame 2 `.`.
After the `LFIRE` dispatch (frame 2), the new laser's `head`, `mid` and `tail` all equal
`(0x20 << 8 | 0x80) + 0x0704 = 0x2784`. Then after that same frame's first `LASR0` tick:
`head === 0x2B84`, `mid === 0x2A84`, `tail === 0x2884`.

**CS-16 — laser origin, left-facing.**
From fixture, reverse first and let `plax16` settle at `0x7000` (`nplaxc = 0x70`). Then `FIRE`.
Laser initial pointers `= (0x70 << 8 | 0x80) + 0x0004 = 0x7084`. After one tick:
`head === 0x6C84`, `mid === 0x6D84`, `tail === 0x6F84`.

**CS-17 — laser head speed is exactly 8 px per frame.**
Continue CS-15. After `N` ticks, assert `head === 0x2784 + N * 0x0400` for `N = 1..20`. Column
advance is 4 per tick = 8 px per tick = 8.000 px/frame.

**CS-18 — laser dies at the right framebuffer edge, on tick 30.**
Continue CS-15. `LASR0`'s bound test reads the head *before* advancing it, so tick `n` tests
`0x2784 + (n−1) * 0x0400`. `0x2784 + 28 * 0x0400 = 0x9784 < 0x9800` passes; `0x2784 + 29 * 0x0400 =
0x9B84 ≥ 0x9800` fails. Assert the laser survives ticks 1–29, is destroyed on tick **30**, and that
`lflg === 0` on that frame. Assert every framebuffer byte from the final `tail` up to the final
`head` inclusive is 0 afterwards.

**CS-19 — four concurrent lasers is the cap.**
From fixture. Inputs `FIRE,.,.` repeated 6 times (18 frames).
After 18 `step()`s: `lflg === 4`, and exactly 4 laser processes exist. The 5th and 6th `LFIRE`
dispatches produced no laser and **no `LASSND`** (assert the sound event stream contains exactly 4
`LASSND` loads).

**CS-20 — the cap releases when a laser expires.**
Continue CS-19 with `.` until `lflg` drops to 3, then `FIRE`. A 5th laser is created.

**CS-21 — Reverse then Fire: the laser goes the new way.**
From fixture. Frame 1 `REVERSE`, frame 2 `.`, frame 3 `FIRE`, frame 4 `.`.
Assert the laser created on frame 4 is a **left** laser (its `head` decreases), even though on
frame 3 `pladir` was still `0x0300` for part of the frame.

**CS-22 — Fire and Reverse on the same tick: the laser goes the old way.**
From fixture. Frame 1 `FIRE|REVERSE`, then `.` ×20.
Assert the laser is a **right** laser and `pladir === 0x0300` for all 20 frames.

**CS-23 — lasers do not scroll with the world.**
From fixture. 200 frames of `THRUST` to build speed, then `FIRE`, then continue `THRUST`.
Assert the laser's `head` advances by exactly `0x0400` per frame regardless of `bgl` changing,
and that `bgl` is not read anywhere in the laser update.

**CS-24 — lasers die when the player dies.**
From fixture with one enemy placed to collide with the player 5 frames from now. Fire a laser at
frame 1. On the frame `COLCHK` sets `status |= 0x08` and `PLEND` sets `status = 0x58`, assert on the
following frame that `lflg === 0` and the laser process no longer exists.

**CS-25 — lasers do not hit shells.**
Place one enemy shell on the shell list directly in the laser's path. Fire. Assert the laser passes
through and the shell survives; assert the laser still dies at the framebuffer edge.

### Smart bomb

**CS-26 — inventory gate.**
From fixture with `psbc = 0`. Frame 1 `SMARTBOMB`, then `.` ×40.
Assert: `psbc === 0`, `sbflg === 0` throughout, no `SBSND` in the sound stream, no screen flash, and
no object was killed.

**CS-27 — exempt classes survive.**
From fixture. Place four objects on the active list with valid `objx`: `otyp = 0x00` (Lander),
`otyp = 0x01` (Lander carrying), `otyp = 0x02` (appearing Swarmer), `otyp = 0x10` (humanoid).
Frame 1 `SMARTBOMB`, then `.` ×40.
Assert the `0x00` and `0x01` objects are dead, the `0x02` and `0x10` objects are alive, `psbc === 2`,
and the humanoid released by the `0x01` kill is now falling.

**CS-28 — off-screen objects survive.**
From fixture. Place a Lander with `ox16 = bgl + 150*64` (exactly at the 300-px cut-off) so that
`objx === 0`. Smart-bomb. Assert it survives.

**CS-29 — enemy shells survive a smart bomb.**
From fixture. Place 3 shells on `SPTR` and 1 Lander on `OPTR`. Smart-bomb.
Assert `bmbcnt === 3` and all three shells still exist; the Lander is dead.

**CS-30 — smart bomb scoring equals laser scoring.**
Run CS-27 and assert the score delta is exactly 150 + 150 = 300.

**CS-31 — smart bomb rate limit.**
From fixture with `psbc = 3`. Inputs: `SMARTBOMB, ., .` repeated 20 times (60 frames).
Assert `psbc === 3 − 2 = 1` at frame 60, i.e. exactly two bombs fired, the second no earlier than
frame 28 + 1.

**CS-32 — smart bomb is blocked during hyperspace.**
From fixture. Frame 1 `HYPERSPACE`, then `SMARTBOMB` on frame 10, then `.`.
Assert `psbc` is unchanged at frame 60.

### Hyperspace

**CS-33 — hyperspace is blocked for 96 frames after spawn.**
Start from `PLSTR5` (not the fixture). At frame 1 `status === 0x05`. Press `HYPERSPACE` on frames
1, 20, 50, 90. Assert `status` is never `0x77` and the player never moves. Press again at frame 100
(`status === 0x00`) — assert `status === 0x77` two frames later.

**CS-34 — the 55-frame sequence.**
From fixture with `rng = (0x00, 0xA5, 0x5A)`. Frame 1 `HYPERSPACE`, then `.`.
Let `h` be the frame `HYPER` starts (= 2). Assert:
- frames `h`..`h+14`: `status === 0x77`, `plaxv === 0`? **no** — `plaxv` retains its pre-jump value
  until `h+15`. Assert `plaxv` is unchanged over `h`..`h+14`.
- frame `h+15`: `plaxv === 0`, `playv === 0`, `bgl === bglx === (seed << 8 | hseed)` for the RNG
  state at that instant, `status === 0x50` (or `0x52`).
- frames `h+15`..`h+54`: `status === 0x50`.
- frame `h+55`: `status === 0x00` (or `0x02`).

**CS-35 — the destination is derived from the RNG, not uniform.**
Run CS-34 with `rng` forced to `(0x12, 0x34, 0x56)` immediately before `HYP02` executes.
Assert exactly: `bgl === 0x1234`; `hseed & 1 === 0` so `plax16 === 0x7000` and `nplad === 0xFD00`;
`nplayc === (0x34 >> 1) + 42 === 26 + 42 === 68`.

**CS-36 — the destination row is always 42..169.**
Sweep `hseed` over all 256 values, run the `HYP02` computation, assert
`42 <= nplayc <= 169` for every one, and assert the set of produced rows is exactly
`{42, 43, …, 169}`.

**CS-37 — velocity is zeroed including the fractional byte.**
From fixture. 400 frames of `THRUST` to reach terminal (`plaxv === 0x00C000`). Release, hyperspace.
At `h+15` assert `plaxv === 0x000000` — all three bytes, not just the top 16 bits.

**CS-38 — `play16`'s low byte survives.**
Set `play16 = 0x80A7` before the jump. At `h+15` assert `(play16 & 0xFF) === 0xA7`.

**CS-39 — the death roll fires at the end.**
Force `lseed = 200` on the frame `HYP2` runs. Assert `status` transitions to `0x58` (via `PLEND`) on
that same frame, i.e. the ship rematerialises and *then* dies.
Force `lseed = 192`. Assert the player survives (`192 > 192` is false).
Force `lseed = 193`. Assert the player dies.

**CS-40 — hyperspace clears all enemy shells but not objects.**
From fixture. Place 5 shells and 3 Landers. Hyperspace. At `h+15` assert `bmbcnt === 0` and zero
shells exist; assert all 3 Landers still exist.

**CS-41 — invulnerability window.**
From fixture. Place a Lander exactly on top of the player. Hyperspace on frame 1.
Assert `COLCHK` reports no collision on frames `h`..`h+54` and that the player survives to `h+55`.

**CS-42 — the one-frame stale-facing artifact.**
From fixture facing right, `THRUST` held continuously. Hyperspace; force `hseed` even so the ship
emerges facing **left** (`nplad = 0xFD00`). At frame `h+55` assert `pladir === 0x0300` still, and
that `V16` moved by `+3 − 0` (thrust applied in the old direction). At frame `h+56` assert
`pladir === 0xFD00`.

**CS-43 — no hyperspace sound.**
Run CS-34 and assert the sound event stream contains no `SNDLD` call attributable to `HYPER`.

### RNG

**CS-44 — the generator's first ten outputs.**
From the power-up state `(seed, hseed, lseed) = (0x00, 0xA5, 0x5A)`, ten calls to `rand()` return
exactly:

```
90 81 74 DC 5C 80 BE E1 7F 73
```

and leave `(seed, hseed, lseed) = (0x73, 0x7C, 0x69)`.
After 32 calls (one `FISS` pass) the state is `(0xFE, 0x63, 0xCF)`.
These are the anchor literals for every seeded test above. If they do not reproduce, nothing else
in the seeded tests is meaningful.

**CS-45 — one unconditional call per frame.**
From fixture with no objects and no input, `step()` 100 times. Assert `rand()` was called exactly
100 times.

**CS-46 — `FISS` consumes exactly 32.**
Call `FISS()` from a known state and assert the RNG advanced by exactly 32 calls and that every
`fistab[i] ∈ {0x00, 0x01, 0x10, 0x11}`.

### State gating

**CS-47 — controls frozen during death, velocity not decayed.**
From fixture. 400 frames of `THRUST` (terminal). Kill the player. Assert `plaxv` is **bit-identical**
for 30 consecutive frames after `status` becomes `0x58` — drag did not run.

**CS-48 — terrain scroll stops on death.**
Same setup. Assert `bgl === bglx` from the frame `PLEND` runs until respawn.

**CS-49 — respawn resets exactly the listed fields.**
Trigger a death from a state where every field is non-default. After `PLSTR5`, assert every field in
§11.2 has exactly the stated value, and assert `psbc` is **unchanged** (smart bombs persist).

**CS-50 — attract mode ignores gameplay switches.**
Set `status = 0xFF`. Feed 100 frames of `FIRE|THRUST|SMARTBOMB|HYPERSPACE|REVERSE`. Assert nothing
changes: `plaxv === 0`, `lflg === 0`, `nplad` unchanged, `psbc` unchanged.

### Adversarial — "the fastest 30 seconds that exposes a fake"

**CS-51 — the reverse-overshoot signature.**
Hold `THRUST` 400 frames. Release 3. `REVERSE` 1. Hold `THRUST` 500.
Assert `plabx` continues to *increase* on every frame for 43 frames after the facing commits, and
that `V16` stays positive throughout those 43 frames. Then run the clean version, CS-11b, and assert
the exact overshoot of **3631 world units = 113.469 px** from exact terminal. An implementation that
snaps or zeroes velocity on Reverse fails on the very first frame.

**CS-52 — the lost-reverse tell.**
Alternate `FIRE|REVERSE` and `.` for 60 frames. Assert `pladir === 0x0300` for all 60 frames and
`lflg` reached 4. Any implementation that queues the lost edge will flip the ship.

**CS-53 — the fire-rate ceiling.**
Feed `FIRE` on every frame for 600 frames. Assert exactly **one** laser was ever created.

**CS-54 — the hyperspace-during-start-delay tell.**
Press `HYPERSPACE` on the very first frame of every new life for 3 lives. Assert it never fires.

**CS-55 — the smart-bomb-on-spawn tell.**
On the first frame after a wave starts, place three enemies still in their 47-frame appear
animation. Smart-bomb. Assert all three survive and `psbc` decreased by 1.

**CS-56 — camera-lean collapse on reverse.**
Hold `THRUST` to terminal. Reverse. Assert `pcx` becomes exactly 0 on the first frame after the
facing commits (velocity sign now opposes facing → `PV1A`), and that `plax16` immediately begins
slewing toward the bare home column `0x7000`, not toward `0x7000 − lean`.

### Coverage of the governing prompt's enumerated tests

`md/02_GAMEPLAY_CONTROLS_INTERACTIONS.md §Acceptance Tests` names eighteen. Where each one lands:

| Required test | Owner |
|---|---|
| thrust from rest | **MP-01, MP-02, MP-04**, CS-01 |
| release thrust | **MP-05**, MP-08 |
| vertical travel | **MP-10 … MP-19** |
| reverse at low speed | **CS-10** |
| reverse at maximum speed | **CS-11, CS-11b**, CS-51, MP-24 |
| reverse + fire same tick | **CS-22**, CS-05 |
| rapid reverse | **CS-12, CS-13, CS-14** |
| sustained fire | **CS-03, CS-04, CS-53** |
| maximum simultaneous lasers | **CS-19, CS-20** |
| smart bomb edge cases | **CS-26 … CS-32, CS-55** |
| hyperspace seeded outcomes | **CS-33 … CS-43**, CS-44 |
| world seam crossing | **MP-27 … MP-32, MP-35 … MP-38, MP-47** |
| catch falling humanoid | `HUMANOID_RESCUE_SPEC.md`; depends on **MP-44, MP-45** (the `PCFLG` walk-abort primitive) and on §7.4 of `MOVEMENT_PHYSICS_SPEC.md` |
| fail to catch humanoid | `HUMANOID_RESCUE_SPEC.md`; same dependency |
| carry humanoid across seam | `HUMANOID_RESCUE_SPEC.md`; depends on **MP-32** |
| deposit humanoid | `HUMANOID_RESCUE_SPEC.md` |
| death while carrying | `HUMANOID_RESCUE_SPEC.md`; depends on **CS-47, CS-49** (what death freezes and what respawn resets) |
| all humanoids lost | `HUMANOID_RESCUE_SPEC.md`; depends on §10 here (`ASTCNT == 0` ⇒ `STATUS` bit 1 ⇒ terrain inactive) |

The five delegated rows are the humanoid state machine, which is a sibling agent's subsystem. This
document owns and specifies every primitive they rest on: the collision walk and its humanoid
exception, the seam arithmetic, the `STATUS` gating, and the death/respawn field list.

---

## 13. Known deviations and open items

### 13.1 Deliberate modeling decisions (not deviations from behavior)

| Decision | Justification | Risk if wrong |
|---|---|---|
| One `commitPlayerDraw()` per step at C5, instead of the original's two raster-band call sites | Proved equivalent in §2.4 for both `PLADIR` and `PLAXC` | None found. If a future renderer needs per-band commit for a visual reason, it must not move the state write. |
| ~~One object screen-position pass per step instead of two banded passes~~ | **WITHDRAWN — `CLASSIC_MODE_CONTRACT` C-25.** The premise ("processed once per frame either way") is true; the conclusion is not. `OPROC` recomputes screen position from live `ox16`/`oy16`/`bgl`, and the two calls straddle `VELO` (A7) and `PLAYER`'s `BGL` advance (C2), so *which* pass draws an object determines whether its `OBJX` — and therefore next frame's `COLCHK` box — is a frame stale. Bands are complementary (C-24): A = rows 121..255 at A6, C = rows 1..120 at C4. | **Deleted in iteration 4.** Implement both passes. `INT-33`. |
| The overload governor is implemented but the *scheduler* never raises `OVCNT` | The browser core cannot overrun | **Amended in iteration 4 — `CLASSIC_MODE_CONTRACT` C-26.** It is not dead code: `TERBLO` forces `OVCNT = 8` thirty-two times per planet destruction (ROM `$EE3D`, the only non-`EXEC` writer in the set), which cuts `strcnt` to 3 and culls one object per `EXEC` pass. Implement the whole routine, including the 16-bit relocation and the `OFSHIT` erase (C-31). |

### 13.2 Open items

| Item | Interim behavior for the builder | What would settle it |
|---|---|---|
| **`STINIT`'s `rand()` call count is data-dependent.** Reproducing it exactly is required for replay determinism across a respawn, and no one has counted it for the default seed. | Implement `STINIT` exactly as written, with both rejection loops, and *measure* the call count in a test rather than asserting a literal. | Trace `$E0xx` in MAME from `SINIT` and record the count for the known initial seed. Then freeze it. |
| ~~**`XXX1`/`XXX2` band boundaries** not pinned down~~ | **CLOSED in iteration 4 — `CLASSIC_MODE_CONTRACT` C-24 / `RENDERING_SPEC` §5.3.** `XXX1`, `XXX2`, `XXX3` are three adjacent **bytes** (`phr6.src:285-287`); `LDD #$FF70 / STD <XXX1` seeds `XXX1 = $FF` and `XXX2 = $70`, and `CLR <XXX3` sets `XXX3 = 0`. `XXX2` is recomputed every frame as `min(VERTCT−8, $A8)` = 120 here. Both band predicates are half-open, so the passes tile rows 1..255 with **no gap and no overlap**. The renderer may **not** draw everything in one pass (C-25). | Settled against ROM `$DF3C`, `$DF4E`, `$DF9F`, `$D78C`. |
| **`LSEED`'s distribution.** The 24.6 % hyperspace death figure assumes `LSEED` is uniform over 0–255 at the sampling instant. It is an LFSR half, so it is not exactly uniform, and the sample point is correlated with frame count. | Use the algorithm, not the probability. Tests assert exact outcomes for exact seeds (CS-39), never a rate. | A 10⁶-jump Monte Carlo over the real generator, reported as a measured rate. Worth doing for the lab overlay, not for correctness. |
| **Whether `PLSTR4`'s `PIA21 = PIA22 = $FF` path can ever be reached in the reconstruction.** It is the cocktail-cabinet mux path (`LDA PIA3 / BPL PLSTR5`). | Model the upright cabinet: PIA3 bit 7 reads 0, the branch is always taken, the shadows are never forced. | Only matters if Classic Mode ever offers a cocktail preservation mode. |
| **`MOVEMENT_ENVELOPE.md §9` proposes ledger ids `L-147`, `L-148`, `L-149` that are already taken** by unrelated Blue/Green/White revision claims in `EVIDENCE_LEDGER.md`. | Cite ROM addresses directly, as this document does. Do not cite `L-147/148/149` for frame clock, world width or movement-revision-identity. | The ledger owner allocates three fresh ids. |
| **`MOVEMENT_ENVELOPE.md §2.3` states "99 % of terminal (`V16 ≥ 190`) = 307 frames".** Re-simulated here: `V16 ≥ 190` first occurs at frame **276**; frame 307 is where `V16 ≥ 191`. | Use the corrected table in `MOVEMENT_PHYSICS_SPEC.md §3.4`. | Already settled — see that table and the simulation in §3.4. |
| **`SBOMB` interaction with a Pod bursting into Swarmers.** §6.4 asserts the new Swarmers survive because they are `OTYP = 2` during their appear animation. This follows from `APST` setting `OTYP = 2`, but the exact instant `PRBKIL` creates them relative to the `SBMB00` restart has not been traced. | Implement as specified (Swarmers survive). Add CS-55 as a regression anchor. | Trace `PRBKIL` → `MMSW` → `APST` and confirm `OTYP` is 2 before control returns to `SBMB00`. |
