# DISPLAY GEOMETRY — the raster, the tube, and the browser policy

**Closes Q-05.** This document fixes the entire canvas geometry of the reconstruction. It is
deliberately prescriptive: every number below is a decision, not a range.

Confidence labels live in `EVIDENCE_LEDGER.md`; this document cites `L-nnn` and gives the
re-check command where one exists. Ledger consequences are in §9.

---

## 0. The decision, in four lines

```
LOGICAL FRAMEBUFFER      304 × 256 px   (152 byte-columns × 256 rows, 4 bpp, 2 px/byte)
SCANNED-OUT (VISIBLE)    292 × 240 px   framebuffer x ∈ [12, 303],  y ∈ [7, 246]
DISPLAY ASPECT (DAR)     4 : 3          exactly — the raster filled a landscape 4:3 tube
PIXEL ASPECT (PAR)       80 : 73        = 1.09589 — PIXELS ARE NOT SQUARE, they are 9.6 % wide
```

> **Render 292 × 240 logical pixels and present them in a box whose ratio is exactly 4:3.**
> On a widescreen browser window: **pillarbox**. On a tall window: **letterbox**. **Never**
> stretch to fill, **never** crop, **never** present with square pixels.

Square-pixel presentation gives 292:240 = 73:60 = 1.2167, which is **8.75 % narrower** than the
real machine. Equivalently, the correct presentation applies a **+9.589 % horizontal stretch** to
a square-pixel image. Either framing describes the same error; both numbers appear in earlier
notes and they are consistent.

---

## 1. The framebuffer, and which part of it reaches the tube

Defender has **no blitter** (L-010) and no tilemap. There is one linear 4-bit-per-pixel
framebuffer, column-major: `address = (x >> 1) · 256 + y`, high nibble = the even-x (left) pixel
(L-009).

| Extent | Range | Size | Notes |
|---|---|---|---|
| RAM the software clears | `$0000–$9BFF` | 156 byte-columns | `SCRCLR` counts down from `$9C00` |
| Scanned out by the video hardware | `$0000–$97FF` | **152 byte-columns × 256 rows = 304 × 256 px** | columns 152–155 are written but never displayed |
| **Visible on the tube** | x 12–303, y 7–246 | **292 × 240 px** | byte-columns 6–151, rows 7–246 |

So the machine **overscans by 12 pixels on the left and by 7 rows at the top / 9 rows at the
bottom.** Those regions contain real drawn content — the top of the scanner band, the terrain
generator's edge column — which the tube simply does not show. A reconstruction must draw them
(the game's code depends on them existing) and must **crop** them.

The 304 px framebuffer width is not an inference from MAME. It is stated by the game:

```
blk71.src:99   ADDD  #$2610      ;START ON RIGHT EDGE OF SCREEN
```
`$2600` = 9728 world units ÷ 32 units-per-pixel = **304 px** exactly (the `$10` is a half-pixel
rounding bias). Re-check: `tools/rom_peek.py addr C01B 3 --bank 7` → `C3 26 10`.

### 1.1 Where the 292-pixel window sits — and why x 12–303, not x 6–297

MAME's shared Williams `set_raw` puts the visible window at x 6–297. **The Defender machine
config deliberately overrides it**:

```
williams.cpp:1601   m_screen->set_visarea(12, 304-1, 7, 247-1);     // defender_state::defender
```

Both windows are 292 px wide; they differ only in origin. The game's own on-screen furniture
corroborates the Defender-specific one:

| Element | Source | ROM check | Framebuffer position |
|---|---|---|---|
| Scanner origin | `phr6.src:159` `SCANER EQU $3000+SCANH`, `SCANH = YMIN−34 = 8` | `$D66A = 8E 30 08` (`LDX #$3008`) | byte column 48, row 8 |
| Scanner width | 64 byte-columns — the blip index is `(ΔOX16) >> 10` over a 65536-unit world; the erase loop ends at `CMPA #(SCANER>>8)+64` | `$CD08 = 81 70 26` | columns 48–111 = **pixels 96–223** |
| Enemy blip row | `amode1.src:1270` `ADDD #SCANER-1` → `OY16>>3 + 7` | `$CD5A = C3 30 07` | **rows 7–38** |
| Player blip base | `amode1.src:1252` `ADDD #$4B00+SCANH-1` | `$CD3A = C3 4B 07` | column 75, **row 7** |
| Window bracket, left | `amode1.src:1226` `LDX #SCANH+$4C01` / `LDD #$9090` / `STD ,X` / `STD $1D,X` | `$CD0C = 8E 4C 09 CC 90 90 ED 84 ED 88 1D` | column 76 (pixel 152), rows 9–10 and 38–39 |
| Window bracket, right | `amode1.src:1230` `LDX #SCANH+$5301` / `LDD #$0909` | `$CD17 = 8E 53 09 CC 09 09` | column 83 (pixel 167), rows 9–10 and 38–39 |

Two independent corroborations fall out:

**Horizontal.** The scanner spans pixels 96–223, center 160. Against the Defender visarea
(12–303, center 158) the margins are 84 px left and 80 px right — asymmetric by 4 px. Against the
base Williams window (6–297, center 152) they are 90 and 74 — asymmetric by 16 px. The
Defender-specific override is four times better centered, which is exactly what a machine-specific
override normally exists to fix. **Use x 12–303.**

**Vertical.** Both the enemy blips and the player blip are placed at framebuffer **row 7** as
their base row — `ADDD #SCANER-1` = `$3007` and `ADDD #$4B07`. Row 7 is precisely the first
scanned-out row in `set_visarea(…, 7, 246)`. The scanner is drawn flush against the top of the
visible area, with nothing above it. That is not a coincidence one would expect if the visible
window began at row 0.

`SCLR1` then clears 214 rows per column and skips `YMIN` = 42 (`LEAU -YMIN,U`), so rows 0–41 are
the scanner/status band and rows 42–255 are the playfield. The visible window 7–246 cuts 7 rows
off the top of the band and 9 rows off the bottom of the playfield. The player's own row limits
(43 … 238, `MOVEMENT_ENVELOPE.md` §6) sit comfortably inside it.

---

## 2. Raster timing

From `defender_state::defender` → `williams_state::williams_base` (`williams.cpp:1531, 1556`):

```
MASTER_CLOCK   12.000 MHz
dot clock      MASTER_CLOCK × 2/3   =  8.000 MHz
htotal         512 dots             → 15.625 kHz line rate,  64.000 µs per line
vtotal         260 lines            → 60.09615 Hz field rate, 16.640 ms per frame
active         292 dots × 240 lines
```

Non-interlaced: one field = one frame. 15.625 kHz is the standard arcade/CCIR horizontal rate; a
period 19-inch arcade CRT locks to it without modification.

**Note the horizontal duty: 292 of 512 counts = 57 %.** That is far below the ~80 % active duty a
broadcast-standard signal carries. Whatever the true relationship between MAME's horizontal
counter and the physical dot rate, the consequence is the same and it is the key to Q-05:

> **You cannot derive Defender's pixel aspect from the dot clock.** The on-tube width of the
> active window was set by the monitor's horizontal-size potentiometer, adjusted at the factory
> and by the operator so the picture fills the screen. The aspect is a property of the *tube*,
> not of the *timing*.

The vertical duty tells the complementary story: 240 of 260 lines = 92.3 % active, which is a
perfectly ordinary vertical blanking interval. The 240 lines fill the tube height as-is.

---

## 3. The tube

1. **The monitor is horizontally mounted.** MAME registers every Defender set with `ROT0` —
   `defender`, `defenderg`, `defenderb`, `defenderw`, `defenderj` (`williams.cpp:3968–3972`).
   No rotation, landscape. This is the machine-readable form of "the Williams cabinet used a
   horizontally-mounted monitor".
2. **The tube is 4:3.** Defender shipped in an upright with a 19-inch raster monitor. Every
   arcade raster CRT of 1980–81, and every 19-inch tube in the Williams line, is a 4:3 tube.
   There was no other option: these are television picture tubes.
3. **The active raster fills it.** The operator adjusts H-size and V-size to fill the visible
   glass; this is what the service adjustments exist for, and §2 shows the timing does not
   constrain it.

Therefore the 292 × 240 raster is displayed across a **4:3** area.

### 3.1 Why MAME's own default aspect is not the evidence here

The Williams driver never calls `set_physical_aspect` — grep for `aspect` across
`williams.cpp`, `williams.h`, `williams_m.cpp`, `williams_v.cpp`, `williamsblitter.*`,
`williamssound.*` returns **zero hits**. Whatever `src/emu/screen.cpp` falls back to when
`m_phys_aspect` is never set is therefore an emulator presentation convention, not a statement
about Williams hardware, and `screen.cpp` is not in this repository's reference set.

Q-05 originally proposed reading `screen.cpp` as the closing evidence. **That is the wrong
witness, and it is not needed.** The raster geometry comes from the driver's `set_raw` /
`set_visarea` (MAME-confirmed hardware description); the physical aspect comes from the cabinet.
Those two facts are independent and sufficient.

---

## 4. The arithmetic

```
storage aspect (SAR)   = 292 / 240        = 73 / 60      = 1.216667
display aspect (DAR)   = 4 / 3                           = 1.333333
pixel  aspect (PAR)    = DAR / SAR
                       = (4/3) · (240/292)
                       = 960 / 876
                       = 80 / 73                         = 1.09589041…
```

**Pixels are not square.** Each pixel is `80/73` as wide as it is tall.

| Presented as | Result |
|---|---|
| 292 × 240 square pixels | image is **8.75 % too narrow** (0.9125× the correct width) |
| 292 × 240 stretched by 80/73 horizontally | **correct** |
| 320 × 240 (the "stretch to a round number" shortcut) | 320/292 = 1.09589 — **exactly correct**, this is the same number |
| 304 × 256 (whole framebuffer, square) | wrong twice: wrong aspect *and* shows overscan |

The `320/292` figure recorded in L-105 is arithmetically identical to `80/73`; both are the same
stretch. L-105's claim is confirmed, and its INFERRED status was only ever about the *justification*
(which witness settles the 4:3), not about the arithmetic.

Canonical presentation sizes (integer vertical scale, exact 4:3 box):

| Vertical scale | Box | Notes |
|---|---|---|
| ×4 | **1280 × 960** | the default; horizontal factor 1280/292 = 4.3836 |
| ×5 | **1600 × 1200** | horizontal factor 5.4795 |
| ×3 | 960 × 720 | minimum for acceptable filtering |

The horizontal scale factor can never be an integer at the same time as the vertical one, because
`80/73` is in lowest terms. This is not a bug to be engineered away — it is what non-square pixels
mean.

---

## 5. Browser policy — normative

### 5.1 The rules

1. **Logical resolution is 292 × 240.** All game coordinates, hit boxes and sprite placement are
   in framebuffer pixels; the visible origin is framebuffer `(12, 7)`.
2. **Presentation aspect is exactly 4:3.** Not 16:9, not "whatever fits", not the source's 73:60.
3. **Fit inside the viewport, preserving 4:3.**
   - viewport wider than 4:3 → **pillarbox** (bars left and right)
   - viewport taller than 4:3 → **letterbox** (bars top and bottom)
4. **Never crop and never stretch to fill.** Defender uses the full 292 px: the ship's home
   columns (pixel 64 facing right, 224 facing left) and its 48 px lean budget are calibrated to
   that width. Cropping changes the game. Stretching changes every sprite.
5. **Bars are black** (`#000000`). The palette's `$00` entry is black and the tube surround was
   black; anything else invents a bezel the machine did not have.
6. **Do not put `image-rendering: pixelated` on the final element.** Nearest-neighbour at a
   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.

### 5.2 The scaling chain that satisfies rule 6

Two stages. Integer first, fractional last, once.

```
offscreen  : 292 × 240 canvas, imageSmoothingEnabled = false   ← the game renders here
intermediate: scale by N = max(1, floor(boxHeightDevicePx / 240)), nearest-neighbour
visible    : one draw into a canvas of exactly (boxW × boxH) device pixels,
             imageSmoothingEnabled = true                       ← the only smoothed step
```

At N = 4 the residual resampling error is ≤ ¼ of a source pixel, which is below the point where
the banding is visible, while the image still reads as hard-edged.

### 5.3 Reference implementation

```html
<div id="stage"><canvas id="screen"></canvas></div>
```

```css
html, body { margin: 0; height: 100%; background: #000; }
#stage {
  position: fixed; inset: 0;
  display: grid; place-items: center;      /* pillar/letterbox falls out of centring */
  background: #000;
}
#screen {
  aspect-ratio: 4 / 3;                     /* THE decision, in one line */
  width:  min(100vw, calc(100vh * 4 / 3));
  height: min(100vh, calc(100vw * 3 / 4));
  display: block;
  /* deliberately NOT image-rendering: pixelated — see §5.1 rule 6 */
}
```

```js
const VIS_W = 292, VIS_H = 240;            // scanned-out window
const FB_W  = 304, FB_H  = 256;            // full framebuffer the game draws into
const VIS_X = 12,  VIS_Y = 7;              // origin of the window inside the framebuffer

function resize(canvas) {
  const dpr  = window.devicePixelRatio || 1;
  const r    = canvas.getBoundingClientRect();
  canvas.width  = Math.round(r.width  * dpr);
  canvas.height = Math.round(r.height * dpr);
  // integer pre-scale factor for the nearest-neighbour stage
  return Math.max(1, Math.floor(canvas.height / VIS_H));
}

function present(ctx, framebufferCanvas, n) {
  // stage 1: crop the visible window and integer-upscale, hard edges
  mid.width = VIS_W * n; mid.height = VIS_H * n;
  const m = mid.getContext('2d');
  m.imageSmoothingEnabled = false;
  m.drawImage(framebufferCanvas, VIS_X, VIS_Y, VIS_W, VIS_H,
                                 0, 0, VIS_W * n, VIS_H * n);
  // stage 2: the single fractional step into the 4:3 box
  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(mid, 0, 0, ctx.canvas.width, ctx.canvas.height);
}
```

### 5.4 Consequences elsewhere in the reconstruction

- **Mouse / touch coordinates** must be mapped back through both stages. There is no uniform
  scale factor: `x_fb = 12 + (x_css - boxLeft) · 292 / boxWidth`,
  `y_fb = 7 + (y_css - boxTop) · 240 / boxHeight`.
- **Any overlay** (debug HUD, scanline shader, CRT curvature) belongs on the *presented* surface,
  after the stretch, or it will be stretched too.
- **Screenshots and video captures** intended as evidence must be taken at a 4:3 size, and the
  size must be stated. A 292 × 240 PNG is a *storage* artifact, not a picture of Defender; label
  it as such or nobody downstream will apply the stretch.
- **Sprite work.** A sprite that is 16 framebuffer px wide and 16 tall is **not square on
  screen** — it is 9.6 % wider than tall. Any asset comparison against a photograph or a video
  capture must apply the stretch first.

---

## 6. Refresh rate

**60.09615 Hz**, frame period **16.640 ms** exactly (`8 MHz / (512 × 260)`). This is not 60.000 Hz
and not 59.94 Hz.

In a browser, `requestAnimationFrame` runs at the display's rate. The correct approach is a fixed
16.640 ms logic step decoupled from presentation (accumulate real elapsed time, run whole steps),
**not** one logic step per animation frame. On a 60.000 Hz display the two rates drift by one
frame every ~10.4 seconds; on a 120 Hz display, every ~5.2 s of paired frames. Every timing
constant in the corpus — thrust ramps, Baiter countdowns, the wave clock — is expressed in these
16.640 ms ticks.

---

## 7. What this does *not* settle

- **The exact on-tube overscan of a specific cabinet.** Individual monitors were adjusted by hand;
  some cabinets showed a little less than 292 × 240, some a little more of the surrounding black.
  The reconstruction targets the *signal*, which is the only reproducible thing.
- **Phosphor, bloom, curvature, scanline structure.** Presentation effects, out of scope here.
  If added, they go on the presented surface (§5.4), and they must be defeatable.
- **Whether MAME's 8 MHz / 512-count horizontal description is the physical dot rate** or a
  counter-width convention that yields the same line and field rates. It does not affect any
  number in §0 — the line rate, field rate, active pixel count and tube aspect are all unchanged
  either way — but it is the one loose thread if anyone later wants to model the analogue signal
  rather than the pixel grid.

---

## 8. Verification commands

```
tools/rom_peek.py addr C01B 3  --bank 7   # C3 26 10  ADDD #$2610 -> 304 px framebuffer width
tools/rom_peek.py addr D66A 3             # 8E 30 08  LDX #SCANER -> scanner at byte column 48
tools/rom_peek.py addr CD08 3  --bank 1   # 81 70 26  CMPA #$70   -> scanner is 64 columns wide
tools/rom_peek.py addr CD0C 6  --bank 1   # 8E 4C 09 CC 90 90  left window bracket, column 76
tools/rom_peek.py addr CD17 6  --bank 1   # 8E 53 09 CC 09 09  right window bracket, column 83
tools/rom_peek.py addr CD5A 3  --bank 1   # C3 30 07  blip base row 7 = first scanned-out row
tools/rom_peek.py addr CD3A 3  --bank 1   # C3 4B 07  player blip base, column 75 row 7
```

MAME side (`_defender-reference/mame-williams/`):

```
williams.cpp:1556   set_raw(MASTER_CLOCK*2/3, 512, 6, 298, 260, 7, 247)
williams.cpp:1601   set_visarea(12, 304-1, 7, 247-1)          ← Defender-specific override
williams.cpp:3968   GAME( 1980, defender, 0, defender, ..., ROT0, "Williams", ... )
williams_v.cpp:210  source = &m_videoram[y];  pix = source[(x/2)*256]
grep -rn aspect .   → no matches
```

---

## 9. Ledger consequences

Proposed for `EVIDENCE_LEDGER.md`; this document assigns no labels itself.

| Entry | Action |
|---|---|
| **L-105** (pixel aspect ratio) | Promote **INFERRED → MULTI_SOURCE_CONFIRMED**, and restate the basis. The closing witnesses are (a) the Defender-specific `set_visarea(12, 303, 7, 246)` fixing the raster at 292 × 240, (b) `ROT0` on all five Defender sets fixing the monitor as landscape, (c) the period 19-inch 4:3 tube. Replace "read `screen.cpp` for MAME's default aspect" with a note that MAME's default is a presentation convention and is *not* evidence. Canonical form: **DAR 4:3, PAR 80:73 = 1.09589**; `320/292` is the same number. |
| **L-009** (framebuffer format) | No change to the claim; add the `blk71.src:99 ADDD #$2610` = 304 px game-side witness for the width, and the scanner-geometry corroboration of the x 12–303 origin (§1.1). |
| **new L-150** | Frame timing: 8 MHz dot clock, htotal 512, vtotal 260 → 15.625 kHz / **60.09615 Hz / 16.640 ms per frame**, non-interlaced. Shared with `MOVEMENT_ENVELOPE.md` §1 (also proposed there as L-147 — merge into one entry). |
| **new L-151** | Presentation policy: 292 × 240 logical, 4:3 box, pillarbox on wide viewports, letterbox on tall, black bars, no crop, no fill-stretch, integer-then-fractional scaling chain. This is a *project decision* recorded so it is not relitigated, not a claim about the hardware. |

---

## 10. The one-paragraph version, for anyone implementing

Defender draws into a 304 × 256 pixel framebuffer. The monitor shows 292 × 240 of it —
framebuffer x 12 to 303, y 7 to 246. That 292 × 240 window was displayed across a landscape 4:3
tube, so its pixels are 80/73 = 1.09589 times wider than they are tall. Render 292 × 240, present
it in a box that is exactly 4:3, center it, and fill the leftover with black — bars on the sides
for a widescreen window, bars top and bottom for a tall one. Do not crop, do not stretch to fill,
and do not use nearest-neighbour for the final fractional step. Run the game logic at 16.640 ms
per tick regardless of what the browser's refresh rate is.
