# CLASSIC INVARIANTS — Williams DEFENDER (1981)

**Required by** `md/07_GAUNTLET_EVALUATION_REGRESSION.md` §1.
**Status** Authored 2026-08-09 at the close of Phase 1, from what Phase 1 actually established.
**Authority** Every invariant here is derived from an entry in `docs/research/EVIDENCE_LEDGER.md`
and cites it by `L-nnn`. Per the label-ownership rule, this file states facts and cites; it does not
issue confidence labels of its own. If a citation and this file ever disagree, the ledger wins.

---

## What this file is for

The gauntlet loop (`md/07`) is allowed to change almost anything in pursuit of a better score. This
file is the short list of things it is **not** allowed to change. **Any mutation that breaks an
invariant below is automatically rejected — no score improvement, no reviewer opinion and no
"it feels better" argument can buy an exception.** Reverting the mutation is the only remedy.

Rejection is meant to be mechanical. Each invariant therefore states:

- **Invariant** — the thing that must remain true, in one falsifiable sentence.
- **Basis** — the ledger entry it comes from.
- **Test** — the concrete check that decides it, including the pass condition.
- **Enforcement** — where that check runs.

Three enforcement tiers are used:

| Tier | Meaning |
|---|---|
| `gate` | Runs in `tools/check.sh`. Blocks the commit. |
| `suite` | Runs in the Phase 2+ simulation test suite, invoked from `tools/check.sh` once that suite exists. |
| `review` | Cannot be fully automated; a named human/agent check with a written verdict in `docs/qa/GAUNTLET_LOG.md`. |

Invariants whose test is not yet wired are marked **PENDING** in the Enforcement line, with the
artifact that must exist first. A PENDING invariant is still binding — it is enforced by review
until its automated test lands.

**Scope.** These invariants govern **Classic Mode**, which is the fidelity reconstruction.
Enhanced Mode is governed by the separate Enhanced scorecard (`md/07` §3) and may do as it likes —
subject to CI-11 and CI-12, which are precisely the invariants that keep the two modes separable.

---

## Tier A — Identity. Breaking one of these means it is no longer the same artifact.

### CI-01 · The canonical revision is fixed: Red Label, hash-verified

**Invariant** The reference baseline is the Williams **Red Label** set (MAME `defender`), and all
fourteen images continue to hash to exactly the CRC32 set recorded in Phase 1:

```
defend.1  c3e52d7e   defend.2  89b75984   defend.3  94f51e9b   defend.4  9a72348b
defend.6  65f4efd1   defend.7  339e092e   defend.8  b649e306   defend.9  6870e8a5
defend.10 a543b167   defend.11 9deaf6d9   defend.12 f1f88938   defend.snd fefd5b48
decoder.2 8dd98da5   decoder.3 c3f45f70
```

No behavior may be sourced from White, Blue, Green or the Taito release, and the baseline may not
be re-selected to make a discrepancy go away.

**Basis** L-001 (canonical set), L-107 (`defend.4` is the corrected ROM4E), L-094 (Taito differs in
three ROMs), `PHASE1_GATE.md` §1.

**Test** `tools/check_roms.py` walks `_defender-reference/roms/defender-redlabel/`, computes CRC32
for each of the fourteen files, and asserts the multiset equals the table above — same names, same
count, no extras. Fails on any mismatch, missing file or added file. Independently, a
cross-revision assertion: the same fourteen CRCs must **not** all be present in the white/blue/green
directories, which proves the table is actually discriminating.

**Enforcement** `gate` — **PENDING**: `tools/check_roms.py` does not exist yet; this is the first
check that should be appended to `tools/check.sh`. Until then, `review`.

---

### CI-02 · The trust rule is not negotiable: where source and ROM disagree, the ROM wins

**Invariant** Where the recovered source text and the Red Label ROM images disagree, the **ROM**
determines behavior. Source is authoritative only for intent — names, comments, structure. No
change may implement a source reading that the ROM contradicts, and no document may assert
otherwise.

This exists because the recovered text is a **late pre-final-Red working snapshot**, not the shipped
Red text; it is provably pre-Red at at least one site.

**Basis** `SOURCE_MAP.md` §2.1, L-135 (`amode1.src` constants date the text as pre-final-Red),
X2/X8/X9/X10/X11 in the ledger's Part 0.

**Test** Two-part.
1. **Documentary**: `tools/check_labels.py`-style scan for the withdrawn claims that violate this
   rule and were explicitly retired in the closure pass — "reassembles bit-for-bit",
   "`PLD21` must use `$C300`", "no ROM dumps are present". Any reappearance in `docs/` fails.
2. **Behavioral**: every simulation constant carrying a ledger entry whose evidence is a ROM byte
   (X2's `$6600`, X8's `ROMMAP`, L-107's coin-patch bytes) is asserted against the byte read live
   from the ROM image at test time, not against a literal copied from the source listing.

**Enforcement** `gate` for part 1 — **PENDING**, one regex list to add to `tools/check.sh`.
`suite` for part 2 — **PENDING** on the Phase 2 simulation existing.

---

### CI-03 · Confidence labels stay in the ledger

**Invariant** Confidence labels are owned by `docs/research/EVIDENCE_LEDGER.md`. No other document
introduces a new confidence label; it states the fact and cites the owning `L-nnn`. This is an
invariant and not a style preference because Phase 1 shipped three separate label drifts, each of
which caused a downstream document to disagree with the ledger about what was known.

**Basis** `EVIDENCE_LEDGER.md` label-ownership rule; GAUNTLET_LOG defects F-04, R-05, H-05.

**Test** `python3 tools/check_labels.py`. Counts confidence labels stated outside the ledger and
fails when the count exceeds the recorded backlog in `tools/label_baseline.json` — i.e. any newly
introduced label fails immediately. The goal state is `--strict`, which fails on any uncited label
at all; the baseline ratchets down to zero as the historical backlog is cleaned up.

> **Honest statement of what this enforces today (2026-08-09, iteration 2).** The baseline is set to
> the *current* violation count (`uncited: 1054`). The check therefore **blocks growth and permits
> every existing violation** — it does not enforce the invariant, and a green run is not evidence
> that the invariant holds. Treat CI-03 as **partially enforced**. The deadline and per-document
> schedule are in `PHASE1_GATE.md` §C4: the four `SOURCE_MAP*` files (549 violations) and
> `SYMBOL_INDEX.json` (111) reach zero before Phase 2 consumes them.
>
> > **NUMBERS CORRECTED, AND ONE REAL GATE ADDED — 2026-08-09, iteration 3 (gate findings F-5, F-6).**
> > The figures in the paragraph above were already stale when written: `1054` was never the recorded
> > baseline (`tools/label_baseline.json` said `1047`), and `549` should have been `543`.
> > **Current recorded baseline: `uncited: 922`.** The `SOURCE_MAP*` + `SYMBOL_INDEX.json` subtotal
> > went **654 → 556**. The retirement schedule is **not** discharged and survives unchanged.
> >
> > What *is* now genuinely enforced, and is not a growth-blocker: `tools/label_owner.py --gate`,
> > registered in `check.sh` as `label-owners`, with a baseline of **0** — a real wall. It resolves
> > each uncited label against the ledger and **fails outright** if any label is asserted while the
> > entry owning the very same source lines sits right there uncited. That is the mechanism by which
> > the backlog was rebuilt in the first place, and it is now closed.
> >
> > Also corrected here: the `constants` description below. `check_constants.py` is a **regression
> > fence** over thirteen previously-corrected phrasings, **not** a cross-document agreement engine —
> > `PHASE1_GATE_FINAL.md` §3 fed it two freshly-invented contradictions and it passed them. It
> > catches the recurrence of a known error, which is worth having, and nothing else.
>
> `SYMBOL_INDEX.json` was **exempt by omission** until iteration 2 — the scanner walked `*.md` only
> — while carrying 176 self-issued `confidence` fields, 101 of them on entries with no `L-nnn`
> anywhere. It is now in scope.

**Enforcement** `gate` — **live, but as a growth-blocker only**; registered in `tools/check.sh` as
`label-ownership`. Two further gates were added in iteration 2: `citations`
(`tools/check_citations.py` — tree-basis routing, in-file bounds for every anchor including
`williams*.cpp`, dangling `L-nnn`) and `constants` (`tools/check_constants.py` — **a regression
fence over thirteen previously-corrected phrasings of shared numeric constants.** *(Description
corrected 2026-08-09, iteration 3, F-5: this was described as "cross-document agreement … the
mechanised form of 'corrected in one document is not closed'". It is not. It is a blocklist and
cannot detect a **new** disagreement, only the recurrence of an old one — demonstrated in
`PHASE1_GATE_FINAL.md` §3.)*). A sixth gate, `label-owners`
(`tools/label_owner.py --gate`), was added in iteration 3.

---

### CI-04 · Source citations stay true

**Invariant** Every `{file, label, line}` citation in `docs/research/SYMBOL_INDEX.json` **and every
`file:line` anchor in the prose corpus** continues to resolve — in the *tree the filename's case
declares* — to the construct the surrounding sentence names. Citations may not be ambiguous (a label
defined twice), stale (pointing at a line the label has moved off), dangling (naming a file not in
the reference trees), or **cross-tree** (a lowercase `*.src` name carrying historicalsource line
numbers, or the reverse — the two trees differ by +5 lines in `vsndrm1`'s IRQ region alone).

**Basis** Phase 1 gate condition C3; GAUNTLET_LOG defect F-02, which found eleven wrong line
numbers and four mis-targeted entries in a single pass.

**Test** `python3 docs/qa/validate_symbol_index.py` for the JSON index — hard-fails on
`FILE_NOT_FOUND`, `AMBIGUOUS_LABEL`, `WRONG_LINE`, `NULL_LINE_BUT_LABEL_EXISTS` and
`LINE_OUT_OF_RANGE`; lists descriptive pseudo-label entries for eye-check without failing.
**Plus** `python3 tools/check_citations.py` for the prose corpus — 1,886 anchors across
`docs/research` and `docs/qa`, including every `williams*.cpp` line reference, which nothing checked
before iteration 2.

> **The validator could not detect the defect it was built for, until 2026-08-09 iteration 2.** It
> discriminated trees with `os.path.isfile()` over an ordered root list. macOS/APFS is
> case-insensitive by default, so `isfile('.../mwenge-defender/src/INFO.SRC')` returns **True** —
> every UPPERCASE `*.SRC` entry resolved to the *mwenge* file and the declared line-number basis was
> silently collapsed. The suite passed only because mwenge `info.src` and historicalsource
> `INFO.SRC` are byte-identical. An `AMODE1.SRC` or `VSNDRM1.SRC` entry would have been validated
> against the wrong file with a 2- and 5-line offset. It now routes by declared tree and requires an
> **exact-case** match via `os.listdir()`. Never use `os.path.isfile()` as a case discriminator.

**Enforcement** `gate` — **live now**, registered in `tools/check.sh` as `symbol-index` and
`citations`.

---

## Tier B — The simulation contract. Breaking one of these changes how the game plays.

### CI-05 · One `EXEC` pass per frame, at 16.640 ms

**Invariant** The scheduler runs **exactly one `EXEC` pass per video frame**; a frame is exactly
**16.640 ms** (3125/52 = 60.09615… Hz); `NAP n` means `n` frames. Four IRQs occur per frame at
scanlines 0/64/128/192, and only the second half-frame increments `TIMER`. Nothing may re-derive
the tick from a host refresh rate, from 60 Hz, or from the IRQ rate.

This is the single most load-bearing timing fact in the project: a scheduler at the IRQ rate would
quadruple every `NAP` constant and run the operator's minutes meter 4× fast.

**Basis** L-002 (frame period), L-003 (IRQ structure), L-004 (tick = frame), L-005, X1.

**Test** Three assertions, all in the simulation suite.
1. Instrument the scheduler: over 3600 consecutive frames, `EXEC` entry count == 3600, and IRQ entry
   count == 14400 with scanlines drawn only from {0,64,128,192}.
2. Elapsed simulated time for 3600 ticks == 59.904 s, within one frame period.
3. The `GEXEC` audit cross-check: run 3600 ticks of the attract/game scheduler and assert audit
   meter 6 ("ball time in minutes") increments exactly once.

**Enforcement** `suite` — **PENDING** on the Phase 2 scheduler.

---

### CI-06 · Simulation results are independent of render frame rate

**Invariant** Simulation state is a pure function of the tick sequence, never of wall-clock time or
of how often the renderer runs. Running the same seeded input script at 30, 60, 120 and 144 host
Hz, headless, or with frames deliberately dropped, must produce **byte-identical** simulation state.
Time is accumulated and floored (`floor(accum / 16.640)` ticks per callback); it is never
interpolated into physics, and no physics quantity is multiplied by a delta-time.

**Basis** L-002 Notes (accumulate-and-floor, and the explicit warning that a naive "double-tick
every 62 callbacks" correction runs the game 1.45 % fast), L-004.

**Test** Deterministic replay harness. Fix seed and input script; run four times under host rates
{30, 60, 120, 144} Hz and once headless with rendering disabled; hash the full simulation state
vector (all object slots, process table, score, RNG state) at ticks 1, 100, 1000, 10000. All five
hashes must match at every checkpoint. Additionally, assert no physics update in the codebase reads
a wall-clock delta — a static grep for `dt`/`deltaTime`/`performance.now()` inside the simulation
module, allowed only in the tick accumulator.

**Enforcement** `suite` for the replay hashes, `gate` for the static grep — both **PENDING**.

---

### CI-07 · Determinism under seeded RNG, including call ordering

**Invariant** The game is fully deterministic from cold boot: there is no entropy source. The LFSR
is `HSEED:LSEED`, taps bit0^bit3 (`x^16+x^13+1`, non-primitive), seeded `$A55A` with `SEED` left 0
by the RAM clear. `RAND` is called **unconditionally once per `EXEC` pass**, and several consumers
read `SEED`/`HSEED`/`LSEED` directly rather than calling `RAND` — so both the state **and the exact
call ordering** must be reproduced. No mutation may add a random source, reorder RNG consumers, or
make an RNG call conditional.

**Basis** L-106 (period figures and the ordering warning), L-054 (hyperspace consumes the state
directly), L-030 (the overload governor reads `SEED` directly).

**Test** Two assertions.
1. **Period**: enumerate the LFSR from `$A55A`; assert period **57 337**, and assert the 24-bit
   `SEED:HSEED:LSEED` walk has a 501-call tail followed by a 114 674-call cycle.
2. **Ordering**: log every read of `SEED`/`HSEED`/`LSEED` for 10 000 ticks of a scripted run and
   compare the log to a checked-in golden trace. As a behavioral cross-check, 10 000 scripted
   hyperspaces must kill the player **24.61 % ± 0.5 %** of the time — **with the core's default
   flags**, i.e. `infiniteHyperspace` off. `KNOWN_DEVIATIONS` `D-ASSIST-2` suppresses the *branch*
   `LDA LSEED / CMPA #192 / LBHI PLEND`, never the *read*: `LSEED` is still consumed in the same
   order and the RNG walk is bit-identical either way, so assertion 1 holds with the flag on and
   only this cross-check moves. Run it against a core built without options, as the suite does.

**Enforcement** `suite` — **PENDING**. Assertion 1 is standalone and can be written today against
the `RAND` code at `defa7.src:943–962`.

---

### CI-08 · World topology is exact

**Invariant** World X is **unsigned 16-bit, 32 units per pixel, 64 per byte-column**; the world is
the full 65 536-unit / **2048-pixel** space and wraps for free because every visibility test is an
unsigned 16-bit difference. The visible object window is `150·64` units = 300 px. The world may not
be widened, narrowed, made signed, or given explicit wrap handling.

Off-screen objects **keep simulating**: inactive-list objects update once per 8 frames at 8×
velocity, the active window is 500 px wide starting 100 px left of the screen, and Y **wraps**
top-to-bottom rather than clamping.

**Basis** L-018 (three mutually independent derivations: object, scanner, terrain), L-035,
L-045 (three distinct X representations), X4 (156 byte-columns software-cleared / 152 byte-columns
= 304 px hardware framebuffer / 292×240 visible = byte-columns 6–151, rows 7–246), L-009,
`DISPLAY_GEOMETRY.md` §1.

**Test** Fly one full lap at constant velocity; assert the terrain silhouette and every object's
world position repeat **exactly** at the lap boundary, with no branch taken for wrapping.
Separately: park an object off-screen for 10 s of simulated time, return, and assert its position
matches continuous motion to the pixel. Type-level: assert world-X storage is an unsigned 16-bit
type and that no comparison against it is signed.

**Enforcement** `suite` — **PENDING**.

---

### CI-09 · Scoring is exact, to the point

**Invariant** Lander 150, Mutant 150, Swarmer 150, Baiter 200, Bomber 250, Pod 1000, **mine 25**.
Humanoid: 250 for an uncaught safe landing, 500 for catching in flight, 500 for depositing — a full
rescue is **1000 paid as two separate 500 events**, and shooting a humanoid pays nothing.
End-of-wave bonus is **100 × min(wave, 5)** per surviving humanoid. The replay award (default every
10 000 points, repeating) grants **one ship and one smart bomb together**; there is no separate
smart-bomb threshold. The shell that kills the player still pays 25.

**Basis** L-026 (two independent derivations: kill-routine constants and the attract-mode display
data), L-025, L-053, L-060.

**Test** Scripted kill harness: destroy one of each enemy class and assert the exact score delta for
each; catch/deposit/drop a humanoid through all three outcomes and assert 500/500/250; clear a wave
with `n` humanoids at waves 1..8 and assert the bonus is `100 × min(wave,5) × n`; cross 10 000
points and assert both the ship counter and the smart-bomb counter increment and audit meter 5
bumps. Every literal in the test is read from the ledger table, not from the implementation.

**Assist note.** `KNOWN_DEVIATIONS` `D-ASSIST-1` (`infiniteSmartBombs`) does **not** touch this
invariant's arithmetic: it suppresses `SBOMB`'s single `DEC PSBC`, and the replay award's paired
`INC PLAS` / `INC PSBC` in `score.js` is untouched, so this test passes with the flag on or off.
A *run* with the flag engaged is nevertheless not score-valid — `core.scoreValid()` is false and the
`GAME_OVER` event carries `scoreValid: false` — because the stock is no longer a real constraint.

**Enforcement** `suite` — **PENDING**.

---

### CI-10 · Control semantics are exact

**Invariant** All inputs are **active HIGH**. There is **no left/right axis** — a 2-way up/down
switch plus a separate Reverse button; Reverse writes exactly one variable. Fire is a rising-edge
detector requiring **two consecutive zero samples**, with no repeat timer and **no autofire**;
switches are sampled once per frame and only the **lowest set bit is decoded per call**, so
simultaneous presses serialise over successive frames. Thrust and the vertical stick are
**level-polled, not edge-triggered**. Vertical motion has an instantaneous kick and instantaneous
release; horizontal has thrust-and-drag with top speed **6 px/tick**. Camera easing preserves world
X exactly.

No mutation may add autofire, add analogue steering, debounce differently, or make input sampling
sub-frame — including "for feel".

**Basis** L-028 (input map), L-062 (firing cadence), L-031 (level-polled), L-040 (Reverse),
L-041/L-042 (thrust, drag, vertical), L-100 (top speed 6 not 8), L-044 (camera easing).

**Test** Hold fire for 60 frames; assert exactly one shot. Press fire and smart bomb on the same
frame; assert they resolve on successive frames in lowest-bit-first order. Hold thrust from rest and
assert the velocity curve matches the ledger's drag model to the unit, with terminal speed exactly
6 px/tick. Release the vertical stick mid-climb and assert vertical velocity is zero on the very
next tick. Assert the input enumeration contains exactly the nine documented switches and no
left/right entry exists anywhere in the input layer.

**Enforcement** `suite` for the behavioral assertions — **PENDING**. `gate` for the "no left/right
control exists" static assertion, which can be a grep today.

---

## Tier C — Mode separation. These keep Classic honest once Enhanced exists.

### CI-11 · Classic Mode cannot depend on Enhanced assets

**Invariant** Classic Mode runs correctly with the entire Enhanced asset tree, Enhanced code paths
and Enhanced configuration **absent from the build**. No Classic code path imports an Enhanced
module, loads an Enhanced asset, or reads an Enhanced setting — not even for a fallback, a
placeholder, or a shared utility that lives on the Enhanced side.

The dependency is permitted in exactly one direction: Enhanced may depend on Classic.

**Basis** `md/07` §1 and §3 (fidelity and Enhanced quality are independent scorecards); the standing
prohibition in `md/07` §"expand Enhanced Mode as an excuse to skip Classic fidelity".

**Test** Build Classic with the Enhanced directory deleted (or renamed) and assert: the build
succeeds, the deterministic replay of CI-06 still produces the identical state hashes, and zero
asset-load failures are logged. Statically, assert the module dependency graph has **no edge from
any Classic module to any Enhanced module** — a one-direction import check that fails on the first
offending edge.

**Enforcement** `gate` for the import-graph check, `suite` for the deleted-tree build — both
**PENDING** on Phase 2 code existing. The import check should be appended to `tools/check.sh` the
day the first Enhanced module lands, not later.

---

### CI-12 · Original behavior that looks like a bug is preserved, not fixed

**Invariant** Behaviors that Phase 1 proved original are preserved in Classic Mode even where they
read as defects. In particular:

- The **overload governor** — when an `EXEC` pass overruns a frame the game cuts the starfield from
  16 stars to 3 and teleports a random `OTYP=0` object off-screen. The planet-destruction stutter is
  deliberate. A fixed-speed reimplementation never triggers this and will feel denser than the
  original; the governor must be modeled, not designed away.
- **Sound `$12` is dead.** `BON2`, `BONV`, `BONSND` and the `B2FLG` state machine are unreachable
  because a register bug sends the idle pattern. The astronaut suck-in plays only the `$0E` `LSKSND`
  ladder. Do not "restore" the missing sound.
- **Explosion slots can starve**, and **operator adjustments are not range-clamped**.
- Enemy AI is **not** Red-only-improved: MAME's "much improved enemy AI" comment is uncited and is
  contradicted by Williams' own revision chart. No speculative Red AI may be implemented.
- `P1LAS`→`P1LAT` was a namespace disambiguation with **zero** behavioral content. Defender did not
  have a laser-display bug; do not implement a fix for one.

**Basis** L-030 (governor), X6 and L-013 (dead `$12`), L-074 (slot starvation), L-085 (unclamped
adjustments), ledger Part 0 closing notes (AI comment, `P1LAT`).

**Test** Force `OVCNT ≥ 2` and assert `STRCNT` drops to 3 and exactly one object migrates by
`(SEED & $3F) + $60`. Trigger the astronaut suck-in and assert the sound bus carries the idle
pattern `$3F` twice and no `BON2` state is entered. Saturate the explosion pool and assert
explosions are dropped rather than queued. Set an operator adjustment out of range and assert it is
stored unclamped.

**Enforcement** `suite` — **PENDING**. Also `review`: any diff whose message or comment claims to
"fix" one of these is rejected on sight.

---

## Rejection procedure

1. A mutation is proposed and `tools/check.sh` runs. A non-zero exit on any `gate` check is an
   automatic reject — the mutation is reverted, not patched forward.
2. The Phase 2 simulation suite runs. A failure on any `suite` check listed above is an automatic
   reject, and outranks every fidelity-scorecard delta and every reviewer opinion.
3. For `review` invariants, the reviewing agent records an explicit verdict against the CI number in
   `docs/qa/GAUNTLET_LOG.md`. A missing verdict is treated as a failure.
4. An invariant may be **amended** only by amending its ledger basis first, with new primary
   evidence, in the same commit. It may never be waived for a single change.

## Maintenance

Every **PENDING** enforcement above is a work item. As each automated test lands, register it in
`tools/check.sh` (one `run <name> <command>` line) and change the Enforcement line here from
`PENDING` to `live`. The intended landing order, cheapest first:

1. CI-01 `tools/check_roms.py` — pure hashing, needs nothing else.
2. CI-02 part 1 — a regex list for the three withdrawn claims.
3. CI-07 assertion 1 — LFSR period enumeration, standalone.
4. CI-10 / CI-06 static greps.
5. Everything else, as the Phase 2 simulation appears.
