# RENDERING SPEC — Classic Mode, implementable contract

**This is a contract, not research.** A Phase 3 builder implements from this document alone.
Every number below is exact, at its original scale, with its provenance. Nothing here says
"approximately". Where something could not be pinned down it is in §14, named, with an interim
rule.

Companion documents: `VISUAL_ASSET_FORENSICS.md` (what the assets *are*),
`DISPLAY_GEOMETRY.md` (the raster and the tube), `TERRAIN_PROFILE.md` (the planet),
`docs/design/ARCHITECTURE.md` (the runtime contract this targets — **binding**).

Exported data this spec assumes is present: `assets/original-derived/`, produced by
`tools/export_assets.py`.

---

## 0. The five decisions this document makes

1. **The core owns the framebuffer.** Drawing is simulation, not presentation, because the act
   of drawing writes `OBJX` and `OBJX` gates both collision and smart-bomb eligibility
   (L-047, L-052). `step()` returns a framebuffer.
2. **The Classic renderer is a pure function** `(framebuffer, cram) → RGBA`. It has no state
   that survives a frame, cannot write to the core, and cannot change what the next frame looks
   like. Presentation options are applied strictly downstream of it.
3. **One `step()` = one 16.640 ms original frame**, containing two draw passes and one executive
   pass, in the fixed order of §5. No delta-time anywhere.
4. **`XXX2`, the beam-race band boundary, is fixed at 120.** On real hardware it tracks the beam
   and varies with CPU load; §5.3 proves the variation cannot change which objects get drawn.
5. **Presentation is 292 × 240 logical pixels in an exactly 4:3 box, never stretched to fill,
   never cropped**, with the two-stage scaling chain of §3.

---

## 1. STATE

Integer-only. Every field is given with its width, its scale and its initial value. `Number` is
used but every value is masked back to width after every operation, exactly as the 6809 does.

### 1.1 The framebuffer

```js
// The one piece of state the renderer reads.
const FB_COLS      = 156;   // byte-columns the software touches ($0000-$9BFF)
const FB_SCANNED   = 152;   // byte-columns the video hardware addresses ($0000-$97FF)
const FB_H         = 256;   // scanlines
const FB_W         = 304;   // = FB_SCANNED * 2, addressable pixels

const VIS_X = 12, VIS_Y = 7, VIS_W = 292, VIS_H = 240;   // the scanned-out window

/**
 * Column-major, 2 pixels per byte, HIGH NIBBLE = LEFT (even-x) pixel.
 *   byteAddress = (x >> 1) * 256 + y      == col * 256 + row
 * Allocate 156*256 = 39936 so the software region is addressable; only columns 0..151 are
 * ever read by the renderer.
 */
class Framebuffer {
  constructor() { this.b = new Uint8Array(FB_COLS * FB_H); }   // init: all zero (SCRCLR at reset)

  peek(addr)          { return this.b[addr]; }
  poke(addr, v)       { this.b[addr] = v & 0xFF; }
  poke16(addr, v)     { this.b[addr] = (v >> 8) & 0xFF; this.b[addr + 1] = v & 0xFF; }

  // pixel helpers -- for tests and for the renderer, NOT for game code
  getPixel(x, y)      { const b = this.b[(x >> 1) * 256 + y];
                        return (x & 1) ? (b & 0x0F) : (b >> 4); }
}
```

> **`poke16` writes two consecutive SCANLINES of the same byte-column**, not two horizontally
> adjacent bytes. This is the single most important thing to get right; every 16-bit store in
> Defender's rendering code (`STD ,X`, `STU [,U]`, `STX ,Y`) works this way. Getting it wrong
> produces a picture that is transposed in 2-pixel blocks and looks almost, but not quite, right.

Addresses wrap at 65536 on the 6809. The array is smaller than 65536, so the implementation
**must** mask `addr &= 0xFFFF` and then reject `addr >= FB_COLS*FB_H` as a no-op write. Real
Defender at `$9C00+` is writing into the explosion-slot RAM, which is a different subsystem;
the renderer must not see it.

### 1.2 Palette state

```js
const CRTAB = [ /* 16 bytes, from assets/original-derived/palettes/palette.json */
  0x00,0x00,0x07,0x28,0x2F,0x81,0xA4,0x15,0xC7,0xFF,0x00,0x00,0x00,0x00,0x00,0x00 ];

class PaletteState {
  constructor() {
    this.pcram = Uint8Array.from(CRTAB);   // the shadow the game writes  ($A026)
    this.cram  = Uint8Array.from(CRTAB);   // what the hardware shows      ($C000-$C00F)

    // animator state
    this.lcolrx     = 0;   // COLR index into COLTAB           u8, init 0
    this.colrTimer  = 2;   // frames until the next COLR step   u8, init 2
    this.tctabIndex = 0;   // TIECOL row 0..2                   u8, init 0
    this.tieTimer   = 6;   // frames until the next TIECOL step u8, init 6
    this.bombTimer  = 3;   // CBOMB: first NAP is 3, then 6     u8, init 3
    this.bombPic    = 0;   // 0 = BMBD10, 1 = BMBD20            u1, init 0
  }
}
```

`cram` is refreshed from `pcram` **once per frame, atomically, at the very top of `step()`**
(`defa7.src:1958–1974`; the `CMPA #8 / BHI I01` guard means it is skipped only when the frame
is already 8 scanlines late, which a deterministic core never is). A once-per-frame snapshot is
behaviorally exact — there is no mid-frame palette change in Defender.

### 1.3 Renderer state

**None.** The Classic renderer holds one reusable `ImageData` and one reusable `Uint32Array`
palette LUT, both derived, both discardable. If a renderer field would change what the next
frame looks like, it belongs in the core and the design is wrong.

```js
class ClassicRenderer {
  constructor() {
    this.img = new ImageData(VIS_W, VIS_H);
    this.lut = new Uint32Array(16);        // rebuilt when cram changes
  }
}
```

### 1.4 Presentation settings (never simulation)

```js
const Presentation = {
  mode:        'clean',   // 'clean' | 'raster' | 'crt'
  scanlineGain:  0.30,    // 0..1, 'raster' and 'crt'
  maskStrength:  0.20,    // 0..1, 'crt'
  glow:          0.35,    // 0..1, 'crt'
  curvature:     0.06,    // 0..0.2, 'crt'
  persistence:   0.25,    // 0..1, 'crt'
  overscanCrop:  0,       // extra pixels cropped per edge; 0 = show the whole signal
  integerScaleFloor: true // §3.2
};
```

Every one of these is applied **after** the 292 × 240 buffer is complete. None of them may be
read by any core function. Tests R-72, R-73 and R-74 assert this.

---

## 2. NUMBERS

Every rendering constant, at its original scale, with provenance. `→` gives the derived value
the code uses.

### 2.1 Geometry

| Name | Value | Provenance |
|---|---|---|
| `FB_W` | 304 px | `blk71.src:99 ADDD #$2610`; `$2600` ÷ 32 units/px = 304 (L-009) |
| `FB_H` | 256 | framebuffer is `col*256 + row` (L-009) |
| `FB_SCANNED` | 152 byte-columns (`$0000–$97FF`) | L-009 |
| `FB_COLS` | 156 byte-columns (`$0000–$9BFF`) | `SCRCLR` counts down from `$9C00` |
| `VIS_X, VIS_Y` | 12, 7 | `williams.cpp:1601 set_visarea(12, 304-1, 7, 247-1)` (L-009) |
| `VIS_W, VIS_H` | 292, 240 | as above |
| `YMIN` | 42 | `phr6.src:21` |
| `SCANH` | 8 (= `YMIN − 34`) | `phr6.src:158` |
| `SCANER` | `$3008` → byte column 48, row 8 | `phr6.src:159` |
| Scanner width | 64 byte-columns → columns 48–111, pixels 96–223 | `amode1.src` `CMPA #(SCANER>>8)+64` |
| Player ship home column, facing right | pixel 64 | `MOVEMENT_ENVELOPE.md` §5 |
| Player ship home column, facing left | pixel 224 | `MOVEMENT_ENVELOPE.md` §5 |
| Player lean budget | ±48 px | `MOVEMENT_ENVELOPE.md` §5 |
| Player row limits | 43 … 238 | `PLAUP CMPB #YMIN+1`, `PLADN CMPB #238` (L-144) |
| PAR | 80 : 73 = 1.09589041… | L-105 |
| DAR | 4 : 3 exactly | L-105 |
| Frame period | 16.640 ms (60.09615 Hz) | 8 MHz ÷ (512 × 260) (L-150) |

### 2.2 World

| Name | Value | Provenance |
|---|---|---|
| World width | 65536 units = **2048 px** | L-142 |
| Units per pixel | **32** | L-142 |
| Wrap | 16-bit overflow, **no explicit modulus** | L-142 |
| On-screen draw cutoff | `(OX16 − BGL) & 0xFFFF >= 150*64 = 9600` → not drawn | `OPON`, `defa7.src:2519` |
| Right-edge column cutoff | `column + pictureWidth > $9C (156)` → not drawn | `OPON`, `defa7.src:2530` |

### 2.3 Palette animation

| Name | Value | Address |
|---|---|---|
| `COLTAB` | 36 entries + `$00` terminator | `$E799` (`defend.2`) |
| `COLR` cadence | every **2** frames | `defa7.src:3024` (`NAP 2`) |
| `TCTAB` | 3 rows × 3 bytes | `$F45B` (`defend.3`) |
| `TIECOL` cadence | every **6** frames | `defb6.src:1195` |
| `CBOMB` cadence | 3 frames, then every **6** | `defb6.src:1213` |
| `CBOMB` index | `COLTAB[SEED & $1F]` — one draw, written to **both** slots A and C | `defb6.src:1218` |
| `PXCOL` | 15 entries, last is `$00` | `$C6AB` (`defend.6`, bank 7) |

### 2.4 Timing envelopes

| Effect | Frames | Seconds | Provenance |
|---|---|---|---|
| Enemy explosion | **72** drawn | 1.198 | `RSIZE $0100 + n·$AA`, quit when high byte > `$30` (L-073) |
| Enemy / player appear | **47** drawn | 0.782 | `RSIZE $AF00 − n·$0100`, quit when non-negative (L-073) |
| Player death | **108** drawn, 109 total | 1.814 | `PXCOL` 56 + 13×4 + 1 (corrects L-075 — see `VISUAL_ASSET_FORENSICS.md` §8.3) |
| Laser color cycle | 72 | 1.198 | 36 `COLTAB` entries × 2 frames |
| Bomber color cycle | 18 | 0.300 | 3 `TCTAB` rows × 6 frames |
| Mine flip + recolour | 6 | 0.100 | `CBOMB` |
| Scanner redraw | every **8** | 0.133 | `SCPROC` = `NAP 2 + NAP 2 + NAP 4` (L-037) |
| Hyperspace blackout | **15** | 0.250 | `HYPER … NAP 15` (`defa7.src:3216`) |

### 2.5 Direct-store constants

| What | Byte / word | Meaning |
|---|---|---|
| Border and scanner bezel | `$5555` | palette index 5 (blue) in both pixels |
| Scanner window bracket | `$9999`, `$9090`, `$0909` | index 9 (white) |
| Player scanner marker | `$9099`, `$90`, `$09` | index 9 (white) |
| Laser body | `$11` | index 1 (animated) in both pixels |
| Laser head | `$99` | index 9 (white) |
| Laser exhaust | `FISTAB[i]` ∈ {`$00`,`$01`,`$10`,`$11`} | index 1, random per pixel |
| Terrain segment, down-right | `$7007` | index 7 at (2c, r) and (2c+1, r+1) |
| Terrain segment, up-right | `$0770` | index 7 at (2c+1, r) and (2c, r+1) |
| Player-death particle, even | `$BBBB` | index B, 2×2 px |
| Player-death particle, odd | `$0B0B` at col, `$B0B0` at col+1 | index B, 2×2 px straddling the byte |

---

## 3. DISPLAY GEOMETRY AND PRESENTATION — normative

### 3.1 The rule

> Render **292 × 240** logical pixels. Present them in a box whose aspect ratio is **exactly
> 4:3**. Center it. Fill the remainder with `#000000`. Never crop, never stretch to fill, never
> letterbox and pillarbox at the same time.

Square-pixel presentation gives 292:240 = 1.2167, which is **8.75 % too narrow**. Equivalently
the correct presentation applies a **+9.589 % horizontal stretch**. `320/292` and `80/73` are
the same number.

### 3.2 The scaling chain

Two stages. Integer first, fractional last, exactly once.

```
offscreen    : 304 x 256 framebuffer canvas    (the core's buffer, palette-mapped)
crop         : take VIS_W x VIS_H at (VIS_X, VIS_Y)
intermediate : scale by N = max(1, floor(boxHeightDevicePx / 240)), NEAREST NEIGHBOUR
visible      : one draw into a canvas of exactly (boxW x boxH) device pixels, SMOOTHED
```

**Do not put `image-rendering: pixelated` on the final element.** Nearest-neighbour at an 80/73
horizontal ratio produces alternating 9- and 10-device-pixel columns — visible vertical banding
on every straight edge, and Defender is nearly all straight edges. At N ≥ 4 the residual
resampling error is under ¼ of a source pixel.

**`measure()` and `present()` are two functions, and only one of them runs per frame.** Sizing a
canvas (`canvas.width = …`) destroys and reallocates its backing store and resets every context
property; `getBoundingClientRect()` forces a synchronous layout. Doing either per frame — at up to
1460×1200 for the intermediate, ~7 MB of texture churned 60 times a second — is the single most
reliable way to manufacture GC- and compositor-driven frame-pacing jitter in a canvas game.

```js
// Called ONLY from the debounced resize / fullscreenchange / matchMedia(resolution)
// handlers of SHELL_AND_LIFECYCLE_SPEC §6.1 rows L-04 / L-05 / L-06. Never from render().
function measure(ctx, mid, view) {
  const dpr = window.devicePixelRatio || 1;
  const r   = ctx.canvas.getBoundingClientRect();
  const w   = Math.round(r.width  * dpr);
  const h   = Math.round(r.height * dpr);
  const n   = Math.max(1, Math.floor(h / VIS_H));
  if (w === view.w && h === view.h && n === view.n) return;   // guard: no realloc if unchanged
  view.w = w; view.h = h; view.n = n;
  ctx.canvas.width  = w;  ctx.canvas.height = h;              // backing-store realloc
  mid.width = VIS_W * n;  mid.height = VIS_H * n;             // backing-store realloc
  ctx.imageSmoothingEnabled = true;                           // context state is reset by the
  mid.getContext('2d').imageSmoothingEnabled = false;         // assignments above — restore it
}

// Called once per requestAnimationFrame. No layout read, no allocation.
function present(ctx, fbCanvas, mid, view) {
  const m = mid.getContext('2d');
  m.drawImage(fbCanvas, VIS_X, VIS_Y, VIS_W, VIS_H, 0, 0, VIS_W * view.n, VIS_H * view.n);
  ctx.drawImage(mid, 0, 0, view.w, view.h);
}
```

`PRF-03` asserts that 600 consecutive `render()` calls perform **zero** forced reflows.

### 3.3 Layout policy, case by case

| Case | Policy |
|---|---|
| **Viewport wider than 4:3** (the normal desktop case) | pillarbox. `width = 100vh · 4/3`, black bars left and right. |
| **Viewport taller than 4:3** | letterbox. `height = 100vw · 3/4`, black bars top and bottom. |
| **Ultrawide (≥ 21:9)** | still pillarbox — the box is height-limited and the bars are simply wide. **Do not** widen the world, do not add side panels that move, do not put gameplay information in the bars. A static, non-animated cabinet-art bezel is permitted as an opt-in presentation setting, defaulting **off**, and must not be present in any screenshot used as fidelity evidence. |
| **Portrait (aspect < 3:4)** | the 4:3 box is width-limited and becomes small. Below **480 CSS px of box width**, show a persistent, dismissible overlay: "Defender was a landscape game. Rotate your device." The game keeps running behind it; the overlay must not pause the core. |
| **Browser zoom** | the box is computed from `getBoundingClientRect()` every resize, so zoom is handled by the same path as a window resize. Recompute `N`. |
| **High-DPI** | back the visible canvas with `cssPx · devicePixelRatio` device pixels and compute `N` from **device** pixels, not CSS pixels. On a 2× display a 640 CSS-px-tall box gives 1280 device px and `N = 5`. |
| **Fullscreen** | `requestFullscreen()` on the `#stage` element, not on the canvas — the canvas must stay inside a centering grid so the bars are drawn by the stage's background. Re-run `resize()` on `fullscreenchange`. |
| **Minimum** | below 240 device px of box height, `N = 1` and the image is fractionally downscaled once. Legibility degrades; this is preferable to cropping. |

```css
html, body { margin: 0; height: 100%; background: #000; }
#stage  { position: fixed; inset: 0; display: grid; place-items: center; background: #000; }
#screen { aspect-ratio: 4 / 3;
          width:  min(100vw, calc(100vh * 4 / 3));
          height: min(100vh, calc(100vw * 3 / 4));
          display: block; }
```

### 3.4 Pointer mapping

There is no uniform scale factor. Map through both stages:

```js
x_fb = VIS_X + (x_css - boxLeft) * VIS_W / boxWidth;
y_fb = VIS_Y + (y_css - boxTop)  * VIS_H / boxHeight;
```

### 3.5 Screenshots

A 292 × 240 PNG is a **storage artifact, not a picture of Defender**. Any screenshot used as
fidelity evidence must be taken at a 4:3 size and the size must be stated. `assets/` PNGs are
storage artifacts and are labeled as such in their provenance JSON (`pixelAspectRatio` field).

---

## 4. THE PALETTE PIPELINE

### 4.1 Byte to sRGB

```js
const W_RG = [37.615, 80.604, 136.781];   // bits 0,1,2  (1200, 560, 330 ohm)
const W_B  = [94.551, 160.449];           // bits 6,7    (560, 330 ohm)

function chan(bits, w) {
  let v = 0;
  for (let i = 0; i < w.length; i++) if (bits & (1 << i)) v += w[i];
  return Math.round(v);
}
function paletteByteToRGB(b) {          // BBGGGRRR
  return [ chan(b & 7, W_RG), chan((b >> 3) & 7, W_RG), chan((b >> 6) & 3, W_B) ];
}
```

Resulting levels — assert these in a test:

```
R, G:  0  38  81  118  137  174  217  255
B   :  0  95  160  255
```

`$FF → #FFFFFF` exactly. `$A4 ("GRAY") → #8989A0`, a blue-tinted gray. **Do not neutralise it.**

### 4.2 The four animators, per frame, in this order

Run **inside the executive pass** (§5.2 step B), because in the original they are processes, not
IRQ code. Each is a countdown; when it reaches zero it acts and reloads.

```
COLR  (slot 1):
    if (--colrTimer == 0) {
        colrTimer = 2;
        let v = COLTAB[lcolrx];
        if (v == 0) { lcolrx = 0; v = COLTAB[0]; }     // wrap on the terminator
        pcram[0x1] = v;
        lcolrx = (lcolrx + 1) & 0xFF;
    }

TIECOL (slots D, E, F):
    if (--tieTimer == 0) {
        tieTimer = 6;
        pcram[0xD] = TCTAB[tctabIndex*3 + 0];
        pcram[0xE] = TCTAB[tctabIndex*3 + 1];
        pcram[0xF] = TCTAB[tctabIndex*3 + 2];
        tctabIndex = (tctabIndex + 1) % 3;
    }

CBOMB (slots A and C, and the mine bitmap):
    if (--bombTimer == 0) {
        bombTimer = 6;
        const v = COLTAB[seed & 0x1F];      // seed = the low byte of the game RNG state
        pcram[0xA] = v;
        pcram[0xC] = v;
        bombPic ^= 1;                       // BMBD10 <-> BMBD20
    }
```

`COLR`'s exact wrap: the original is `LDB A,X / BEQ COLR` — it reads the table entry, and if it
is `$00` it restarts the *process*, resetting `LCOLRX` to 0 and immediately reading `COLTAB[0]`.
It does **not** write `$00` to the slot. A naive `index = (index+1) % 37` writes black for two
frames every cycle and is visibly wrong.

Initial values, from process start: `colrTimer` counts down from 2 and the first write happens
on the frame the process is created — treat `colrTimer = 1` at wave start so `COLTAB[0]` lands
on frame 1. `CBOMB` writes `pcram[0xA] = $FF` and `pcram[0xC] = $00` once at process start, then
its first randomised write is 3 frames later.

### 4.3 The latch

At the very top of `step()`, before anything draws:

```
cram.set(pcram)     // all 16 bytes, atomically
rebuildLUT()        // only if cram changed
```

### 4.4 The LUT

```js
function rebuildLUT(cram, lut) {
  for (let i = 0; i < 16; i++) {
    const [r, g, b] = paletteByteToRGB(cram[i]);
    lut[i] = (255 << 24) | (b << 16) | (g << 8) | r;   // little-endian RGBA
  }
}
```

**Index 0 is opaque black, not transparent.** Defender has no transparency; index 0 is the
color of space and it is written by every erase.

---

## 5. THE FRAME: ORDER OF OPERATIONS

Getting this wrong is a silent fidelity bug. Defender's behavior depends on evaluation order
within a frame.

### 5.1 What the hardware actually does

`PIA1` CB1 is wired to VA11 = bit 5 of the scanline (`williams_m.cpp:19–29`), so it toggles
every 32 scanlines. The PIA is armed for the falling edge (`LDA #5 / STA PIA1+1` at `IRQX`),
giving IRQs at scanlines **0, 64, 128, 192**. The handler branches on `VERTCT >= 128` and uses
`IFLG` to make each half do work exactly once, so **two of the four IRQs are no-ops**:

| Scanline | `VERTCT` | Path | Work |
|---|---|---|---|
| 0 | < 128 | `I0` | **top-of-frame pass** |
| 64 | < 128 | `I0`, `IFLG` already cleared | nothing |
| 128 | ≥ 128 | main | **mid-frame pass** |
| 192 | ≥ 128 | main, `IFLG` already set | nothing |

The executive (`EXEC`) is the *main loop*, gated on `TIMER`, which is incremented by the
top-of-frame pass. It therefore runs between the two IRQ passes.

### 5.2 The serialisation `step()` must implement

```
step(input):

  A. TOP-OF-FRAME PASS                       (IRQ at scanline 0, defa7.src:1988-1994)
     A1. TIMER++  (the executive gate)
     A2. cram <- pcram, atomically; rebuild the LUT
     A3. CSCAN                                          [no visual effect]
     A4. if (STATUS & $02) == 0:  BGOUT()               terrain scroll + full redraw
     A5. PRDISP(band = BAND_A)                          player erase then draw
     A6. OPROC (band = BAND_A)                          objects erase then draw
     A7. VELO()                                         velocity integration

  B. EXECUTIVE PASS                          (main loop, defa7.src:3040)
     B1. overload accounting (OVCNT); if OVCNT >= 2 set STRCNT = 3
     B2. dispatch every live process in list order, each decrementing PTIME:
           - palette animators COLR / TIECOL / CBOMB          (section 4.2)
           - SCPROC, which redraws the scanner every 8th frame (section 7)
           - EXPU: every explosion / appear slot               (section 6)
           - laser processes LASR / LASL                       (section 8)
           - enemy AI, humanoid logic, wave logic
           - COLIDE / COLCHK, using PLAXC -- the position as last DRAWN (L-049)
        A process created by MKPROC is inserted immediately after the current one with
        PTIME = 1 and therefore runs LATER IN THE SAME PASS (L-082). This is load-bearing.

  C. MID-FRAME PASS                          (IRQ at scanline 128, defa7.src:1938-1956)
     C1. SNDSEQ()                                       sound sequencer + switch scan
     C2. PLAYER(input)                                  player movement / fire / thrust
     C3. STOUT()                                        stars: erase all 16, then draw
     C4. OPROC (band = BAND_B)
     C5. PRDISP(band = BAND_B)
     C6. SHELL()                                        shells + mines move and draw
```

**Note the two passes use opposite internal order**: A does `PRDISP` then `OPROC`; C does
`OPROC` then `PRDISP`. That is what the ROM does and it is observable — in band A the player is
erased/redrawn *before* the enemies, in band C *after*, so the overlap resolution flips between
the top and bottom halves of the screen.

### 5.3 The bands — complementary, sharing the single boundary `XXX2`

> **CORRECTED — governed by `CLASSIC_MODE_CONTRACT.md` C-24.** Iterations 1–3 of this section read
> `LDD #$FF70 / STD XXX1` as seeding a 16-bit variable `XXX1 = $FF70` and took `$70` = 112 as band
> A's lower bound, producing a fictitious 8-row overlap at rows 113–120. The trust rule says the
> ROM wins and the misread was never taken back to it. `INT-06`, which asserted the overlap, is
> deleted.

`XXX1`, `XXX2` and `XXX3` are **three adjacent one-byte variables** (`phr6.src:285-287`), not one
word:

```
XXX1    RMB    1                ;SCREEN OUTPUT PARAMS
XXX2    RMB    1
XXX3    RMB    1
```

`INIT` (`defa7.src:1009`; ROM `$D78C  CC FF 70  DD A1  0F A3`) does `LDD #$FF70 / STD <XXX1`, which
writes **`XXX1 = $FF` and `XXX2 = $70`**, then `CLR <XXX3`. `$70` is `XXX2`'s value before the first
IRQ, nothing more. `LDD XXX1` loads the pair `(XXX1, XXX2)`; `LDD XXX2` loads `(XXX2, XXX3)`.

```
band A call:  LDD XXX1  ->  (hi, lo) = (0xFF, XXX2)     defa7.src:1990-1994, ROM $DF9F  DC A1
band C call:  LDD XXX2  ->  (hi, lo) = (XXX2, 0x00)     defa7.src:1951-1957, ROM $DF4E  DC A2
```

Both predicates are half-open, so the two passes **tile rows 1..255 exactly once — no gap and no
overlap — for any value of `XXX2`**:

| Routine | Test | Accepts | Band A | Band C |
|---|---|---|---|---|
| `OPROC` (`defa7.src:2503-2518`) | `PSHS D`; `CMPB ,S / BHI skip`; `CMPB 1,S / BLS skip` | `lo < row ≤ hi` | rows 121..255 | rows 1..120 |
| `PRDISP` (`defa7.src:2294-2300`) | `CMPA PLAYC / BLS ret`; `CMPB PLAYC / BHI ret` | `lo ≤ playc < hi` | rows 120..254 | rows 0..119 |

`XXX2 = min(VERTCT − 8, $A8)` is **recomputed every frame**, at the head of the scanline-128 pass
before `OPROC`/`PRDISP` are called (ROM `$DF3C`: `LDA VERTCT / SUBA #8 / CMPA #$A8 / BLS / LDA #$A8
/ STA <XXX2`). It tracks the beam, and therefore the CPU load. The mid-frame IRQ cannot fire before
scanline 128, so `VERTCT ≥ 128` and `XXX2 ≥ 120` always; in this core, which has no cycle model,
`vertctAtService` is fixed at 128 and **`XXX2` is 120 on every frame**. That is deviation D-1, and
it is unobservable *here* — but the reason is now the right one:

> The union of the bands is rows 1..255 for **any** `XXX2`, because the bands are complementary.
> Moving `XXX2` does not change *which* objects are drawn; it changes *which pass* draws them, and
> therefore whether they carry one frame of position lag (C-25).

The one-row disagreement between `OPROC` and `PRDISP` at row 120 is real and must be reproduced:
the player at row 120 is committed in the **A** pass, an object at row 120 in the **C** pass.

**No object is drawn twice.** Any implementation that erases and redraws a row in both passes is
wrong.

### 5.4 `OPROC`, exactly

```
OPROC(band):
  if (STATUS & $20) return            // "screen off" -- attract transitions, hyperspace
  for (obj of activeList) {           // OPTR, in list order
    // --- erase the old image ---
    if (obj.OBJX != 0) {
      const row = obj.OBJX_row;
      if (row <= band.upper && row > band.lower) {
        callErase(obj.picture, obj.OBJX);   // OBJDEL vector
        obj.OBJX = 0;
      } else continue;                      // out of band: leave it drawn, skip entirely
    }
    // --- draw the new image ---
    const row = obj.OY16 >> 8;
    if (row > band.upper || row <= band.lower) continue;
    let v = (obj.OX16 - BGL) & 0xFFFF;
    if (v >= 9600) continue;                          // 150*64: off screen right / wrapped left
    const col   = (v >> 6) & 0xFF;                    // byte column
    const phase = (v >> 5) & 1;                       // odd pixel?
    if (col + obj.picture.W > 0x9C) continue;         // over-width protection
    obj.OBJX = (col << 8) | row;
    callDraw(obj.picture, obj.OBJX, phase);           // OBJWRT vector
  }
```

Three subtleties, all real:

- The erase test and the draw test are **separate**. An object that has moved out of the band
  since it was drawn is skipped entirely and stays on screen — that is the `continue` after the
  erase test, and it is why objects near the band boundary can persist for a frame.
- `obj.OBJX == 0` is the sentinel for "not currently drawn" and it is also a legal screen
  address (byte column 0, row 0). Column 0 is outside the visible window, so the collision is
  harmless — but do not "fix" it.
- **Collision always reads the even-phase bitmap** regardless of which phase was drawn
  (`LDU OBJP0,U` in `COLIDE`, L-047). The hitbox is therefore 2-px quantised and does not track
  the drawn phase.

### 5.5 Sprite blit, exactly

```
draw(picture, screenAddr, phase):
  const data = phase ? picture.p1 : picture.p0;      // W*H bytes, column-major
  let a = screenAddr;
  for (let c = 0; c < picture.W; c++) {
    for (let r = 0; r < picture.H; r++) fb.poke((a + r) & 0xFFFF, data[c * picture.H + r]);
    a = (a + 0x100) & 0xFFFF;
  }

erase(picture, screenAddr):
  let a = screenAddr;
  for (let c = 0; c < picture.W; c++) {
    for (let r = 0; r < picture.H; r++) fb.poke((a + r) & 0xFFFF, 0);
    a = (a + 0x100) & 0xFFFF;
  }
```

**Opaque, unmasked.** Index-0 pixels in the source overwrite whatever is beneath. This is why
every sprite carries a blank right-hand column.

Text and static pictures (`CWRIT`) use `p0` unconditionally and take no phase argument. They can
therefore only be placed at **even pixel columns**; snap.

---

## 6. THE EXPLOSION ENGINE

The signature Defender visual. It is not a particle system.

### 6.1 State — 16 slots, 64 bytes each

```js
const EXPLOSION_SLOTS = 16;        // RAMALS $9C00-$9FFF, RAMSIZ = $40
const ERASE_CAPACITY  = 26;        // (64 - 12 header bytes) / 2

class ExpSlot {
  constructor() {
    this.rsize   = 0;      // u16.  0 = free.  >0 = explosion.  bit15 set = appear.
    this.picture = null;   // the object's picture descriptor at the moment of death
    this.eraseN  = 0;      // how many entries of eraseTbl are live  (0..26)
    this.eraseTbl= new Uint16Array(ERASE_CAPACITY);
    this.center  = 0;      // u16 screen address (hi = byte column, lo = scanline)
    this.topLeft = 0;      // u16 screen address
    this.objPtr  = null;   // appear only: the object to restore
  }
}
let lsexpl = 0;            // round-robin cursor, index of the last slot allocated
```

### 6.2 Starting an explosion — `EXST`

```
EXST(obj):
  const v = (obj.OX16 - BGL) & 0xFFFF;
  if (((v >> 8) & 0xFF) > 0x26) return;              // off screen: NO EXPLOSION AT ALL
  const xstart = v;

  // round-robin, skipping in-progress appears
  let y = lsexpl;
  for (;;) {
    y = (y + 1) % EXPLOSION_SLOTS;
    if (y == lsexpl) return;                         // every slot is an appear -> DROP IT
    if (slot[y].rsize & 0x8000) continue;            // appear in progress -> skip
    if (slot[y].rsize != 0) eraseSlot(y);            // steal a live explosion
    break;
  }
  lsexpl = y;

  slot[y].rsize   = 0x0100;                          // SIZE = 1
  slot[y].picture = obj.picture;
  slot[y].eraseN  = 0;
  const col = ((xstart << 2) >> 8) & 0xFF;           // same conversion as OPON
  slot[y].topLeft = (col << 8) | (obj.OY16 >> 8);

  // centre: the exact collided pixel, if it is inside the picture
  const d = (slot[y].topLeft - CENTMP) & 0xFFFF;
  const dx = (d >> 8) & 0xFF, dy = d & 0xFF;
  if (((dx + picture.W) & 0x100) && ((dy + picture.H) & 0x100))
       slot[y].center = CENTMP;                      // in bounds: use the impact point
  else slot[y].center = (slot[y].topLeft
                        + ((picture.W >> 1) << 8) + (picture.H >> 1)) & 0xFFFF;
```

The two `BHS` tests in `EXST6` are carry tests on 8-bit adds: "in bounds" means the add
**carried**, i.e. `dx + W >= 256`, which for a negative `dx` held as an unsigned byte means the
offset is within `W` to the left. Implement them as literal 8-bit adds with carry inspection —
the sign conventions are not what a modern reading suggests.

`CENTMP` is written by `COLIDE`, which back-solves the exact screen address of the colliding
byte (L-048). Several call sites deliberately override it before exploding (absorbed and
splatted humanoids).

### 6.3 Starting an appear — `APST`

Identical allocation, except `rsize = 0xAF00` (SIZE 47, appear flag set) and `objPtr` is stored
so the real picture can be restored on completion. The object's `OTYP` bit 1 ("appearing, not
hyperable, not smart-bombable") is set on entry and cleared on completion.

### 6.4 Per-frame update — `EXPU`, once per executive pass

```
EXPU():
  for (let y = 0; y < EXPLOSION_SLOTS; y++) {
    const s = slot[y];

    if (STATUS & 4) {                     // not in play: tear everything down
      if (s.rsize & 0x8000) { finishAppear(y); } else { s.rsize = 0; }
      continue;
    }
    if (s.rsize == 0) continue;

    if (!(s.rsize & 0x8000)) {
      // ---- EXPLOSION ----
      s.rsize = (s.rsize + 0xAA) & 0xFFFF;
      if (((s.rsize >> 8) & 0xFF) > 0x30) { eraseSlot(y); s.rsize = 0; continue; }
      // scroll the whole burst with the world, quantised to 4 byte-columns.
      // PHASE 4: this is the SYSTEM BGLX ($A022) — ROM $FD64 `DC 22 C4 C0` — and EXPU
      // runs at B3, BEFORE PLAYER's C2 write, so it holds the PREVIOUS frame's pre-move
      // BGL. The term is the previous frame's camera step and is NON-ZERO whenever the
      // camera moved. §6.4's claim below that it is zero while the terrain is alive is
      // withdrawn: a burst is WORLD-locked in both halves of the game.
      const d = ((((BGLX & 0xFFC0) - (BGL & 0xFFC0)) & 0xFFFF) << 2) & 0xFFFF;
      const dc = (d >> 8) & 0xFF;
      s.center  = (s.center  + (dc << 8)) & 0xFFFF;
      s.topLeft = (s.topLeft + (dc << 8)) & 0xFFFF;
    } else {
      // ---- APPEAR ----
      s.rsize = (s.rsize - 0x0100) & 0xFFFF;
      if (!(s.rsize & 0x8000)) { finishAppear(y); continue; }
      const v = (s.objPtr.OX16 - BGL) & 0xFFFF;
      if ((((v >> 8) + 0x0C) & 0xC0) != 0) { finishAppear(y); continue; }   // wandered off
      const col = ((((v >> 8) + 0x0C - 0x0C) << 2) >> 8) & 0xFF;
      s.topLeft = (col << 8) | (s.objPtr.OY16 >> 8);
      // "phoney centre" = topLeft + (0xDA*W/128 , H/2)  -- see below
      const a = (((0xDA * s.picture.W) >> 7) & 0xFF);
      s.center = (s.topLeft + (a << 8) + (s.picture.H >> 1)) & 0xFFFF;
    }
    eraseSlot(y);
    writeSlot(y);
  }
```

The appear's "phoney center" (`EXPU7`) is `LDB #$DA / MUL / ASLA / LDB W / MUL / LDB H / LSRB`.

⚠ **PHASE 4 CORRECTION.** The formula in the code block above — `a = (0xDA * W) >> 7` — is wrong,
and so is the sentence that used to follow it. At `LDB #$DA / MUL` the **A register still holds the
BYTE COLUMN**, not a constant: `MUL` multiplies A by B, so the first product is
`column × $DA`, and only the second `MUL` brings `W` in. **The horizontal term is
column-dependent.** This is exactly the open question O-4 in §16, and it is now resolved by
implementing the byte-level arithmetic literally (8-bit `MUL` producing a 16-bit result, `ASLA`
acting on the high byte only), which is what `src/core/explosions.js` does. The rounding still
matters at `W = 2` (`ASTP*`, `BMBP*`); what changes is that the value is not a constant fraction
of the picture's width.

### 6.5 `EWRITE` — the tile blit

```
writeSlot(y):
  const s = slot[y], W = s.picture.W, H = s.picture.H;
  const SIZE  = (s.rsize >> 8) & 0x7F;
  const DSIZE = (SIZE * 2) & 0xFF;
  const data  = s.picture.p0;                       // ALWAYS phase 0

  // offsets of the centre inside the picture
  const XOFF   = ((s.center - s.topLeft) >> 8) & 0xFF;
  const rawY   = (s.center - s.topLeft) & 0xFF;
  const YOFF   = rawY >> 1;
  const FLAVOR = rawY & 1;                          // the bit rolled out by LSRB

  let xs = ((s.center >> 8) & 0xFF) - ((SIZE * XOFF) & 0xFFFF);   // 16-bit, signed-ish
  let col = 0;
  s.eraseN = 0;

  // skip source columns whose screen column is still negative
  while ((xs & 0xFF00) != 0) {
    col++; if (col >= W) return;
    xs = (xs + SIZE) & 0xFFFF;
  }

  for (; col < W; col++) {
    const scol = xs & 0xFF;
    if (scol > 0x98) break;                          // off the right edge -> done

    let ys  = (((s.center & 0xFF) - ((YOFF * DSIZE) & 0xFFFF) - FLAVOR) & 0xFFFF);
    let skip = 0, len = H;
    while ((ys & 0xFF00) != 0 || (ys & 0xFF) < 0x2A) {   // above the playfield / in the scanner
      skip++; len -= 2;
      if (len <= 0) { xs = (xs + SIZE) & 0xFFFF; continue outer; }
      ys = (ys + DSIZE) & 0xFFFF;
    }

    let src = col * H + (skip * 2);
    let row = ys & 0xFF;
    for (let k = 0; k < (len & 0xFE) / 2; k++) {      // whole 2-row tiles
      const addr = ((scol << 8) | row) & 0xFFFF;
      if (s.eraseN < ERASE_CAPACITY) s.eraseTbl[s.eraseN++] = addr;
      fb.poke(addr,     data[src]);
      fb.poke(addr + 1, data[src + 1]);
      src += 2;
      const nr = row + DSIZE;
      if (nr > 0xFF) break;                           // BCS: fell off the bottom
      row = nr;
    }
    if ((len & 1) && row + DSIZE <= 0xFF) {           // odd H: one single-row byte
      const addr = ((scol << 8) | row) & 0xFFFF;
      if (s.eraseN < ERASE_CAPACITY) s.eraseTbl[s.eraseN++] = addr;
      fb.poke(addr, data[src]);
    }
    xs = (xs + SIZE) & 0xFFFF;
  }

  // and finally: erase the centre block itself
  const cc = (s.center >> 8) & 0xFF;
  if (cc <= 0x98) fb.poke16((s.center - FLAVOR) & 0xFFFF, 0);
  // NOTE: this final erase is NOT undone and is NOT in the erase table. At SIZE = 1 the burst
  // is therefore the original sprite WITH A 2 x 2 PIXEL HOLE PUNCHED AT THE IMPACT POINT.
  // That hole is the first visible frame of every enemy death and it is easy to miss.

eraseSlot(y):
  for (let i = 0; i < slot[y].eraseN; i++) fb.poke16(slot[y].eraseTbl[i], 0);
  slot[y].eraseN = 0;
```

**Every tile is 2 pixels wide by 2 scanlines tall and keeps the two source bytes verbatim** —
its original color indices, unchanged, all the way out. That is what makes a Defender explosion
look like a Defender explosion and not like sparks.

Tile spacing: `SIZE` byte-columns horizontally = `2·SIZE` pixels; `2·SIZE` scanlines vertically.
**Isotropic in pixels.** At `SIZE = 1` the tiles are contiguous and the burst *is* the original
sprite; at `SIZE = 48` they are 96 px apart.

### 6.6 The `SIZE` ramp — the exact sequence

`RSIZE` starts `$0100` and gains `$AA` per frame. `SIZE = RSIZE >> 8`. Terminate when
`SIZE > $30`.

```
frame:  1  2  3  4  5  6  7  8  9 10 11 12 ... 70 71 72 | 73
SIZE:   1  2  2  3  4  4  5  6  6  7  8  8 ... 47 48 48 | 49 -> erase, free
```

`SIZE(n) = floor((256 + 170n) / 256)`. **72 drawn frames = 1.198 s.** The doubled values are not
a bug — they are the visible stutter in the burst's growth and an expert will notice a smooth
ramp instantly.

Appear: `SIZE = (0xAF00 − n·0x0100) >> 8 & 0x7F` = **46, 45, … 1, 0 over 47 frames = 0.782 s**,
ending with every tile on the center.

### 6.7 The player's death — a different engine

```js
const PARTICLES = 128;
class DeathParticle { constructor(){ this.addr=0; this.x=0; this.y=0; this.vx=0; this.vy=0; } }
// x, y, vx, vy are all u16.  x's high byte is a byte-column, low byte is a 1/256 fraction.
// y's high byte is a scanline.
```

Initialization — reject until the velocity passes the corner test:

```
lfsrA = 0x0808;  lfsrB = 0x1732;

function nextLFSR(s) {                  // LDA lo / LSRA / EORA lo / LSRA / LSRA / ROR hi / ROR lo
  const lo = s & 0xFF;
  const fb = ((lo >> 2) ^ (lo >> 1)) & 1;          // bit2 XOR bit1 of the low byte
  return ((s >> 1) | (fb << 15)) & 0xFFFF;
}

for (let i = 0; i < 128; i++) {
  for (;;) {
    p.x = (PCENT & 0xFF00);                        // ship centre byte-column, fraction 0
    p.y = ((PCENT & 0x00FF) << 8);                 // ship centre scanline,   fraction 0

    lfsrA = nextLFSR(lfsrA);
    p.vx = ((((lfsrA >> 8) & 1) - 1) << 8 | (lfsrA & 0xFF)) & 0xFFFF;   // $00xx or $FFxx
    let ax = (p.vx & 0x8000) ? (~p.vx & 0xFFFF) : p.vx;                 // ONE's complement

    lfsrB = nextLFSR(lfsrB);
    p.vy = (((((lfsrB >> 8) & 3) - 2) & 0xFF) << 8 | (lfsrB & 0xFF)) & 0xFFFF;
    let ay = (p.vy & 0x8000) ? (~p.vy & 0xFFFF) : p.vy;

    if (((ay >> 1) + ax) < 0x016A) break;          // ";CHECK FOR CORNERS"
  }
  p.addr = 0;
}
```

> The rejection region is an **L1 ball (a diamond)**, not a circle. With `vy` at twice `vx`'s
> scale and halved before summing, the result is isotropic in pixels and the debris field reads
> as round — but the boundary is straight-edged and a long capture shows it. Say "diamond".
> Also note `COMA/COMB` is a **one's** complement: `|v|` is one short of the true magnitude for
> negative velocities. Reproduce that.

Per frame:

```
// ORDER: read the colour and test it FIRST; decrement the hold counter LAST.
const colour = PXCOL[pcolp];
if (colour == 0) { finish(); return; }             // BEFORE drawing -- the terminator draws nothing
pcram[0xB] = colour;

for (const p of particles) {
  fb.poke16(p.addr, 0);                            // erase 2 bytes at addr
  fb.poke16((p.addr + 0x100) & 0xFFFF, 0);         // and 2 at addr + one byte-column

  let ny = (p.y + p.vy) & 0xFFFF;
  if (((ny >> 8) & 0xFF) < 0x2A) continue;         // above the playfield: dead, do not update
  p.y = ny;

  let nx = (p.x + p.vx) & 0xFFFF;
  if (((nx >> 8) & 0xFF) > 0x98) continue;         // off the right: dead
  p.x = nx;

  p.addr = (((p.x >> 8) & 0xFF) << 8) | ((p.y >> 8) & 0xFF);

  if ((p.x & 0x80) == 0) {                         // "left flavour"
    fb.poke16(p.addr, 0xBBBB);                     // 2 px x 2 rows, one byte-column
  } else {                                         // "right flavour": straddle the boundary
    fb.poke16(p.addr,                     0x0B0B);
    fb.poke16((p.addr + 0x100) & 0xFFFF,  0xB0B0);
  }
}
if (--pcolc == 0) { pcolp++; pcolc = 4; }          // AFTER the particle loop (DEC PCOLC / BNE PX1)
```

`pcolc` starts at **56** and `pcolp` at 0; after the first entry expires every entry gets 4
frames. `PXCOL = FF 7F 3F 37 2F 27 1F 17 07 06 05 04 03 02 00`.

**108 drawn frames, 109 total, 1.814 s.** Note the terminator costs one frame and no draw.

The playfield does not scroll during the player's death, and the particles carry no scroll
compensation — they are absolute screen addresses.

---

## 7. THE SCANNER

### 7.1 Cadence

`SCPROC` is a three-stage process: `ISCAN` → `NAP 2` → `OSCAN`+`SHSCAN` → `NAP 2` →
`MAPCH1`+`SCNRV` → `NAP 4` → repeat. **The scanner is redrawn once every 8 frames**, on the
frame that runs `SCNRV`. The step and the lag are part of the game's texture. **Do not smooth
it, do not interpolate it, do not redraw it every frame.**

### 7.2 Erase-then-draw, in order

`SCNR` (`amode1.src:1182`) does, in this exact order:

1. **Erase old blips.** Walk `SETAB` in 8-byte strides writing `$0000` through each stored
   address (four indirect stores per stride) until `SETEND`. Then erase the player marker:
   `$0000` at `[SETEND]`, `$00` at `+2`, `$0000` at `−$100`.
2. **Draw the mini-terrain** (unless `STATUS & 2`, planet destroyed). 64 byte-columns from
   `MTERR`, erasing the previous address from `STETAB` as it goes.
3. **Draw the bezel brackets**: `$9090` at column 76 rows 9–10 and rows 38–39; `$0909` at column
   83, same rows.
4. **Draw object blips** from `OPTR` (active) **then** `IPTR` (inactive / off-screen), appending
   each address to `SETAB`; store the end in `SETEND`.
5. **Draw the player marker.**

The order matters: the player marker is drawn last and therefore wins every overlap.

### 7.3 The arithmetic, worked

```
XTEMP = (BGL - 0x8000 + 150*32) & 0xFFFF          // 150*32 = 4800

blipColumn = 0x30 + ((((OX16 - XTEMP) & 0xFFFF) >> 8) >> 2)      // == >> 10, always 0..63
blipRow    = 0x07 + ((OY16 >> 8) >> 3)                            // OY16 high byte >> 3
blipAddr   = (blipColumn << 8) | blipRow
fb.poke16(blipAddr, obj.OBJCOL)                                   // 2 px wide x 2 scanlines
```

`obj.OBJCOL` is a 16-bit word; the high byte lands on scanline `blipRow`, the low byte on
`blipRow + 1`. Values in §7.5.

**Test points.** Take `BGL = 0x0000` (screen-left at world x 0), so `XTEMP = 0x8000 + 4800 =
0x92C0`.

| Object `OX16` | world px | `(OX16 − XTEMP) & 0xFFFF` | `>> 10` | column | pixels |
|---|---|---|---|---|---|
| `0x92C0` | 1174 | `0x0000` | 0 | 48 | 96–97 |
| `0x0000` | 0 | `0x6D40` | 27 | 75 | 150–151 |
| `0x12C0` | 150 | `0x8000` | 32 | 80 | 160–161 |
| `0xFFFF` | 2047.97 | `0x6D3F` | 27 | 75 | 150–151 |
| `0x0001` | 0.03 | `0x6D41` | 27 | 75 | 150–151 |

The last two are **the seam test**: world x 2047.97 and world x 0.03 are adjacent pixels either
side of the wrap, and they land in the same scanner column. There is no discontinuity, no
special case and no modulus — the 16-bit subtraction does it. An implementation that stores
world X as an unbounded integer and takes `% 2048` somewhere will pass the first three rows and
fail these two.

Second seam test, with the screen near the wrap: `BGL = 0xFF00` (world x 2039.5). Then
`XTEMP = 0xFF00 − 0x8000 + 0x12C0 = 0x91C0`. An object at `OX16 = 0x0100` (world x 8) gives
`(0x0100 − 0x91C0) & 0xFFFF = 0x6F40`, `>> 10 = 27`, column 75 — dead center, which is correct:
the object is 24 px right of screen-left, and screen-left maps to 150 px left of scanner center.

### 7.4 Mini-terrain

```
mtIndex = ((XTEMP >> 8) >> 2) & 0x3F              // 0..63
u = MTERR + mtIndex * 3
a = 0x30                                          // SCANER >> 8, the first byte column
repeat 64 times:
    eraseAddr = STETAB[i];  fb.poke16(eraseAddr, 0)
    row     = u[0];  pattern = (u[1] << 8) | u[2];  u += 3
    addr    = (a << 8) | row
    STETAB[i] = addr
    fb.poke16(addr, pattern)
    a++
```

`MTERR` holds **128** entries — 64 unique, repeated — precisely so that 64 consecutive entries
can be read from any of the 64 starting offsets with no wrap test. Copy all 384 bytes.

`MTERR` is hand-drawn and **does not match the real terrain** (L-145). Do not compute it.

### 7.5 Blip colors

| Class | Word | Scanline *r* | Scanline *r+1* |
|---|---|---|---|
| Lander | `$4433` | yellow, yellow | green, green |
| Mutant | `$CC33` | cycler, cycler | green, green |
| Baiter | `$3333` | green ×2 | green ×2 |
| Bomber | `$8888` | purple ×2 | purple ×2 |
| Pod | `$CCCC` | cycler ×2 | cycler ×2 |
| Swarmer | `$2424` | red, yellow | red, yellow |
| Humanoid | `$6666` | gray ×2 | gray ×2 |
| Score popup | `$0000` | — | — (erases) |

Mines and player lasers have **no scanner representation**.

### 7.6 The player marker

```
markerCol = 0x4B + ((PLAXC >> 8) >> 4)            // PLAXC high byte = screen byte column
markerRow = 0x07 + ((PLAXC & 0xFF) >> 3)          // PLAXC low byte  = screen scanline
addr = (markerCol << 8) | markerRow

fb.poke16(addr,                 0x9099)           // (2c, r) ; (2c, r+1),(2c+1, r+1)
fb.poke (addr + 2,              0x90)             // (2c, r+2)
fb.poke ((addr - 0xFF) & 0xFFFF,0x09)             // (2c-1, r+1)   -- one byte-column LEFT, +1 row
```

A 3-pixel cross whose horizontal bar is centered one pixel to the **left** of its vertical stroke.
It is derived from **screen** position, so it slides inside the white bracket (columns 76–83) as
the ship leans. **MULTI_SOURCE_CONFIRMED** (L-038). Worked values:

| Ship screen column | `PLAXC >> 8` | marker column | inside bracket 76–83? |
|---|---|---|---|
| pixel 64 (home, facing right) | 32 | 75 + 2 = **77** | yes |
| pixel 16 (full left lean) | 8 | 75 + 0 = **75** | just outside, left |
| pixel 224 (home, facing left) | 112 | 75 + 7 = **82** | yes |
| pixel 272 (full right lean) | 136 | 75 + 8 = **83** | yes, at the edge |

---

## 8. THE LASER

Not a sprite. Three pointers per beam, all screen addresses, each moving at a different rate.

```js
class Laser {
  constructor() {
    this.head  = 0;   // u16 screen address -- the bright tip
    this.spark = 0;   // u16 -- the exhaust writer
    this.tail  = 0;   // u16 -- the eraser
    this.dir   = 1;   // +1 = right, -1 = left
  }
}
let lflg  = 0;                 // number of live lasers, max 4
let fisx  = 0;                 // cursor into FISTAB
const FISTAB = new Uint8Array(32);   // regenerated once per LIFE by FISS
```

Fire (`LFIRE`): refuse if `lflg >= 4`. Otherwise `lflg++` and

```
right: head = spark = tail = (NPLAXC + 0x0704) & 0xFFFF      // +7 byte-columns, +4 scanlines
left:  head = spark = tail = (NPLAXC + 0x0004) & 0xFFFF      // +0 byte-columns, +4 scanlines
```

Per frame, in this order (`LASR`, `defa7.src:2792`):

```
1. if (STATUS & 0x40) -> die
2. right: if ((head >> 8) >= 0x98) -> die        left: if ((head >> 8) <= 0x05) -> die
3. for k in 0..3:  fb.poke(head, 0x11);  head += dir * 0x100
4. fb.poke(head, 0x99)                            // white tip at the 5th column
5. if (fisx > 32 - 3) fisx = 0
   for k in 0..2:  fb.poke(spark, FISTAB[fisx++]);  spark += dir * 0x100
6. fb.poke(tail, 0);  tail += dir * 0x100
7. collide with LASP1 (8 byte-columns x 1 row) at:
        right: head - 0x0600         left: head
   if hit -> die
8. NAP 1
```

Death (`LRDIE`/`LLDIE`): sweep `tail` toward `head` writing `$00`, then `lflg--`.

Net motion per frame: head **+4 byte-columns = 8 px**, exhaust **+3 = 6 px**, eraser
**+1 = 2 px**. The beam therefore **lengthens by 6 px every frame**. That head/tail asymmetry is
the Defender laser.

`FISTAB` generation (`FISS`, once per life):

```
for (let i = 0; i < 32; i++) {
  const a = rand();                       // the game RNG's A return
  let b = 0;
  if (a & 1) b |= 0x01;
  if (a & 2) b |= 0x10;
  FISTAB[i] = b;
}
```

Each byte is `$00`, `$01`, `$10` or `$11` — two independent bits, one per pixel, both in
palette index 1. One quarter of the exhaust bytes are blank, which is what makes the trail
sparkle rather than glow.

---

## 9. TERRAIN

### 9.1 Data

`TDATA`, 256 bytes at `$C350` (bank 7). Copy verbatim from
`assets/original-derived/terrain/TDATA.json`. sha1 `8e79db98772f0f546bf322233b17ed97699ad36a`.
Exactly 1024 set and 1024 clear bits; the walk closes on row 224 with no seam.

### 9.2 The ring buffers

Two "flavour" tables, `TERTF0` and `TERTF1`, each **152 entries of 3 bytes**, each entry written
**twice** at a `$1C8` (456-byte) offset so the reader never needs a wrap test.

```js
class TerrainRing {
  constructor() {
    this.buf = new Uint8Array(0x390);   // 912 bytes = 2 x 456
    this.ptr = 0;                       // byte offset of the write cursor, 0..455
  }
}
// entry layout: [ scanline, patternHigh, patternLow ]
```

`BGL` **bit 5** (one pixel) selects which ring is read; both are maintained.

### 9.3 Per-frame — `BGOUT`, executed in step A4

```
BGOUT():
  const delta = ((((BGL & 0xFFE0) - BGLX) & 0xFFFF) << 3) & 0xFFFF;
  let steps = (delta >> 8) & 0xFF;                    // signed byte: pixels scrolled
  if (steps & 0x80) steps -= 256;

  while (steps > 0) { BGLX = (BGLX + 0x20) & 0xFFFF; addRightColumn(); steps--; }
  while (steps < 0) { BGLX = (BGLX - 0x20) & 0xFFFF; addLeftColumn();  steps++; }

  BGLX = BGL & 0xFFE0;
  const ring = (BGLX & 0x20) ? ring0 : ring1;         // BITB #$20, BNE -> flavour 0
  // ⚠ PHASE 4: every `BGLX` in this routine is the TERRAIN MODULE'S OWN cell at $A015
  // (blk71.src:30-47, inside phr6.src:214's `BGSAV RMB 32` reservation), NOT the system
  // BGLX at $A022. ROM: $C092 `DC 20 C4 E0 93 15`, $C0C2 `DC 20 C4 E0 DD 15` — direct
  // page $15, and `DD 15`/`93 15` occur nowhere outside defend.6 bank 7. The core models
  // it as `state.bglxt`. BGOUT NEVER WRITES THE SYSTEM BGLX; C-02's "two writers" ruling
  // conflated the two cells and has been re-adjudicated.
  let p = ring.ptr;
  for (let col = 0; col < 152; col++) {
     fb.poke16(STBL[col], 0);                          // erase what was there
     const row  = ring.buf[p];
     const patt = (ring.buf[p+1] << 8) | ring.buf[p+2];
     p += 3;
     // ⚠ PHASE 4 CORRECTION (R-49). This line is WRONG: it walks columns 151..0. ROM
     // $C0DA is `LDA #$98 / STX [,Y] / PULS B,U / STD ,Y / STU [,Y++] / DECA`, unrolled
     // eight times, and the STORE happens BEFORE the DECA — so the first column written
     // is $98 = 152 and the last is $01. Column 0 never carries terrain and column 152
     // is one past the last scanned column. Confirmed by silhouette: max deviation from
     // ALTBL[col-1] is 1 across all 152 columns under `world pixel = 2*(col-1)`.
     const addr = ((0x98 - col) << 8) | row;           // col runs 1..152, high to low
     STBL[col] = addr;
     fb.poke16(addr, patt);
  }
```

> **Note on the column counter.** The original's inner loop uses `A` as *both* the loop counter
> and the high byte of the screen address, counting down from `$98` — this is the trick that
> makes the loop eight-way unrollable with a single `DECA`. Column `col` of the output therefore
> lands at byte column `0x98 - 1 - col`, and the ring is read forwards. Verify against the
> silhouette in `TERRAIN_PROFILE.md` §4 before trusting either direction; §13 test R-31 pins it.

Adding a column (`ADDR01`, scrolling right):

```
addRightColumn():
  bit = nextTerrainBit(rightWalker);        // MSB-first through TDATA, wrapping at 256 bytes
  const ring = (BGLX & 0x20) ? ring0 : ring1;
  ring.ptr = (ring.ptr - 3 + 456) % 456;    // note: the RIGHT side moves the cursor BACKWARDS
  if (bit) {                                // bit set = UP
     roff -= 1;
     writeEntry(ring, ring.ptr, roff, 0x0770);
  } else {                                  // bit clear = DOWN
     writeEntry(ring, ring.ptr, roff, 0x7007);
     roff += 1;
  }
  // writeEntry stores at ptr AND at ptr+0x1C8 (the double mapping)
```

`ADDL01` (scrolling left) is the mirror: it advances the cursor **forwards** and uses the
opposite pattern for the same bit sense, because a step that is "up going left" is "down going
right".

Each byte-column therefore lights exactly **two pixels** in palette index 7 (brown), arranged
diagonally:

```
  $7007 ->  #.        $0770 ->  .#
            .#                  #.
```

### 9.4 What terrain does not do

**The player has no terrain collision** (L-144). `GETALT` is never called on any player path.
The ship flies through the mountains. Any Enhanced-Mode terrain treatment that reads as solid
must not create the expectation of collision — see `VISUAL_ENHANCEMENT_SPEC.md` §7.

---

## 10. STARS

```js
const STARS = 16;
class Star { constructor(){ this.col=0; this.row=0; this.colour=0; } }
let strcnt = 16;              // cut to 3 under CPU overload
```

Initialization (`STINIT`, once per life):

```
let c = 0;
for (let i = 0; i < 16; i++) {
  do { col = rand(); } while (col >= 0x9C);
  do { row = rand(); } while (row > 0xA8 || row <= 42);      // YMIN = 42
  star[i] = { col, row, colour: c };
  c = (c + 0x11) & 0x77;                                     // 0,$11,...,$77,0,...
}
```

`colour` values `$00`, `$11`, … `$77` are byte patterns, not indices: they set the same nibble
in both halves so the phase mask picks one. **Stars 0 and 8 have color `$00` and are therefore
permanently invisible.** Reproduce that.

Per frame (`STOUT`, step C3), in this order:

```
1. if (STATUS & 0x20) return;
2. movement = (((((BGLX & 0xFF80) - (BGL & 0xFF80)) & 0xFFFF) << 1) >> 8) & 0xFF;   // signed
   // ⚠ PHASE 4 CORRECTION (R-53). THE OPERANDS ARE THIS WAY ROUND, not BGL minus BGLX.
   // ROM $E086: `8E AF 9D / DC 20 C4 80 DD 6F / DC 22 C4 80 93 6F / 58 49 / 97 6F`
   //          = ITEMP = BGL & $FF80 ; D = (BGLX & $FF80) - ITEMP ; ASLB/ROLA ; ...
   // With the order this spec previously gave, the stars scroll the wrong way.
3. phaseMask = (BGL & 0x40) ? 0xF0 : 0x0F;
4. for all 16 stars (NOT strcnt): fb.poke((col << 8) | row, 0);      // erase, whole byte
5. for i in 0 .. strcnt-1:
       col = (col + movement) & 0xFF;
       if (col >= 0x9C) col = (col > 0xC0) ? 0x9B : 0x00;
       fb.poke((col << 8) | row, colour & phaseMask);
```

Step 2 gives **1 byte-column (2 px) of star movement per 4 px of terrain scroll — exactly half
the terrain rate, a 2:1 parallax** (L-078).

Step 4 erases **all sixteen** even when only 3 are drawn, and it clears the **whole byte**,
wiping both pixels — including any object pixel that shares the byte. This is an authentic
artifact and it is visible as a one-frame hole punched in a sprite that a star passes through.

`SBLNK` re-randomises one star's color per invocation (`SEED & $3C` picks the star, color
`+$11 & $77`) and also contains the anti-tamper trap (L-084) which a reconstruction may omit.

---

## 11. HUD

All positions are screen addresses `(byteColumn << 8) | scanline`.

### 11.1 Layout

| Field | Address | Column | Row | Rule |
|---|---|---|---|---|
| P1 score | `$0F1C` | 15 | 28 | 6 digits, pitch `$0400` = 4 byte-columns |
| P2 score | `$711C` | 113 | 28 | as above |
| P1 lives | `$0F14` | 15 | 20 | ≤ 5 icons, pitch `+$0600` = 6 byte-columns, **horizontal** |
| P2 lives | `$7114` | 113 | 20 | as above |
| P1 smart bombs | `$291B` | 41 | 27 | ≤ 3 icons, pitch `+$04` = 4 **scanlines**, **vertical** |
| P2 smart bombs | `$8B1B` | 139 | 27 | as above |
| Status-band clear | `$3008`, `W=$40 H=$20` | 48–111 | 8–39 | `TDISP` |
| Divider line | column 0 … 155, rows 40–41 | — | 40–41 | `$5555`, blue |

### 11.2 Score transfer — `SCRTR0`

> **CORRECTED — `CLASSIC_MODE_CONTRACT.md` C-03.** The score is stored in **four** bytes
> (`phr6.src:442` `P1SCR RMB 4`); `SCORE` (`defa7.src:485-505`) propagates the BCD carry across all
> four. Only bytes 1..3 are *displayed*: `SCRTR0` loads `LDU #P1SCR+1` and renders six digits. The
> display claim below was right; the state model ("3 bytes") was wrong.

```
SCRTRN(player):
  base   = (player == 1) ? 0x0F1C : 0x711C;
  bcd    = score[1..3]                                   // a 3-byte WINDOW into the 4-byte
                                                         // Uint8Array(4) of CLASSIC_MODE_CONTRACT
                                                         // §3.12; P1SCR+1 .. +3, MSB first
  seen   = 0;                                            // XTEMP, the "non-zero seen" flag
  addr   = base;
  for (let b = 6; b >= 1; b--) {                         // b counts 6 down to 1
     const digit = (b & 1) ? (bcd[(6-b) >> 1] & 0x0F) : (bcd[(6-b) >> 1] >> 4);
     if (digit == 0 && b > 2 && !seen) {
        eraseGlyph(addr, CHRTBL[6]);                     // COFF with the '0' descriptor
     } else {
        seen = 1;
        drawGlyph(addr, CHRTBL[6 + digit]);              // CWRIT, phase 0 only
     }
     addr = (addr + 0x0400) & 0xFFFF;
  }
```

Consequences to assert: a score of 0 shows **`00`**, not blank and not `000000`. The **displayed**
score wraps at 1,000,000, because only bytes 1..3 are rendered; the **stored** score does not wrap
until 99,999,999, where `SCORE`'s carry out of byte 0 is discarded. `RCHK`'s replay-threshold
comparison also uses only bytes 1..3, so bonus ships restart their cycle at 1,000,000 too (C-03).

### 11.3 Lives — `LDSP`

```
LDSP(addr, count):
  if (count > 5) count = 5;
  blockClear(addr, W = 0x20, H = 6);            // 32 byte-columns x 6 scanlines
  for (let i = 0; i < count; i++) {
     cwrit(addr, PLAMIN);                       // 10 x 4 px, static, phase 0
     addr = (addr + 0x0600) & 0xFFFF;           // ADDA #$06 -> six byte-columns right
  }
```

### 11.4 Smart bombs — `SBDSP`

```
SBDSP(addr, count):
  if (count > 3) count = 3;
  blockClear(addr, W = 3, H = 0x0B);            // 3 byte-columns x 11 scanlines
  for (let i = 0; i < count; i++) {
     cwrit(addr, SBPIC);                        // 6 x 3 px, static
     addr = (addr + 0x04) & 0xFFFF;             // ADDB #4 -> four SCANLINES down
  }
```

> **`ADDA` is columns; `ADDB` is scanlines.** Ships go across, bombs go down. This is the most
> commonly wrong detail in Defender reconstructions.

### 11.5 Text color

**Every glyph is drawn in palette index 1**, the slot `COLR` is cycling every 2 frames. The
score, wave banners, the Hall of Fame and the floating "250" all change color continuously, in
lock-step with the laser. The floating "500" is different: it uses indices D/E/F and therefore
follows `TIECOL` instead.

### 11.6 Border — `BORDER`

```
1. $5555 at rows 40-41 of every byte column 0..155                    (the playfield divider)
2. $5555 at rows 8..39 of byte column 47 and of byte column 112       (scanner side walls)
3. $55   at row 7 of byte columns 47..112                             (scanner top rail)
4. $9999 at rows 7-8 and rows 40-41 of byte columns 76..83            (screen-window bracket)
```

`TDISP` is the composite: `BLKCLR($3008, W=$40, H=$20)` → `BORDER` → `LDISP` → `SBDISP` →
`SCRTR0` per player.

---

## 12. PRESENTATION OPTIONS

All three modes consume the same finished 292 × 240 buffer. **None may read core state, none
may write core state, and switching between them mid-game must not change a replay's result.**

### 12.1 `clean`

The default. The scaling chain of §3.2 and nothing else. This is the mode fidelity screenshots
are taken in.

### 12.2 `raster`

Adds, on the presented surface only:

| Element | Rule |
|---|---|
| Scanlines | multiply every odd **source scanline** row band by `1 − scanlineGain` (default 0.30). Applied at the intermediate stage where `N` is known, so the bands are always an integer number of device pixels. |
| Line bloom | a 1-device-pixel vertical blur at 20 % weight, applied only to pixels whose luminance exceeds 0.6. Simulates the beam's vertical spot size. |

Not permitted in `raster`: curvature, mask, persistence, chromatic separation.

### 12.3 `crt`

Everything in `raster`, plus:

| Element | Default | Rule |
|---|---|---|
| Aperture-grille mask | `maskStrength` 0.20 | a 3-device-pixel RGB triad, strength scaled by `1/N` so it fades out rather than aliasing at small sizes |
| Phosphor glow | `glow` 0.35 | separable Gaussian, σ = 1.2 device px, added (not blended) |
| Curvature | `curvature` 0.06 | barrel, applied in the final draw; **the 4:3 box does not change size**, the image is inset by the maximum displacement so nothing is cropped |
| Persistence | `persistence` 0.25 | previous presented frame blended in at 25 %; **held in the renderer, never in the core**; cleared on any state discontinuity (hyperspace, wave change, reset) |
| Overscan | `overscanCrop` 0 | if non-zero, crop *n* pixels per edge and re-fit. Default 0 — the reconstruction targets the *signal*, which is the only reproducible thing. |

**Hard rules for all modes:**

1. No effect may change the number of `step()` calls, their inputs, or their outputs.
2. No effect may be enabled by default other than `clean`.
3. Every effect must be individually defeatable, and a single "reduce motion / reduce effects"
   control must set all of them to zero.
4. Persistence must be disabled when `prefers-reduced-motion: reduce` is set.
5. A screenshot taken in `crt` mode may not be used as fidelity evidence.

---

## 13. ACCEPTANCE TESTS

Deterministic and executable. These become the Phase 3 test suite verbatim. `fb` is a fresh
`Framebuffer`; `pal` a fresh `PaletteState`.

### Framebuffer model

- **R-01** `fb.poke(0, 0xAB)` → `fb.getPixel(0,0) === 0xA` and `fb.getPixel(1,0) === 0xB`.
- **R-02** `fb.poke16(0x0500, 0x1234)` → `fb.b[0x0500] === 0x12` and `fb.b[0x0501] === 0x34`;
  and `fb.getPixel(10, 0) === 1`, `fb.getPixel(11, 0) === 2`, `fb.getPixel(10, 1) === 3`,
  `fb.getPixel(11, 1) === 4`. (Byte column 5 → pixels 10 and 11; the second byte is the **next
  scanline**, not the next column.)
- **R-03** For all `x` in 0…303 and `y` in 0…255, the address of pixel `(x,y)` is
  `(x>>1)*256 + y`, and that address is `< 0x9800` exactly when `x < 304`.
- **R-04** `fb.poke(0xFFFF, 1)` does not throw and does not modify any byte in `0…0x97FF`.

### Palette

- **R-05** `paletteByteToRGB(0xFF)` deep-equals `[255,255,255]`.
- **R-06** `paletteByteToRGB(0x00)` deep-equals `[0,0,0]`.
- **R-07** `paletteByteToRGB(0xC7)` deep-equals `[255,0,255]`.
- **R-08** `paletteByteToRGB(0xA4)` deep-equals `[137,137,160]` — **not** neutral gray.
- **R-09** `paletteByteToRGB(0x15)` deep-equals `[174,81,0]`.
- **R-10** `[0..7].map(i => chan(i, W_RG))` deep-equals `[0,38,81,118,137,174,217,255]`.
- **R-11** `[0..3].map(i => chan(i, W_B))` deep-equals `[0,95,160,255]`.
- **R-12** Run 72 frames of `COLR` from a fresh `PaletteState` with `colrTimer = 1`. Assert
  `pcram[1]` takes the values `COLTAB[0..35]` in order, each held for exactly 2 frames, and that
  `pcram[1] === 0x00` on **no** frame. On frame 73 it is `COLTAB[0] = 0x38` again.
- **R-13** Run 18 frames of `TIECOL` with `tieTimer = 1`. Assert `[pcram[0xD],pcram[0xE],pcram[0xF]]`
  is `[0x81,0x81,0x2F]` on frames 1–6, `[0x81,0x2F,0x07]` on 7–12, `[0x2F,0x81,0x07]` on 13–18,
  and `[0x81,0x81,0x2F]` again on frame 19.
- **R-14** After `CBOMB` fires, `pcram[0xA] === pcram[0xC]`, always, for 600 frames with any seed.
- **R-15** Writing `pcram[2] = 0x28` does not change `cram[2]` until the next `step()`; after one
  `step()` `cram[2] === 0x28`.

### Sprite blit and pre-shift

- **R-16** Decode `player-right` phase 0. Assert width 16 px, height 6, and that row 4 (0-based)
  equals `[2,6,8,8,8,8,6,6,6,6,6,6,6,9,3,0]`.
- **R-17** Decode `player-left` phase 1 (`PLD21`). Assert `grid[3][4] === 0xF` and
  `grid[4][4] === 6` and `grid[4][5] === 6`. (With the mwenge `$C300` byte these would be `0xC`
  and `3`. This test is the `defend.11` / `defend.12` guard.)
- **R-18** For all 24 two-phase pictures except `SCZP1`, `TIEP4`, `LNDP3`, `PLAPIC`:
  `phase1[r][c] === (c === 0 ? 0 : phase0[r][c-1])` for every `r`, `c`.
- **R-19** For the four exceptions, assert the exact deviating pixels:
  `SCZP1.p1[7][0] === 3` and `SCZP1.p1[7][1] === 0`; `TIEP4.p1[7][2] === 8`;
  `LNDP3.p1[4][4] === 0`; `PLAPIC.p1[3][2] === 2`.
- **R-20** Blit `humanoid-l1` phase 0 at screen address `$1050` (byte column 16, row 80). Assert
  `fb.b[0x1050] === 0x33`, `fb.b[0x1057] === 0x07`, `fb.b[0x1150] === 0x00`,
  `fb.b[0x1152] === 0x80`. Then erase and assert all 16 bytes are 0.
- **R-21** Blit `player-right` at column 150 with `W = 8`: `150 + 8 = 158 > 0x9C` → the draw is
  **skipped** and `OBJX` stays 0.
- **R-22** With `BGL = 0x1000` and `OX16 = 0x1000 + 9600`, `OPON` skips the object; with
  `OX16 = 0x1000 + 9599` it draws at byte column 149.

### Frame order

- **R-23** In a single `step()`, record the call order. Assert exactly:
  `latch, BGOUT, PRDISP(A), OPROC(A), VELO, EXEC, SNDSEQ, PLAYER, STOUT, OPROC(B), PRDISP(B), SHELL`.
- **R-24** An object at row 100 is drawn only in pass B; an object at row 200 only in pass A; an
  object at row 118 in **both**. After the frame, all three have `OBJX !== 0`.
- **R-25** `BAND_B.upper` is 120 and `BAND_A.lower` is 0x70 = 112, so `BAND_A.lower < BAND_B.upper`:
  assert `BAND_B.upper > BAND_A.lower` — the no-gap invariant.
- **R-26** A process created by `MKPROC` during the executive pass runs in the **same** pass
  (assert `COLCHK`'s `PLEND` executes in the frame it is created).

### Explosions

- **R-27** `SIZE(n)` for `n = 1…72` equals `[1,2,2,3,4,4,5,6,6,7,8,8,…,47,48,48]`; specifically
  `SIZE(1)===1, SIZE(2)===2, SIZE(3)===2, SIZE(4)===3, SIZE(6)===4, SIZE(71)===48, SIZE(72)===48`,
  and `SIZE(73) > 0x30` so the slot frees on frame 73.
- **R-28** Explode a `lander-1` (`W=5, H=8`) whose `topLeft` is `$3E7C`, with `CENTMP` equal to
  the geometric center `$4080` (so `XOFF = 2`, `YOFF = 2`, `FLAVOR = 0`). On frame 1
  (`SIZE = 1`, `DSIZE = 2`) assert the drawn tiles reproduce the source bitmap exactly: for
  every `c` in 0…4 and `k` in 0…3 **except (c = 2, k = 2)**,
  `fb.b[((0x3E + c) << 8) | (0x7C + k*2)] === LND10[c*8 + k*2]` and
  `fb.b[((0x3E + c) << 8) | (0x7C + k*2 + 1)] === LND10[c*8 + k*2 + 1]`.
  For `(c = 2, k = 2)` — the center block — assert both bytes are **`0`**: the final
  center-erase punches a 2 × 2 pixel hole at the impact point.
- **R-29** Same explosion, at the frame where `SIZE` first reaches 24 (`n = 35`): assert exactly
  20 tiles (5 columns × 4) are written, that adjacent tiles within a column are
  `DSIZE = 2*SIZE = 48` scanlines apart, and that adjacent columns are `SIZE = 24` byte-columns
  apart — i.e. 48 pixels apart in both axes.
- **R-30** Fill all 16 slots with appears, then call `EXST`. Assert no slot changes and no tile
  is drawn — the explosion is silently dropped (L-074).
- **R-31** Start an appear. Assert `SIZE` runs 46, 45, … 1, 0 over 47 frames, that on frame 47
  every tile address is within 2 byte-columns and 2 scanlines of `center`, and that on frame 48
  the object's original picture has been restored and `OTYP & 2` is clear.
- **R-32** Explode an object at `(OX16 − BGL) >> 8 === 0x27`. Assert `EXST` returns without
  allocating.
- **R-33** Explode `mine-1` (`W=2, H=3`, odd height). Assert 2 tiles of 2 rows plus 2 single-row
  bytes are written per frame, 4 erase-table entries in total.

### Player death

- **R-34** `nextLFSR(0x0808)` — assert the first 8 outputs are stable and reproducible, and that
  the sequence has period > 1000 (it must not lock at 0).
- **R-35** Initialize 128 particles from seeds `$0808`/`$1732`. Assert **every** particle
  satisfies `(|vy| >> 1) + |vx| < 0x016A` using **one's**-complement magnitudes.
- **R-36** Run `PLEX` to completion. Assert exactly **108** frames draw particles, the 109th
  reads `PXCOL[14] === 0` and returns, and `pcram[0xB]` was `0xFF` for frames 1–56, `0x7F` for
  57–60, `0x02` for 105–108.
- **R-37** A particle whose `x` low byte is `< 0x80` writes `0xBB` at `addr` and `addr+1` and
  nothing at `addr+0x100`; one whose low byte is `>= 0x80` writes `0x0B,0x0B` at `addr..addr+1`
  and `0xB0,0xB0` at `addr+0x100..+0x101`.
- **R-38** A particle stepping to row `0x29` is removed and its previous cell is left cleared.

### Scanner

- **R-39** With `BGL = 0`, the five test points of §7.3 map to columns 48, 75, 80, 75, 75.
  Specifically assert `scannerColumn(0xFFFF) === scannerColumn(0x0001) === 75` — **the seam
  test**.
- **R-40** With `BGL = 0xFF00` and `OX16 = 0x0100`, `scannerColumn === 75`.
- **R-41** `scannerRow(OY16 = 0x2A00) === 12` and `scannerRow(0xEE00) === 36`; both are inside
  the interior 8–39.
- **R-42** Advance 8 frames; assert `SCNRV` ran exactly once. Advance 800; assert exactly 100.
- **R-43** Place a Lander on the inactive list only. Assert its blip is drawn.
- **R-44** Assert the scanner's blip write is `poke16(addr, OBJCOL)` — the two color bytes land
  on scanlines `r` and `r+1` of the **same** byte column.
- **R-45** Move the ship from screen pixel 16 to pixel 272. Assert the player marker column runs
  75 → 83 monotonically, and that at no point is it outside 75…84.
- **R-46** Assert `MTERR[i] === MTERR[i+64]` for all `i` in 0…63 (all three bytes).

### Terrain

- **R-47** `TDATA` has exactly 1024 set bits, and the ±1 walk over all 2048 bits returns to 224.
- **R-48** `ALTBL[i]` is even for all 1024 `i`; `min === 160`, `max === 232`;
  `sha1(ALTBL) === '799b45320dfc169fcc2eb65a3cc152b9487b915c'`.
- **R-49** After `BGINIT` and one `BGOUT` with no scroll, exactly 152 byte-columns hold a
  2-scanline brown segment, and every segment's row is within 1 of `ALTBL[worldPixel >> 1]`.
- **R-50** Scroll one full world lap (2048 px) one pixel at a time. Assert the terrain rendered
  at the end is byte-identical to the terrain rendered at the start — **no seam**.
- **R-51** Every terrain word written is `0x7007` or `0x0770`, never anything else.

### Stars

- **R-52** Stars 0 and 8 have `colour === 0` and write `0x00` — they are invisible.
- **R-53** Scroll 4 px; assert every star's byte column advanced by exactly 1 (2 px), i.e. half
  the terrain rate.
- **R-54** Draw a sprite, then step a star through it. Assert the star's erase writes `0x00` to
  the whole byte, punching a 2-pixel hole in the sprite for one frame.
- **R-55** Set `OVCNT = 2`; assert `strcnt === 3` and that only 3 stars are drawn while all 16
  are still erased.

### Laser

- **R-56** Fire right from `NPLAXC = 0x2050`. Assert `head === spark === tail === 0x2754`.
- **R-57** After one frame: `head === 0x2B54`, `spark === 0x2A54`, `tail === 0x2854`. Bytes, in
  the order the four write phases leave them: `fb.b[0x2754] === 0x00` (written `0x11`, then a
  sparkle byte, then **erased** by the tail in the same frame), `fb.b[0x2854] === FISTAB[1]`,
  `fb.b[0x2954] === FISTAB[2]`, `fb.b[0x2A54] === 0x11`, `fb.b[0x2B54] === 0x99`.
  This test exists because the write order — body, head, sparkle, erase — is not obvious and
  getting it wrong leaves a stale bright pixel at the muzzle.
- **R-58** After 10 frames the lit run is `4*10 = 40` byte-columns long minus the 10 erased at
  the tail = **30 byte-columns = 60 px**. Assert the beam grew by 6 px per frame.
- **R-59** Fire 4 lasers; assert the 5th `LFIRE` is refused and `lflg === 4`.
- **R-60** Every `FISTAB` byte is in `{0x00, 0x01, 0x10, 0x11}`.
- **R-61** Firing right, the collision box's top-left is `head − 0x0600`; firing left it is
  `head`. Assert the right-hand box lags the visible head by exactly 12 px.

### HUD

- **R-62** Score 0 renders exactly two `'0'` glyphs at byte columns 31 and 35, and erases columns
  15, 19, 23, 27.
- **R-63** Score 102030 renders `1 0 2 0 3 0` at byte columns 15, 19, 23, 27, 31, 35 with no
  erasure.
- **R-64** 6 lives renders **5** icons at byte columns 15, 21, 27, 33, 39, all at row 20.
- **R-65** 4 smart bombs renders **3** icons all at byte column 41, at rows 27, 31, 35.
- **R-66** Every glyph byte written lands at an even pixel column — assert no glyph is ever
  drawn with a phase offset.
- **R-67** All score glyphs use palette index 1 (assert every non-zero nibble written by
  `SCRTR0` is `1`).

### Presentation

- **R-68** For a 1920 × 1080 viewport, the box is 1440 × 1080, `N = 4`, and the horizontal
  scale is 1440/292 = 4.9315 — **not** an integer, and that is correct.
- **R-69** For a 1000 × 1000 viewport, the box is 1000 × 750 (letterbox), `N = 3`.
- **R-70** For a 3440 × 1440 ultrawide, the box is 1920 × 1440 and the bars are 760 px each side.
- **R-71** For a 390 × 844 portrait phone, the box is 390 × 292 and the rotate overlay is shown.
- **R-72** Running 600 `step()`s in `clean` and 600 in `crt` from the same seed and input
  sequence produces **identical** framebuffers at every frame.
- **R-73** Changing any `Presentation` field mid-run does not change any `step()` output. Assert
  by hashing the framebuffer each frame in both runs.
- **R-74** The Classic renderer module has **zero** imports from `src/core/`, and `src/core/`
  has zero imports from `src/render/`. Assert by static scan of the import graph.

---

## 13A. PERFORMANCE — the Classic budget

Added in iteration 4 (SYS-09). Before this, the only frame-cost budget in the corpus was
`VISUAL_ENHANCEMENT_SPEC` §6's GPU budget — which covers **Enhanced**, a Phase 6 mode that is not
the default. The Classic path is the default and does strictly more CPU work: the whole original
software rasteriser re-implemented in JS (`BGOUT` terrain, `STOUT` stars, `OPROC` ×2, `PRDISP` ×2,
`SHELL`, `SCNR`, `EWRITE`/`EERASE`, HUD glyphs — byte at a time into a 39 936-byte framebuffer),
then a 70 080-pixel palette-LUT expansion, then the `present()` chain of §3.2.

### 13A.1 Reference machine and budget

Reference: **Apple M1 (8 GB) in Chrome stable, and an 11th-gen Intel Iris Xe in Firefox stable**, at
the default 4:3 box on a 1440p display, `clean` presentation, no overlays. Budget of the 16.640 ms
frame:

| Stage | Budget (mean) | Budget (99th pct) |
|---|---|---|
| `core.step()` | **≤ 4.0 ms** | ≤ 8.0 ms |
| framebuffer → RGBA (palette LUT expansion) | ≤ 1.0 ms | ≤ 2.0 ms |
| `present()` (two `drawImage` calls) | ≤ 1.0 ms | ≤ 2.0 ms |
| **total main-thread work per frame** | **≤ 8.0 ms** | ≤ 12.0 ms |

Exceeding the mean budget on the reference machines is a **release blocker**, not a nice-to-have:
`SHELL_AND_LIFECYCLE_SPEC` §6.3 throttles the simulation once the host cannot sustain four steps per
callback (SYS-07), so a slow `step()` does not merely look bad, it makes the game run slower than
wall clock.

### 13A.2 Event-object allocation policy

`CLASSIC_MODE_CONTRACT` §5 mandates plain event objects that may never be reordered, deduplicated or
coalesced, and §5.4 emits `EXPLOSION_STEP` / `APPEAR_STEP` / `LASER_STEP` / `STARS` per active slot
per frame. That is up to ~40 objects per frame, 2 400/s, allocated and discarded.

**Policy — pooling that preserves order and identity.** `state.events` is a pre-allocated array of
reusable records plus a `length` cursor. `step()` writes into `events[n]` and increments `n`;
draining sets `n = 0` without clearing the backing store. Consumers must read the stream **within
the tick that produced it** (they already do — the host drains after each `step()`); anything that
needs an event to outlive the tick copies it. Order and identity are unchanged, because the cursor
is monotonic within a tick and records are handed out in emission order. Records are never resized:
each `kind` has a fixed field set, unused fields hold `0`/`null`.

### 13A.3 Tests

| Id | Assertion |
|---|---|
| `PRF-01` | Over a 30 000-tick headless replay: mean `step()` ≤ 4.0 ms and 99th percentile ≤ 8.0 ms on the reference machine. Report both; fail on either. |
| `PRF-02` | No allocation growth: sample the heap at tick 100 and tick 10 000 of the same replay; the delta must be < 4 MB and must not scale with tick count (run at 10 000 and 20 000 and compare slopes). |
| `PRF-03` | `render()` performs no layout: over 600 frames, the count of forced reflows attributable to the render path is **0**. |
| `PRF-04` | The Lab overlay's `frame ms` readout turns red above the 99th-percentile budget, so the number has a threshold attached rather than being decoration. |

---

## 14. KNOWN DEVIATIONS AND OPEN ITEMS

### 14.1 Deliberate deviations

| # | Deviation | Why | Interim rule | What would remove it |
|---|---|---|---|---|
| D-1 | `XXX2` fixed at **120** instead of tracking the beam | the core has no cycle model | recompute `xxx2 = min(vertctAtService − 8, 0xA8)` every frame from a `vertctAtService` fixed at 128, so the arithmetic is present and Lab-forceable (`CLASSIC_MODE_CONTRACT` §2.3.1) | a cycle-accurate 6809. §5.3 proves the *union* of the bands is rows 1..255 for any `XXX2`, so nothing is skipped or doubled; what a moving `XXX2` changes is which pass draws a row, i.e. whether it carries a frame of position lag (C-25) |
| D-2 | The two draw passes are **serialised** around the executive pass rather than interleaved by interrupt | determinism | the order in §5.2 | nothing — an interrupt model would make replays non-reproducible, which the architecture forbids |
| D-3 | **No CPU-load model**, so `OVCNT` never rises on its own and the star field never drops to 3 and the executive never culls an object | drawing is instantaneous in JS | expose `OVCNT` as a debug-forceable value so the behavior can be tested and demonstrated; leave it at 0 in normal play | a cost model that charges per drawn byte and compares against a frame budget |
| D-4 | The renderer holds an `ImageData` and a persistence buffer | performance | both are derived and discardable; test R-73 asserts they cannot affect the core | nothing |
| D-5 | The anti-tamper trap `SBLNK` (L-084) is not implemented | it corrupts RAM by design | omit | nothing; recorded so that a bit-exact RAM trace against a patched ROM is known to diverge here |

### 14.2 Open items

| # | Item | Interim behavior | How to settle |
|---|---|---|---|
| O-1 | **Terrain output column direction.** The original's inner loop uses `A` as both loop counter and screen-address high byte, counting **down** from `$98`, while the ring buffer is read forwards. §9.3 states one direction; the other is possible. | Implement §9.3 as written, then render one screen at `BGL = 0` and compare against the silhouette in `TERRAIN_PROFILE.md` §4. If the mountains are mirrored, reverse the ring read, not the column order. | Tests R-49 and R-50; or a MAME capture at a known `BGL`. |
| ~~O-2~~ | **The thrust flame. RESOLVED, Phase 5.** `PRDISP` $E213 selects on the sign of `PLADIR` for the erase and of `NPLAD` for the draw, with the `PLAXC:PLAYC` commit between them. `PLADIR >= 0` = facing RIGHT = `POUT`/`PLAPIC` + `THOUT` $E15C, whose base is `PLAXC-$100+1` and which walks to `-$300` — byte-columns `PLAXC-1 … -4`, to the ship's LEFT. `PLADIR < 0` = facing LEFT = `POUT1`/`PLBPIC` + `THOUT1` $E19B, base `PLAXC+$801` walking to `+$B00` — columns `+8 … +11`, to its RIGHT. The `BITA #$02 / BEQ` thrust test sits after the first three stores, so idle is 5 bytes in one column and thrust adds three narrowing runs (12 bytes, 4 columns = half the ship's width). `THOUT` takes INDEXED picks off `THTAB` (`,X`, `5,X`, `9,X`, `12,X`, `3,X`, `6,X`, `10,X`, `4,X`, `7,X`, `11,X`, `8,X`); `THOUT1` takes them SEQUENTIALLY via three `PULU D,Y`, a genuinely different mapping — the two are not mirror images and cannot share a routine. Erases are `THOFF` $E1F0 and `THOFF1` $E1CD, both unconditional over all **twelve** bytes — `PSHU B,X,Y` is 1+2+2 = 5 and the five explicit stores are 2+1+2+1+1 = 7, matching THOUT's twelve exactly, as it must or a byte would strand on every thrust release; `POFF`'s 8×6 block does not reach them. `THPROC` $E9BF advances `THX` over 0..32 inclusive (`BLS`, so 33 phases) every 4 frames. All offsets verified against the Red Label image with `tools/rom_peek.py`. | Implemented — `src/core/draw/index.js`, `tests/thrust.test.js` THR-01..THR-51. | Nothing. Residual: `CBOMB`/`TIECOL` are still unimplemented, so 7 of the 16 palette slots the flame's unmasked random nibbles select are dark — see `KNOWN_DEVIATIONS` `D-SCOPE-3b`. |
| O-3 | **`EWRITE`'s odd-height final byte.** For `H = 3` (mines only) the `ODD` path writes a single byte; whether the erase table entry is a 1-byte or 2-byte erase is ambiguous in the source. | Erase 2 bytes (`poke16`) — the erase routine is uniform (`STD [,X++]`) and does not know the tile size. | Trace `EERASE` against a MAME capture of a shot mine. |
| O-4 | **The appear's "phoney center" rounding.** `LDB #$DA / MUL / ASLA / LDB W / MUL` — the intermediate truncation at `W = 2` (`ASTP*`, `BMBP*`) may round differently than §6.4 states. | Implement the byte-level arithmetic literally, with 8-bit `MUL` producing a 16-bit result and `ASLA` acting on the high byte only. | A single-step trace of `EXPU7` in a 6809 interpreter, which `gen_altbl.py` already contains the skeleton of. |
| O-5 | **Humanoid rendered ground contact** (Q-01a, carried from `TERRAIN_PROFILE.md` §7). The arithmetic places a walking humanoid's 8-row sprite wholly beneath the 2-row terrain line. | Draw exactly what the arithmetic says. Do not "fix" it into looking right. | A VIDEO_MEASURED reading of the pixel gap on the plain and on the massif, for both walk directions. |
| O-6 | **Two-player HUD mirror positions.** `P2DISP`, `P2LAT`, `P2SBD` are read from `phr6.src` but never verified against a running two-player game. | Implement as stated. | A MAME capture of a two-player game. |
| O-7 | **Wave-transition and planet-destruction screen choreography.** `MAPCH7` / `BGERAS` / `TBLP` are located but their frame-by-frame sequencing is not specified here. | Blank the terrain, place two `TEREX` bursts per §12 of `VISUAL_ASSET_FORENSICS.md`, and hold. | Read `MAPCH7` and the wave-end process chain in `defa7.src`. |
| O-8 | **Attract-mode logo decompressors.** The three schemes are documented (L-081) but not specified to pseudocode here. | Out of scope for the first Classic build; the game may start at the title without the animated logo. | Specify `LOGO`, `DEFNNN` and `COPYRT` in a follow-on pass. |

### 14.3 Corrections this document makes to the corpus

| Entry | Change |
|---|---|
| **L-075** | Player death is **108 drawn frames / 109 total / 1.814 s**, not 112 / 1.87 s. The `PXCOL` terminator costs one frame and no draw. |
| **L-019** | Add: the pre-shift relation is **exact for 20 of 24** two-phase pictures. `SCZP1`, `TIEP4`, `LNDP3` and `PLAPIC` have hand-adjusted odd-phase images. Copy the bytes; do not generate phase 1. |
| **L-021** | Add the table addresses: `COLTAB $E799`, `TCTAB $F45B`, `PXCOL $C6AB` (bank 7), `CRTAB $F8BE`. Add that `COLR` restarts the process on the `$00` terminator and never writes `$00` to slot 1. |
| **L-079** | Add: `CHRTBL` is at **`$C5E3`** (bank 2), glyph data `$C697–$CA4E`, and **all text is drawn in animated palette slot 1** and therefore cycles color with the laser. |
| **L-029 / L-052** | **Corrected in iteration 4 (C-24).** The two draw bands do **not** overlap — they are complementary about the single shared boundary `XXX2`, and both predicates are half-open. The conclusion the earlier note reached is nevertheless still true and now rests on the right proof: because the union is rows 1..255 for any `XXX2`, band membership can never exclude an on-screen object, so the "band membership gates smart-bomb reach" mechanism is real but never fires. |

---

## 15. Reproduction

```
python3 tools/export_assets.py --check         # verify every asset against the ROM
python3 tools/rom_peek.py addr F8CE 16         # picture descriptor table
python3 tools/rom_peek.py addr E799 40         # COLTAB
python3 tools/rom_peek.py addr F45B 9          # TCTAB
python3 tools/rom_peek.py addr C6AB 15 --bank 7  # PXCOL
python3 tools/rom_peek.py addr C5E3 16 --bank 2  # CHRTBL
python3 tools/gen_altbl.py --check --ascii     # terrain profile and silhouette
```
