fs2port/ARCHITECTURE.md
2026-05-13 21:32:05 -05:00

1450 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FS2 — Apple II Flight Simulator II Architecture
This document describes the architecture of *Flight Simulator II* for the Apple II as
reconstructed from this disassembly. The original was written by Bruce Artwick / Sublogic
(1984). The build target here is the qkumba ProDOS port that reassembles byte-identical to
the shipped chunks via cc65.
Conventions used below:
* Hex addresses are written `$1234`; binary is `%01010101`.
* `Lxxxx` are raw labels from the disassembly; semantic names like `MainGameEntry` are
aliases we've added. Both resolve to the same byte address.
* Citations are `file:line` against `src/`.
* "ZP" = zero page (`$00..$FF`).
---
## Table of Contents
1. [System Overview](#1-system-overview)
2. [Boot & Initialization](#2-boot--initialization)
3. [Main Loop](#3-main-loop)
4. [Zero Page Atlas](#4-zero-page-atlas)
5. [Aircraft State Model](#5-aircraft-state-model)
6. [Physics](#6-physics)
7. [Scenery System](#7-scenery-system)
8. [3D Pipeline](#8-3d-pipeline)
9. [Rasterization](#9-rasterization)
10. [Instrument Panel](#10-instrument-panel)
11. [Engine Model](#11-engine-model)
12. [Input](#12-input)
13. [64K Patch Mechanism](#13-64k-patch-mechanism)
14. [WW1 Ace Mode](#14-ww1-ace-mode)
15. [Self-Modifying Tricks](#15-self-modifying-tricks)
16. [Mode Library](#16-mode-library)
17. [Known Unknowns](#17-known-unknowns)
---
## 1. System Overview
### 1.1 Memory Map
```
$0000-$00FF Zero page (control state + matrix + dispatcher cursor)
$0100-$01FF Stack
$0200-$25FF chunk4 (~9 KB: initial ZP data, instrument panel bitmaps,
pixel-list tables, message/digit renderer,
sin/cos tables, multiply primitives,
ProDOS read driver)
$2600-$3FFF Scratch / disk-load staging
$4000-$5FFF chunk1 (loading-panel hires page, displayed during boot)
$6000-$B3DF chunk5 (~21 KB: scenery interpreter, 3D pipeline,
rasterizer, instrument compositor,
key dispatch, main loop, boot init,
64K patch table)
$B3E0-$BFFF chunk5 tail / scenery RAM
$A800-$BFFF Live scenery bytecode area (overwritten as the dispatcher
walks; loader streams sections in via L1E03 cursor)
$C000-$CFFF I/O soft switches & slot ROM
$D000-$F3FF chunk3 (~8.5 KB: ATIS/COM message scroller, ADF model,
reality-mode + crash handler,
engine model w/ magnetos (64K),
scenery sector loader, hide/show panel
instruments, war report)
$F400-$F5FF Free / data
$F600-$FBFF chunk2 (~1.5 KB: course plotter, altimeter 10K-foot
hand, wind/turbulence, demo-mode 64K)
$FC00-$FFFF ROM (Apple II monitor / ProDOS)
+ language-card reset/IRQ vectors in 64K mode
```
The whole image links as a single segment in `src/asm.cfg` starting at `$4000` and is
sliced into the original disk chunks by `dd` in the Makefile. This matches the original
disk layout, where the loader pulls each chunk from a known sector range.
**Dead pad bytes.** The binary carries ~455 bytes of confirmed-unused filler:
* `Chunk2TrailingData` (132 bytes, `$FB7C..$FBFF`) at the tail of chunk2.
* Unnamed pad (208 bytes, `$D300..$D3CF`) at the head of chunk3.
* Unnamed pad (115 bytes, `$86CD..$873F`) between `HorizonANDMask` and
`TogglePauseRelay` in chunk5 — likely page-alignment padding.
All three contain the same repeating `$FF $FF $00 $00` 16-byte pattern with `$76`
at certain offsets. They are not referenced by any code, indirect pointer,
scenery dispatcher walk, or 64K patch — verified by exhaustive grep of all
source files and the patch table. They are 1984-era residue from the original
Sublogic assembler's output (likely leftover stack frames at the time the disk
image was authored).
### 1.2 Build Topology
| Chunk | Address Range | Size | Role |
|-------|---------------|-------|------|
| 1 | $4000-$5FFF | 8 KB | Loading-panel hires bitmap (data only) |
| 2 | $F600-$FBFF | 1.5 KB| Course plotter, altimeter 10K hand, wind, demo |
| 3 | $D300-$F3FF | 8.5 KB| Scenery loader, ATIS, engine model, fail/crash, COM messages |
| 4 | $0200-$25FF | 9 KB | Instrument render, pixel lists, math, message draw, disk I/O |
| 5 | $6000-$B3DF | 21 KB | Main game logic |
Chunks 4 and 5 are the engine; chunks 2 and 3 hold the slower / 64K-only systems and
get DMA'd in over the language card. Chunk 1 is just a splash bitmap.
### 1.3 48K vs 64K Modes
The game ships in two configurations:
* **48K mode** is what runs out of the box. Many features are stubs (no magnetos, no
ADF lookup, no separate fuel mixture, no course plotter, no ATIS, no day/night model).
Numerous routines in chunk5 are written as `NoOp` placeholders.
* **64K mode** uses the language card (`LCBANK2`). On boot, `ProbeLCMemory`
(`src/chunk5.s:12465`) detects extra RAM. If present, `Apply64KPatchTable`
(`src/chunk5.s:11486`) walks a 5-byte-entry patch table and overwrites ~30 sites in
the running code, swapping `NoOp` stubs and 48K simple paths for full-featured 64K
paths in chunks 2 and 3.
This is how the same disk image fits in both machines without runtime branches —
the patches are applied once at startup and never re-checked.
---
## 2. Boot & Initialization
### 2.1 Reset Vectors
The shipped binary doesn't ship hardware reset vectors. Boot is launched by ProDOS as
a binary at `$4000`. In 64K mode, `Apply64KPatchTable` writes `ResetInterruptHandler`
($AC15) into the language-card NMI/Reset/IRQ vectors at `$FFFA/$FFFC/$FFFE` so that
post-boot resets land back in the game (`src/chunk5.s:12536-12539`). Because bank 2
is selected when the write happens, the vectors persist past the boot sequence.
### 2.2 `ResetInterruptHandler` and `InitFromReset`
`ResetInterruptHandler` is two `jmp InitFromReset` instructions back-to-back
(`src/chunk5.s:11906`). `InitFromReset` (`src/chunk5.s:12082`) does:
1. `ldx #$3F / txs` — reset the stack.
2. `jsr LoaderNoOpSuccess` — open / handshake the disk loader (chunk4).
3. `jsr ReloadGameChunks` — re-pull chunk4 (sectors $00..$06 to $0200) and chunk5
(sectors $08..$19 to $6000). The 32 bytes at `LA7E0` (= the scenery dispatcher
entry point that chunk5 overwrites in its own image) are stashed via `LABCC` then
restored.
4. `jsr LoadIntroPanel` — pulls 8 KB of chunk1 (sectors $1A..$21) into hires page 1 at
`$4000`. This is the splash screen visible during the boot phase.
5. `jsr ProbeLCMemory` — 64K detection (see below).
6. `jsr SceneryCopyLoadedSection` (= `LA6F9`) — finalize the first scenery section.
7. `jsr InitZeroPage` + `jmp MainGameEntry`.
Boot failures land at `InitFailureExit` (`src/chunk5.s:12105`) which jumps to
`L1F89` = `SwapPagesWithStash` (chunk4) — a page-swap routine that stashes the
running aircraft state into a save region and then returns through the BRK /
Ctrl-Y / IRQ vector chain. On boot failure this effectively aborts back to
ProDOS while preserving the game state for a later resume.
### 2.3 64K Probe (`ProbeLCMemory`)
`ProbeLCMemory` (`src/chunk5.s:12465`) enables language-card bank 2 read/write by
reading the `LCBANK2` ($C083) soft switch twice (the standard idiom). It then writes
the X register through all 256 values into `$D000` and reads back to confirm
write-and-read both work. Any mismatch jumps to `LAE9E` (a bare RTS), leaving
`Has64K = 0`. On success the patch table is applied and `Has64K = 1`.
### 2.4 `MainGameEntry`
`MainGameEntry` (`src/chunk5.s:12109`) is the top-level game start:
1. `jsr Apply64KPatchTable` — no-op on 48K, runs the patch list on 64K.
2. `jsr InitGraphicsScreens` — sets the IIE soft switches for hires page 1
visible, double-buffer to page 2.
3. `jsr PromptColorOrBW` — color/monochrome prompt (only matters for the dither tables).
4. `jsr InitInstruments` — paints the static cockpit panel bitmaps.
5. `jsr InitInstrumentSaveBuffers` — captures the panel bitmap's hires bytes into the
per-instrument save buffers in chunk3 ($E800-$FC78); see §10.
6. `jmp MainLoopEntry`.
`MainLoopEntry` (= `L877F`, `src/chunk5.s:6660`) runs `PatchSlot_FrameSync` once
before falling into the regular `MainLoop`.
---
## 3. Main Loop
### 3.1 Frame Architecture
Each iteration of `MainLoop` (`src/chunk5.s:6661`) does roughly:
```
add #$0D to $33 (frame work-cost accumulator; rolls over slowly)
if EditModeFlag or ModeLibraryAction: jsr PatchSlot_PreMode
jsr PatchSlot_FrameSync
if SlewMode: jsr ApplySlewDeltas, else: jsr IntegratePhysicsStep
jsr ApplyWind (64K patch slot)
jsr LA60C (DrawATISMessage in 64K)
jsr LoadDispatcherPointer
jsr SetupViewProjection ; rebuild rotation matrix
ldx #$00 / lsr $089A (crash counter); bcc skip / lda $0899; bne skip; inx
jsr ShowSimpleCrashMessage ; 64K patches to HandleCrashOrSplash
jsr FlipPagesFillViewportRelay ; swap hires pages
jsr ProcessScenery ; the big one — see §7
jsr UpdateCoursePlotter ; 64K patch
jsr AnimateVOR2Needle
jsr DrawRPM
jsr DrawVOR1IndicatorChanges
jsr DrawVOR2IndicatorChanges
jsr IntegrateClimbRate
jsr MaybeDrawSlewOverlays
jsr UpdateTurnCoordinator
jsr DrawHeading
jsr DrawMagCompass
jsr UpdateInstrumentLights ; 64K patch
if WW1AceMode: jsr PatchSlot_Gunsight (draw gunsight overlay)
jsr DrawViewOverlays ; 64K patch
jsr UpdateAltimeterPose
jsr UpdateAirspeedDerivedValue
jsr HideOrShowInstruments ; 64K patch
if DemoMode: jsr DemoMode64K ; 64K patch
jsr ... (UpdateCounter dispatch)
jmp FinishMainLoop ; inc UpdateCounter; jmp MainLoop
```
### 3.2 `UpdateCounter` 4-phase dispatch
To amortize "slow" jobs across multiple frames, the loop's tail dispatches on the
low 2 bits of `UpdateCounter` (`src/chunk5.s:6731-6779`):
| UpdateCounter & $03 | Phase work |
|---------------------|------------|
| `00` | ComputeDayPhase, MaybeBootDOS, fuel-left gauge, ComputeWindComponents. Every 8th frame additionally: DrawDME, NAV1/NAV2 lookups, ADF lookup |
| `01` | fuel-right gauge, altimeter 10K hand, VOR1/VOR2 bearings, magnetic heading, ADF indicator |
| `10` | oil temperature, auto-trim + yaw decay |
| `11` | oil pressure, reality-mode hook, VOR/heading update (joins phase 01 tail) |
Each phase ends with either `CheckFlightEnvelope + UpdateSlipSkid + maybe joystick
calibration` (phases 00/10) or a timer rotate (phases 01/11), then `FinishMainLoop`.
### 3.3 `PatchSlot_*` Trampolines
Three 4-byte slots in chunk5 (`src/chunk5.s:11917-11933`) contain `jsr LoadSceneryFileN
/ brk brk brk` initially. Mode transitions (mode-library load, WW1 gunsight, etc.)
self-modify these slots — the `brk brk brk` padding gives room to widen the
`jsr` into a `jmp` with a different target, or to inline additional opcodes:
* `PatchSlot_PreMode` — pre-mode initialization, runs when entering edit mode
* `PatchSlot_Gunsight` — WW1 gunsight overlay
* `PatchSlot_FrameSync` — per-frame sync hook
The self-modify sites are at `$8758/$875B/$8761/$AA03` (see the comment block at
`src/chunk5.s:11912`).
---
## 4. Zero Page Atlas
The 6502 zero page is packed densely. Key allocations:
| Range | Purpose |
|-------|---------|
| `$08-$10` | V1 shadow (snapshot for re-chain across draws) |
| `$18-$1F` | xform-B accumulators (24-bit, 3 axes × hi/mid/lo) |
| `$21-$22` | Bresenham dx / dy |
| `$25-$27` | Polygon scan-fill max-edge / row-bounds / span column |
| `$28-$2A` | Altimeter angle bytes (1000s / 10000s / heading) |
| `$2D-$2E` | Cached-vertex pointer ($0140 + idx*8) |
| `$32-$33` | Frame work-cost accumulator |
| `$3E-$3F` | Message scroll dest pointer |
| `$4A-$52` | Section base (24-bit, 3 axes; signed) |
| `$53-$54` / `$EF-$F0` | Polygon scan-fill min/max X/Y |
| `$5A-$5D` | Aircraft world X (24-bit) |
| `$5E-$60` | Altitude (= world Y; $5F/$60 straddle the 8-bit boundary) |
| `$61-$65` | Aircraft world Z (24-bit) |
| `$66-$6B` | Section-relative origin offset = camera ZP image |
| `$6C-$6D` | Pitch (signed 16-bit) |
| `$6E-$6F` | Bank |
| `$70-$71` | Yaw |
| `$78-$89` | Active 3×3 rotation matrix (18 bytes, signed 16-bit per element) |
| `$8B-$8C` | Scenery dispatcher cursor |
| `$8E-$8F` | Hires row pointer |
| `$94-$95` | NeedlePosTable pointer (DrawIndicatorDialNeedle) |
| `$96-$97` | DrawIndicatorDialNeedle data pointer |
| `$98-$99` | TestSceneryRange bound |
| `$9A-$9B` / `$BA-$BB` | Hires row ptrs for the two pages |
| `$9E-$A2` | Pixel-list scratch |
| `$A5-$A8` | Loader src/dst pointers |
| `$A9-$AC` | Multiply scratch |
| `$AD` | Frame-stash flag (variant 6 of $07/$24) |
| `$B0-$B1` | Stashed X / Y across nested calls |
| `$B5` | Vertex output index in clip passes |
| `$B6-$B7` | Generic 16-bit scratch / Set3DigitString input |
| `$B8-$B9` | Set3DigitString string pointer |
| `$BE-$BF` | Compute-distance / Set3DigitString scratch |
| `$C2-$C5` | ScaleC2ByAX inputs |
| `$C6-$C7` | Cost accumulator |
| `$C9-$D2` | Vertex 1 (X/Y/Z hi+lo + outcode-shadow at $D1/$D2) |
| `$CA` | V1 outcode |
| `$CB-$D0` | V1 transformed coords |
| `$D3` | V2 outcode |
| `$D4-$D9` | V2 transformed coords |
| `$DA-$E2` | V2 snapshot for EmitClippedLine cleanup |
| `$E5` | Stream-emit cursor for xform-A |
| `$E6-$E7` | Pixel-list current position |
| `$E9` / `$EA` | Line endpoint 1 column / row (140-wide, 192-tall) |
| `$EB` / `$EC` | Line endpoint 2 column / row |
| `$F1-$F9` | Bresenham step state |
| `$FA` | `InputMode` |
| `$FB-$FD` | Instrument operational flags (3 bytes) |
| `$FE` | scratch |
Instrument operational flags `$FB/$FC/$FD` are bit-masks — bits set when the
corresponding gauge is operational. `HideOrShowInstruments` (chunk3) compares them
to the previous value and shows / hides each instrument's bitmap.
---
## 5. Aircraft State Model
The full aircraft state lives in two regions: zero page (high-frequency-access values
like position, attitude, velocities) and the `$08xx-$0Axx` "expanded state" region
in chunk4 (instrument values, key state, mode flags).
### 5.1 Position and orientation
* World position: `$5A-$65`, three signed 24-bit values (X, Y=Altitude, Z) in scenery
units. The unit is approximately 1/3 metre — used by the scenery system and
converted to/from metres at the boundaries.
* Attitude: `$6C-$71`, three signed 16-bit values (pitch, bank, yaw). The high byte
is the "byte angle" (0-256 = full circle), the low byte is sub-byte interpolation.
* `ViewDirection` (`$0A70`) — a 16-step view-around-the-aircraft index in $00..$0F.
Scaled ×16 into a byte angle and folded into yaw via `SetupViewProjection`.
### 5.2 Controls
| Name | Address | Meaning |
|------|---------|---------|
| `YokeHorizPos` (= `$09B0`) | $09B0 | Aileron position, signed |
| `YokeVertPos` | chunk4 | Elevator yoke position |
| `RudderPos` | chunk4 | Rudder position |
| `ElevatorTrim` | chunk4 | Trim setting (-84..+84) |
| `$0A5F` | | Flap position (0/32/64/...) |
| `$0A49` | | Mixture setting (0..7) |
| `$0A58` | | Carb heat (0=off, FF=on) |
| `PanelLights` | chunk4 | Instrument-light flag |
| `MagnetoState` | chunk4 | Magneto state (0=off, 1=L, 2=R, 3=both, 4=start) |
| `$0A6F` | | Throttle setting |
| `$0998` | | Fuel tank select |
### 5.3 Derived values
| Name | Address | Meaning |
|------|---------|---------|
| `Airspeed` (= `L0A11`) | $0A11 | 16-bit; high byte drives RPM curves + stall envelope |
| `$0990` | | Smoothed RPM displayed on tachometer |
| `$0843` | | Climb rate (signed 16-bit) |
| `$0A36/$0A37` | | Climb-rate indicator smoothed value |
| `$0830/$0831` | | Magnetic-compass smoothed angle |
| `$0A15/$0A16` | | Raw heading (16-bit, signed turn rate) |
| `$2A` | | Final compass byte for display |
---
## 6. Physics
### 6.1 IntegratePhysicsStep
`IntegratePhysicsStep` (`src/chunk5.s:8908`) is called every frame in non-slew mode.
It:
1. Reads `$09A5-$09A8` (body-velocity X/Z) and `$09D5/$09D6` (Y).
2. Adds the world-frame velocity components to `$5A-$65` (position). The world-frame
components are computed earlier in `ComputeFlightDerivedValues` by multiplying the
body velocity by the inverse rotation matrix.
3. If altitude `$5F/$60` would go below ground ($0000/$03), clamps it.
4. If the aircraft just touched down (`$089E` non-zero), calls
`ClampAltitudeOnTouchdown` to recompute altitude from climb rate.
5. Updates the live pitch/bank/yaw at `$6C-$71` by integrating angle deltas.
6. Sets `OnGroundFlag` based on the altitude clamp.
7. Triggers crash codes ($0834) when ground contact happens with bad pitch / bank.
### 6.2 ComputeFlightDerivedValues
Called near the top of the loop (`src/chunk5.s:8545`). Recomputes ~15 derived
quantities from the live pitch/bank/yaw and airspeed:
* sin(pitch), cos(pitch), sin(bank), cos(bank) via `L1763` / `L1778` (chunk4 trig).
* Rudder authority gauge.
* Body-frame X/Z velocity contributions (`$0A19-$0A1C`).
* Stall envelope: when the airspeed-derived term `$09AC` enters the danger band,
lights the fault state `$08B6` / `$0899`.
* Climb rate (`$0A0F/$0A10`) from pitch/bank/airspeed scale chain.
* Turn rate (`$09CD/$09CE`).
* Magnetic compass slew via `$08B2/$08B3`.
* Sideslip wrap: if the sideslip integrator at `$09AD` crosses ±90°, flips signs of
`$09AE/$09B0/$09E5` to keep the indicator symmetric.
### 6.3 IntegrateClimbRate
`IntegrateClimbRate` (`src/chunk5.s:8843`) slews the climb-rate needle and altitude
by 0/±1/±2 units per frame. The inner step (`ICR_Step`) compares climb rate against
the needle target, picks a +1/-1/0 nudge, and applies the OPPOSITE nudge to
altitude — so when the climb rate is +N, the needle moves up AND altitude increases.
The function uses a clever `jsr ICR_Step / fall-through to ICR_Step` trick to run
the step twice per call, giving 2 ticks of integration per frame.
A `beq L89A9` jump in the middle handles the "climb rate exactly matches the
needle" case as a 0-nudge shortcut.
### 6.4 UpdateAutoTrimAndYaw
Per-frame (`src/chunk5.s:9227`) — pulls `$09AF/$09B0` (yaw integrator) toward zero
by 5 units when the rudder is centred, clamping at zero. Also runs bomb-drop
housekeeping via `$0838`.
### 6.5 Wind (64K)
`ApplyWind` (chunk2) is patched into the main loop via `P64K_B`. It picks one of
three wind layers (`WindLayer1/2/3`) based on altitude, multiplies by the wind
direction's sin/cos, and adds to the body velocity. Skipped entirely in 48K mode.
### 6.6 CheckFlightEnvelope
Tests airspeed and attitude for stall/g-load violations and sets crash codes.
---
## 7. Scenery System
The scenery system is the biggest single piece of the codebase. It consists of a
disk-backed loader (chunk3), a paged scenery RAM area (`$A800-$BFFF`), and a
bytecode interpreter (chunk5 `ProcessScenery`) that walks per-section streams of
~70 different opcodes.
### 7.1 File Format Overview
A scenery file is a sequence of "sections". Each section is a contiguous stream of
opcode-bytes. Opcodes consume variable-length payloads (1 to 14+ bytes). Some
opcodes are flow-control (jump, sub-invoke, range cull); some emit vertices and
draws; some carry per-section frame data (offsets and axis permutations).
Sections live in scenery RAM at `$A800-$BFFF`. The dispatcher cursor `$8B/$8C`
walks forward through this region until it hits a stream-end opcode or an opcode
`$46` (invalid → end). Many sections call into others via `$0B` JumpRelative or
`$18` SubInvoke.
### 7.2 Loader
The scenery loader is in chunk3 (~`$D3xx-$D5xx`). Key entry points:
* `SceneryLoaderEntry1..7` (chunk3 `$DA-..` block) — public callable thunks.
* `SceneryReadFixed` — read N pages (256-byte units) into the buffer at L1E03,
advancing the L1E03 destination cursor.
* `SceneryReadUntilC0` — read bytes until the source pointer crosses into ROM space.
* `SceneryEnsureOpen` — initialize / re-open the disk read stream.
* `ComputeBlockFromSector` / `FetchSectorFromDisk` — chunk4 helpers.
The loader has two coupled state cells: `L1E01` (16-bit sector counter) and
`L1E03/L1E04` (destination pointer, advanced by every successful read). Multiple
in-flight loads share these by save/restore at the loader entries.
`LoadSceneryFile0..4` (chunk5 `$AB37`) each load a different per-purpose section
(world geometry, default scenery, etc.) by setting L1E01/L1E03 from the descriptor
words `LA60F..LA619`.
### 7.3 Dispatcher
`ProcessScenery` (`src/chunk5.s:1245`) is invoked per frame. It:
1. Sets `$8B/$8C` to the section entry from `LA7E0` (a 16-bit address).
2. Calls `SceneryInterpreterEntry``SceneryInterpreterStep` to fetch the next
opcode.
3. `SceneryDispatch` (`asl A / lda SceneryOpcodeTable,X / jmp (L00A5)`) jumps to
the handler. Each handler advances `$8B/$8C` past the record's bytes and tail-
jumps back to `SceneryInterpreterEntry`.
4. Continues until an opcode byte with bit 7 set, or ≥ `$46`. Both terminate the
pass.
### 7.4 Opcode Table
`SceneryOpcodeTable` (`src/chunk5.s:1158`) is 70 16-bit pointers covering opcodes
`$00..$45`. Opcodes above $45 are stream-end markers.
#### Vertex emit
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $00 | SceneryOpEmitV1XformAndPlot | 7 | Xform-A on V1, plot single pixel. (1 opcode + 6-byte XYZ stream → TransformVertex80C5 internally advances +7.) |
| $01 | SceneryOpEmitV1Xform80C5 | 7 | Xform-A on V1, no draw (line start) |
| $02 | SceneryOpEmitV2Xform80C5 | 7 | Xform-A on V2 → EmitClippedLine V1→V2 |
| $40 | SceneryOpEmitV1Xform7EBC | 5 | Xform-B (XZ only) on V1. (1 opcode + 4-byte XZ stream → TransformVertex7EBC internally advances +5.) |
| $41 | SceneryOpEmitV2Xform7EBC | 5 | Xform-B on V2 → EmitClippedLine |
#### Vertex cache (8-byte slots at $0140 + idx*8)
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $31 | SceneryOpRefreshCachedXform80C5 | 8 | Xform-A, write back to cache slot. (Opcode + 1-byte vtx-idx via SetVertexPointerFromStream/L6987 + 6-byte xform-A stream.) |
| $32 | SceneryOpVertexCachedV1 | 2 | Load cached V1 (opcode + 1-byte vtx-idx via L6987). |
| $33 | SceneryOpVertexCachedV2 | 2 | Load cached V2 → EmitClippedLine |
| $35 | SceneryOpVertexCachedDraw | 2 | Load cached V1 + plot pixel |
| $42 | SceneryOpRefreshCachedXform7EBC | 6 | Xform-B, write back to cache slot. (Opcode + 1-byte vtx-idx + 4-byte xform-B stream.) |
#### Curve
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $2B | SceneryOpEmitCurve | 9 | Two-vertex curve → 8 segments via midpoint subdivision. xform-B reads 4 bytes for V1, `dec $8B` re-reads, xform-B reads 4 bytes for V2 (= 5 - 1 + 5 = 9 total inc. opcode). |
#### Frame setup
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $07 | SceneryOpEnterLocalFrame | 14 | Set local frame $66..$6B from stream; 5 axis-permutation variants in L6BB0 |
| $24 | SceneryOpPushOriginWithStash | 8 | Same as $07 but stashes prior frame for `$AD` flag |
#### Cull / branch
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $04 | SceneryOpCullByOutcodeList | 4+N | AND outcodes of listed vertex indices; cull if all share half-plane. Length = 4 (header) + N (list bytes, terminated by sentinel). |
| $0B | SceneryOpJumpRelative | — | Tail-jumps to SceneryJumpToFetched; sets cursor from inline 16-bit target |
| $13 | SceneryOpJumpIfBeyondXY | — | TestSceneryRangePair on XY; passes ⇒ jump, else continue |
| $14 | SceneryOpJumpIfBeyondXYZ | — | Same shape, 3 axes |
| $20 | SceneryOpCullIfOutside1 | 9 | Read target + run TestSceneryRange once |
| $21 | SceneryOpCullIfOutside2 | 15 | Two range tests |
| $22 | SceneryOpCullIfOutside3 | 21 | Three range tests |
| $23 | SceneryOpJumpIfBitsClear | 7 | jump if `(mask1 & *ptr) == 0 && (mask2 & *(ptr+1)) == 0` |
| $28 | SceneryOpJumpIfWordCompare | 8 | 3 sub-modes (eq, signed-lt, alt-lt) compares 16-bit values at two ptrs |
Failure path for `$20..$22` pops to a saved jump target via
`TestSceneryRangeReject → L00A5`. Success uses fixed advance.
#### Data / records
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $03 | SceneryOpCall64K_2 | 6 | 48K: skip; 64K: tail-call SceneryRotatedTransform (chunk3) |
| $05 | SceneryOpADFRecord | 9 | 48K: skip; 64K: patched to JMP LookupADFStation |
| $0E | SceneryOpCall64K | — | 48K: rts; 64K: chunk3 callback |
| $1A | SceneryOpWriteWord | 5 | `*dst = *src` (16-bit), dst+src ptrs inline |
| $1D | SceneryOpNAVRecord | 11 | NAV station; checks $08F3 then matches frequency |
| $1E | SceneryOpCOMRecord | inline | COM/airport record; length byte at offset 1 |
| $25 | SceneryOpStoreImmWord | 5 | `*dst = imm16`; dst ptr (2 bytes) + 2 literal bytes (= 4-byte payload + opcode). |
| $29 | SceneryOpCopyToD2 | 1 | Two branches: line-emit (copy V2 → shadow) or polygon-fill |
#### Misc
| Op | Handler | Len | Summary |
|----|---------|-----|---------|
| $06 | SceneryOpDrawLine | 5 | Reads 4 raw screen-coord bytes into `$E9..$EC` and tail-jumps to `DrawColorLine` (HUD-style overlay; bypasses 3D pipeline). |
| $09 / $0A | SceneryOpSkip3 | 3 | Pure skip |
| $0D | SceneryOpHeader | 6 | Section-header + demand-load trigger. Latches 5 inline payload bytes into `$08E5..$08E9` (scale/range/tile-delta/slot index), and (when bit 7 of `$08E9` is clear) cumulatively advances the saved section-base cursor `$08E7` by the current cursor `$8B`. Masks `$08E9` to slot index 0..3 then calls `SceneryHeaderLoadIfMiss` via L8776: on a cache miss invalidates newer slots and DMA-fetches the section bytecode from disk via `SceneryHeaderRunSection`; on a cache hit returns immediately. |
| $0E | SceneryOpCall64K | 0 | 48K: `rts` immediately (= `SceneryOpReturn`); 64K: tail-jumps `SceneryOp64KCallback` (chunk3). No advance — pops the dispatcher recursion. |
| $11 | SceneryOpSkip1 | 1 | Pure 1-byte no-op. |
| $12 | SceneryOpSetColor | 2 | Inline byte indexes `ToHiresColorTable` (16 entries) to pick 1-of-8 hires colours, then calls `SetPixelDrawMode` to patch the 4 line/span ORA/AND opcode + 4 operand sites. |
| $18 | SceneryOpSubInvoke | 3 | Push cursor+3; jump to relative target; recurse `SceneryInterpreterStep`. |
| $19 | SceneryOpReturn | 0 | `rts` — pops one level of recursion. |
| $1B | SceneryOpModeWhite | 1 | Restores the line drawer to day mode: writes `STY $B1` ($84 $B1) at `DCLYMajorSaveByte` and `LDA (HiresRowPtr),Y` ($B1 $8E) at `DCLXMajorReadByte`, then `SetPixelDrawMode #$03` (HIRES_WHITE1). |
| $1C | SceneryOpDayOnly | 1 | If `$083C & $01` (= day phase) → skip; else patches `DCLYMajorSaveByte` to `BPL +$2E` ($10 $2E) and `DCLXMajorReadByte` to `BPL +$16` ($10 $16), then `SetPixelDrawMode #$01` (HIRES_VIOLET). The BPL offsets jump past the actual paint store so night-only objects don't render. |
| $2F | SceneryOpResetState | 1 | **Arms** polygon mode: `$2C = $FF`, `$B5 = 0`. Pairs with `$29` polygon-end. Falls through into `$11 SceneryOpSkip1` for the advance. The "Reset" name is misleading. |
Invalid opcodes (`$08, $0C, $0F, $10, $15, $16, $17, $1F, $26, $27, $2A, $2C, $2D,
$2E, $30, $34, $36-$3F, $43, $44, $45`) all use `SceneryOpInvalid` which calls
`L1F89` (`SwapPagesWithStash`) with `A = $90`, then falls through into
`SceneryDispatch` with whatever value L1F89 left in A — an in-engine recovery
fallback rather than a hard reset. The state-stash side effect preserves the
in-progress aircraft state through any fault.
**Stream end** (`SceneryStreamEnd`, `src/chunk5.s:1267`) fires not only on opcode
bytes with bit 7 set (= ≥ `$80`) but ALSO on any opcode in `[$46..$7F]` — these
neither dispatch nor return because the dispatcher's `cmp #$46 / bmi
SceneryDispatch` discards them. `SceneryStreamEnd` clears the three "lookup in
progress" mirrors (`$090A` / `$08F3` / `$08A9`) and `rts`s.
### 7.5 Sub-Invoke and Recursive Sections
`SceneryOpSubInvoke` ($18) pushes the post-record cursor and recurses
`SceneryInterpreterStep` to walk a child section. On return, the cursor is restored
and the parent stream continues. A depth counter at `$08B5` is decremented on
return so a nested section is treated identically to the top-level dispatch.
### 7.6 Section Coordinate Frame
Each section runs with a base origin at `$4A-$52` (24-bit) and a camera offset at
`$66-$6B` (16-bit-per-axis = base + cam-pos delta). The `$07`/`$24` opcodes update
this frame from inline stream bytes. Each axis pair `$66/$67`, `$68/$69`, `$6A/$6B`
holds the (cam_axis - section_anchor) delta that vertex transforms subtract from
each emitted vertex.
The five variants of `$07/$24` (encoded in the first byte of the record) permute
axes differently:
* L6CCE — 4× asl/rol cascade producing a halved frame
* L6C6E — byte combine: hi of scratch[i-1] with lo of scratch[i+1]
* L6C89 — cascade with scratch hi byte
* L6C53 — direct copy
* L6D28 — alternate axis order
These variants let a single bytecode encode the same scenery section at multiple
scales / orientations.
---
## 8. 3D Pipeline
### 8.1 Coordinate Frames
Three frames in play:
1. **World** — the absolute coordinate frame; aircraft `$5A-$65` lives here.
2. **Scenery-section** — translated by the section anchor, optionally permuted by
`$07/$24` variant.
3. **Camera-relative** — vertices after `TransformVertex` subtracts `$66-$6B`. This
is what's projected.
### 8.2 Matrix Setup (`SetupViewProjection`)
`SetupViewProjection` (`src/chunk5.s:203`) builds the 3×3 rotation matrix at
`$78..$89` from inputs:
* Yaw (`$70/$71`, 16-bit signed)
* Pitch (`$6C/$6D`)
* Bank (`$6E/$6F`)
* `ViewDirection` (`$0A70`, 4-bit index — scaled ×16 into the yaw)
The matrix is computed via 9 calls to `chunk4 L177B` (cosine table interpolation
with sub-byte fraction) followed by `ScaleC2ByC4` multiplies (16-bit signed). The
matrix elements end up scaled differently per column (`$L6301` applies col 0 >>= 1
and col 2 >>= 2) to keep multiply results in a known range — the renderer's
projection compensates.
The matrix at `$78..$89` is in *column-major* layout when the source uses it
multiplied as `(dx,dy,dz) * M`. Conceptually:
```
$78 $79 $7A $7B $7C $7D (row 0: cos(yaw)*cos(bank) etc)
$7E $7F $80 $81 $82 $83 (row 1: sin(pitch)*sin(yaw) etc)
$84 $85 $86 $87 $88 $89 (row 2: -sin(yaw)*cos(pitch) etc)
```
(The exact symbolic expansion depends on the rotation convention — see
`port/src/chunk5Setup.c` in the C port for the bit-perfect implementation.)
### 8.3 Vertex Transform
Two flavours, picked per opcode:
**`TransformVertex7EBC` (xform-B)** — 4-byte stream (X-hi/X-lo/Z-hi/Z-lo only;
Y comes from the section base). Used for ground-plane sceneries (buildings, runway
edges). Inputs: stream bytes `$66/$6A` (X/Z camera-axis delta). Output: 9 multiply-
add steps using the 9 matrix coefficients.
**`TransformVertex80C5` (xform-A)** — 6-byte stream (full XYZ). Used for sceneries
with vertical detail (towers, mountains). Same matrix multiply but reads Y from
the stream.
Both transforms:
1. Read the delta bytes from `($8B),y`.
2. Subtract `$66-$6B` to get camera-relative coordinates.
3. Auto-scale: if any |hi byte| ≥ $40, halve all 3 axes and increment `$2F`
(scale exponent). Repeats until the smallest fits.
4. Multiply by the 3×3 matrix at `$78..$89` via the chunk4 `ScaleC2ByC4` primitive.
5. Apply `L8234` range-check halve to keep the projected result in -127..127.
6. Store at `$D4-$D9` (V2) or `$CB-$D0` (V1).
The 6-iteration shift-and-add multiply at `L1818` (chunk4) is the inner kernel; we
have a bit-perfect C port at `port/src/chunk5Transform.c`.
### 8.4 Vertex Cache
A 32-slot ring at `$0140` (each slot 8 bytes) caches transformed vertices for
re-use across multiple line draws — a common scenery encoding where one vertex
participates in 5+ edges. The opcodes for the cache:
* `$31`/`$42` (RefreshCachedXform) — transform a new vertex and write to slot.
* `$32`/`$33` (VertexCachedV1/V2) — load a slot into V1 or V2 for the next emit.
* `$35` (VertexCachedDraw) — load slot into V1 and plot a single pixel.
The "slot valid" flag is the high bit of byte 7 of each slot ($DB-relative for V2,
$D2-relative for V1).
### 8.5 Frustum Clipping
A 6-plane Cohen-Sutherland-style outcode is computed per vertex by
`ClassifyVertex1` / `ClassifyVertex2`:
| Bit | Plane |
|-----|-------|
| $80 | z < 0 (behind viewer) |
| $40 | x + z < 0 (right edge) |
| $20 | z - x < 0 (left edge) |
| $10 | y + z < 0 (bottom) |
| $08 | z - y < 0 (top) |
Outcodes go to `$CA` (V1) / `$D3` (V2). The `EmitClippedLine` routine then:
1. ANDs the two outcodes; if non-zero, both ends share a clip plane cull.
2. ORs them; if non-zero, at least one is outside call `ClipBothVerticesToFrustum`.
3. Calls `ClipVertex2ToFrustum` if just V2 is outside.
4. Projects to screen via `ProjectV1ToScreen` / `ProjectV2ToScreen` and emits
the line via `DrawColorLine`.
5. After the draw, restores V1 from V2's shadow at `$DB` so the next emit can
chain off the same endpoint without re-transforming.
`HalveBothVertices` is the overflow-recovery path: if a per-axis subtract during
clipping overflows, halve both endpoints (preserves the line direction) and retry.
### 8.6 Sutherland-Hodgman for Polygon Fill
When a polygon (= sequence of vertex emits closed by `$29`) is detected, the
clipper runs 4 passes across all stored vertices:
* **Pass 1** (Left, plane Z-X=0): `PrimVerts → SecVerts`
* **Pass 2** (Top, plane Z-Y=0): `SecVerts → PrimVerts`
* **Pass 3** (Right, plane X+Z=0): `PrimVerts → SecVerts`
* **Pass 4** (Bottom, plane Y+Z=0): `SecVerts → PrimVerts`
Each pass walks the polygon vertices in order. For each edge (V[i] V[i+1]):
* If both inside the plane: emit V[i] to output.
* If V[i] inside, V[i+1] outside: emit V[i] then the clipped intersection.
* If V[i] outside, V[i+1] inside: emit the clipped intersection.
* If both outside: emit nothing.
The 4 passes share `$B4` (sticky outcode of last-classified vertex), `$B5` (output
count), `X` (input index, counts down), `Y` (output index, counts up). After all
4 passes the polygon is in `PrimVerts` ready for `PolygonScanFill_ProjectVertices`.
### 8.7 Perspective Projection
`ProjectVertex` (`src/chunk5.s:5151`) does the two perspective divides (X/Z and Y/Z)
that turn camera-relative coords into screen pixels:
1. Look up the X / Y axis perspective table (`kPerspXTable` at $7D80 / `kPerspYTable`
at $7E00) via a self-modified `LDA L7D77,X` (chunk5.s:5219). The table maps a
7-bit signed quotient final screen-pixel offset.
2. `PerspectiveDivide` does the divide: 7-bit signed quotient = numerator (X or Y)
/ denominator (Z). The algorithm is 8 unrolled iterations of shift-subtract,
interleaved across two paths (PosPath / NegPath) each iteration's branch
crosses to the opposite path at the same iteration index. The resulting
quotient ends up in `L00A5` as a sequence of "stayed on this path" bits.
The early-out paths handle |num| = |denom| (unity) and num + denom = 0 (negative
unity) without entering the loop.
---
## 9. Rasterization
### 9.1 Polygon Fill (`PolygonScanFillSetup`)
After clipping, the polygon goes through:
1. **`PolygonScanFill_ProjectVertices`** projects each clipped vertex to screen
via `ProjectVertex`, stashes screen-X in `SecVertXHi` and screen-Y in
`SecVertYHi`, tracks min/max row + column across all vertices, and adds an
estimated work cost to `$32/$33`.
2. **`PolygonScanFill_BuildEdgeList`** for each consecutive vertex pair, computes
the screen-Y delta. Three cases:
* delta = 0 horizontal edge (handled by `PolygonScanFill_HorizEdge`, which
folds consecutive horizontal edges into a single $FF-marked span).
* delta < 0 edge runs upward; encode top vertex first.
* delta > 0 → edge runs downward; encode top vertex first.
The per-edge state is stored back into `PrimVerts[x]`:
* `PrimVertXLo` = top screen row
* `PrimVertXHi` = row count - 1
* `PrimVertYHi` = top screen column
* `PrimVertZLo/ZHi` = column delta per row (signed Q8.8)
* `PrimVertYLo` = byte-bit half-row adjust flag
3. **`PolygonScanFill_SetupRowEmit`** — sets `$25 = max edge index`, `$B1 = top row`,
then runs the per-row scan loop.
4. **`PolygonScanFill_RowTop`** — for each row from top to bottom, walks all edges
`(X = $25 down to 0)`:
* If the edge crosses the current row, capture its X intercept into the sort
buffer at `L7889` (a self-modified `STA $02,X` site).
* If `PrimVertXHi` has the $FF horizontal-edge sentinel, call `DrawColorSpan`
for the captured horizontal span directly.
5. **`PolygonScanFill_SortIntercepts`** — bubble-sort the captured X values in
`$02..$0F` so spans emit left-to-right.
6. **`PolygonScanFill_EmitSpansLoop`** — for each consecutive pair of sorted
intercepts, call `DrawColorSpan` to emit the filled span.
7. **`PolygonScanFill_NextRow`** — `inc $B1`, loop.
### 9.2 `DrawColorLine` (Bresenham)
Standard Bresenham with two interesting wrinkles:
* **8-quadrant via swap + sign** — ensure `X1 ≤ X2` by swapping endpoints, then
derive |dx|, |dy|. The Y-step direction is encoded by self-modifying the
`INC $EA` / `DEC $EA` opcode at `DCLYMajorRowStep` based on whether Y2 > Y1.
* **Pick major axis** — if |dy| > |dx| use Y-major (paint one pixel per row, step
X conditionally); else X-major (paint one per column, step Y conditionally).
The inner Y-major and X-major loops each have 4 self-modified mask sites
(`L7982/L7A26/L7A2E/L7A9A`) and 3 self-modified OR sites (`L7976/L7A1A/L7A8E`) that
`SetPixelDrawMode` rewrites to swap between ORA / AND / EOR plus the even/odd
colour mask tables.
Two paint sites (`DCLYMajorSaveByte`, `DCLXMajorReadByte`) get patched to `BPL <skip>`
at night by `SceneryOpDayOnly` — when scenery defines day-only objects, those
ops are turned into no-ops via the BPL trick.
### 9.3 `DrawColorSpan`
`DrawColorSpan` (`src/chunk5.s:4283`) emits a horizontal span:
* Inputs: `A` = width in colour pixels (0..139), `$27` = right edge.
* Setup: compute byte-offset + bit-position for the right edge via
`AltColorPixelToByteTable` and `PixelToBitNumberTable`.
* Right-edge pixel loop: paint pixels one at a time until aligned on a byte boundary.
* Byte-mode middle: fill whole bytes via `DCSEnterByteMode`.
* Left-edge cleanup: paint the remaining 1..7 pixels.
The 4 self-mod sites at `L7921/L7922/L792D/L792E` rewrite the ORA mask table per
colour mode.
### 9.4 `PlotColorPixel`
Single-pixel toggle with self-modified ORA/AND at `L7976/L7983` and self-modified
operand at `L7977/L7984`.
### 9.5 Apple II Hires Layout
The Apple II hires screen is 280 pixels wide × 192 rows. Each byte holds 7 pixels
+ 1 colour-phase bit. The renderer uses a 140-pixel "colour pixel" model where:
* Each colour pixel is one of 4 hues (black, magenta, blue, white) depending on
byte and bit position.
* `OrMaskTable1/2` and `AndMaskTable1/2` are the masks for each colour group.
* `HiresTableLo/Hi` provides per-row hires page-1 base addresses (320 bytes ×
192 entries).
* `HiresPageDelta` is the difference (always $20) between page 1 and page 2 bases.
Page flipping is achieved by writing $20 to `HiresPageDelta` (page 1 visible,
draw on page 2) or $E0 (= -$20) for the reverse. `FlipPagesFillViewport` swaps
pages each frame.
---
## 10. Instrument Panel
### 10.1 Per-Instrument Strategy
Every instrument uses the same pattern:
1. Read the current value (e.g. RPM, throttle position).
2. Compare against the "last drawn" cell (one cell per instrument: chunk4
`LastDrawnElevatorPos`, `LastDrawnAileronPos`, `LastDrawnSlipSkid`,
`LastDrawnRudderPos`, `LastDrawnThrottle`, `LastDrawnFlaps`, `LastDrawnTrim`,
`LastDrawnMixture`, `LastDrawnFuelLeft`, `LastDrawnFuelRight`,
`LastDrawnOilTemp`, `LastDrawnOilPressure`).
3. If unchanged → RTS.
4. Else: redraw the OLD value (XOR mode → undraws it) then redraw the NEW value
(XOR mode → draws it).
5. Store new value to the "last drawn" cell.
The XOR mode for redraws is achieved by `SetPixelDrawMode #$83` which patches the
line drawer to EOR mode.
Most instruments are operational gates: a bit in `$FB/$FC/$FD` indicates the
instrument is working. Cleared bits cause `HideOrShowInstruments` (chunk3) to
paint over the gauge with the static panel background.
### 10.2 `DrawIndicatorDialNeedle` (chunk4)
The shared needle-painter for round dial instruments (airspeed, vertical-speed,
ADF, RPM). Takes:
* `A` = position byte (0..$57 for full sweep)
* `X` = needle index (0=alt1, 1=alt2, 2=airspeed, 3=vertical, 4=ADF)
Algorithm:
1. Pick the pixel-list table based on `IndicatorDialNeedleStyle[X]` (thin or thick).
2. Pick the dial-quadrant from the position byte:
* 0..$15: upper-left (`DIN_UpperLeft`, X increments, Y decrements)
* $16..$2B: upper-right (`DIN_UpperRight`, X decrements, Y decrements)
* $2C..$41: lower-left (`DIN_LowerLeft`, X decrements, Y increments)
* $42..$57: lower-right (`DIN_LowerRight`, X increments, Y increments)
3. Configure the inner pixel-emit loop's 4 self-modified opcode sites
(`L1A01/L1A04/L1A32/L1A38`) for the chosen quadrant via the
`SetUpForINX/DEX/INC/DEC` helpers.
4. Walk the pixel list, XOR-ing each pixel into the hires page.
The pixel-list format is a sentinel-terminated stream of (X-step, count) pairs.
### 10.3 Update*Indicator Family (chunk4)
Each of these is a thin wrapper that:
* Compares the new value against the per-instrument "last drawn" cell
* Calls a per-instrument inner `Draw` helper twice (old XOR-undraw, new XOR-draw)
* Stores the new value
The inner helpers each prep `(X, Y)` for a position and dispatch to one of:
* `DrawAileronOrRudderIndicator` (PLAileronAndRudderIndicators pixel list)
* `DrawFTM_Helper` (PLFlapsTrimMixtureIndicator — flaps/trim/mixture share)
* `DrawFuelOrOilGauge` (PLFuelAndOilGauges)
* `DrawSlipSkidIndicator` (PLSlipSkidIndicator)
* `DrawElevatorControlPositionIndicator` (PLElevatorControlPositionIndicator)
* `DrawThrottleIndicator` (PLThrottleIndicator)
### 10.4 Artificial Horizon (`UpdateArtificialHorizon`, chunk5)
Repaints the artificial-horizon area from the current bank ($6F) and pitch ($6D):
1. Fills the upper $1F rows with the "top half" colour (orange when upright, blue
when inverted).
2. If |pitch| ≥ $20 the horizon line is off-screen — just apply bezel masks.
3. Looks up the per-pitch slope offset in `ArtHorizonSlopeTable` (a 34-byte signed
LUT), multiplies by sin(bank), and derives the two horizon endpoints
(column, row) in `$E9/$EA` and `$EB/$EC`.
4. Self-modifies `DrawSkyGroundRow` to jump to `AltDrawSkyGroundRow` and invokes
`FillMixedViewportRows` to rasterise the slanted horizon line via Bresenham.
5. Fills the area below the horizon with the "bottom half" colour.
6. Applies the round-bezel `ApplyArtificialHorizonMask` to clip to a circle.
### 10.5 Turn Coordinator (`UpdateTurnCoordinator`, chunk5)
Computes a 16-step tilt index from $09CD/$09CE (turn rate). The tilt becomes a
4-element delta lookup in `$0DE0,X` (X = A*4), which gives two `DrawColorLine`
strokes mirrored around the centre (12, 166).
Per-page snapshots at `$0A40` (page 1) / `$0A41` (page 2) cache the previous
tilt; on page-flip, only the new page is repainted. The chunk5 dispatch table
exposes `DrawTurnCoordinatorAndSnapshot` (`$6015` trampoline) so external code
can force a redraw on the active page.
### 10.6 Magnetic Compass and Heading
`UpdateMagneticHeading` (`$0A15/$0A16` raw → `$2A` byte) reduces the raw 16-bit
heading by modulo $58 and clamps to ±$09 to track turn rate. `DrawHeading`
slews the displayed value at `$08B2/$08B3` toward 0 with rate-dependent steps,
then formats as a 3-digit string via `Set3DigitString`.
### 10.7 Altimeter
`UpdateAltimeterPose` (`src/chunk5.s:10022`) computes three altimeter hands from
altitude (`$5F/$60` + `$099E` correction term):
1. Multiplies by $24F4 (= ~0.144) to scale altitude to dial angle.
2. Modulo $0370 reduce by repeated subtract (full revolution).
3. Multiplies by $0CCC and adds $16 → 10,000-foot hand at `$29`.
4. Modulo $58 reduce → 1,000-foot hand at `$28`.
5. The 100-foot hand uses the chunk2 `UpdateAltimeter10K` routine.
### 10.8 Radios
| Radio | Mode | Storage |
|-------|------|---------|
| COM | 4 digits, 118.0-135.0 | `str_com1` |
| NAV1 / NAV2 | 4 digits, 108.0-117.0 | `str_nav1` / `str_nav2`; `$0A63` picks which to edit |
| VOR1 / VOR2 OBS | 3 digits, 000-359 | `VOR1ObsCourse` / `VOR2ObsCourse`; `$0A71` picks |
| ADF | 3 digits, kHz | `str_adf_frequency` (chunk3) |
| Transponder | 4 octal digits | `str_xpndr` |
| DME | 3 digits, distance | `str_dme` |
| OMI | 2 marker chars | `str_omi` |
The key handlers (`KeyDecrease` / `KeyIncrease` / `Select1` / `Select2`) dispatch
on `InputMode` ($FA) which holds the currently-edited instrument. See §12.
NAV1/NAV2 lookup is gated by `$08F4` (per-instrument enable bits). When a frequency
changes, `RequestNAV1Lookup` / `RequestNAV2Lookup` clears the active flag and arms
a fresh scenery scan for the new frequency. The scenery interpreter's `$1D`
NAVRecord opcodes carry the per-station data; on a match the station coords land
in `$08F9-$0900`.
VOR distance and bearing are computed in `ComputeStationDistance` (great-circle
approximation: max + min*~0.5 via a 16-bit multiply) and `ComputeVORBearing`
(distance-derivative scaled into a clamped deflection). The clamped deflection is
3-state: $00 (out of range, zeros indicator), $01 (saturated), $02 (on-radial).
### 10.9 ATIS Message Scroll (64K)
`DrawATISMessage` (chunk3) and the digit-formatting machinery at `LB148-LB337`
(chunk5) compose the rolling ATIS message displayed on the COM radio:
```
OHARE INTERNATIONAL AIRPORT
INFORMATION BRAVO 13:00 ZULU
WEATHER - VISIBILITY
10 - TEMPERATURE 53 -
WIND 00 AT 0 - ALTIMETER
29.95 - LANDING AND DEPARTING
RUNWAY 04 - ADVISE CONTROLLER...
```
A byte $80+ in the message stream encodes a fragment lookup via
`ChunkOffsetTable`. The digit-formatting pipeline handles airport-specific values
(wind, temp, visibility, ceiling, runway, time) with leading-zero suppression.
### 10.10 Engine Gauges
* **RPM** (`DrawRPM`) — formats `$0990` as 2 digits + '0' or '5' (even/odd RPM
bucket).
* **CHT / Oil Temperature** — slow-slewed targets in chunk3
`UpdateEngineWithMagneto`. The slew uses 8-bit fraction accumulators at
`CHTSlewFraction` / `OilTempSlewFraction`.
* **Fuel tanks L/R** — slow-slewed gauges driven by the fuel-burn accumulator at
`$0992-$0994` (left) / `$0995-$0997` (right).
---
## 11. Engine Model
### 11.1 48K Engine (`UpdateSimpleEngine`)
`UpdateSimpleEngine` (`src/chunk5.s:10887`) is the baseline:
1. Picks RPM curve from throttle (`$0A6F`).
2. Clamps to fuel-tank-available: if the selected tank is empty (X register loaded
from `$0994` left or `$0997` right is zero), RPM target = 0.
3. Applies a Reality Mode idle floor of $0D.
4. Every 32 ticks, burns fuel from the selected tank by the throttle-scaled rate.
5. Pegs CHT / oil temp to a fixed value ($05) — no dynamic gauge update.
In 64K mode this is patched to `JMP UpdateEngineWithMagneto` (chunk3).
### 11.2 64K Engine (`UpdateEngineWithMagneto`, chunk3)
The full engine has five phases:
1. **RPM curve selection** — combines throttle, carb-heat penalty (`$0A58`),
prop-spinup offset (`EngineSpinupCounter` after restart), mixture-fault clamp
(`$0991 & $03` = lean fault), and single-magneto degradation. Result is a curve
index into `RPMCurveIdle` (when engine off) or `RPMCurveRun`.
2. **Reality-mode idle floor** — same $0D floor as the 48K version.
3. **CHT gauge** (`$099C`) — target from engine state + mixture fault. Slews by $0A
per call (when enabled by `$FC & $40`).
4. **Oil temp gauge** (`$099D`) — same shape as CHT, slews by $64.
5. **Fuel consumption** — every 32 ticks, burns from the selected tank.
WW1 Ace mode multiplies the burn rate by 4 (via `txa / asl / asl`). The
left-tank-fault flag `$0991 & $04` clamps the burn to 0.
The magneto state (`MagnetoState` 0=off / 1=left / 2=right / 3=both / 4=start)
gates the engine-running check; bits in `LeftMagnetoOn` / `RightMagnetoOn` are
the actual electrical flags (cleared on failure).
---
## 12. Input
### 12.1 Key Buffer
`ReadKeyBuffer` / `WriteKeyBuffer` (`src/chunk5.s:7063`) implement an 8-slot key
ring buffer at `KeyBuffer`. The hardware keystroke is read in
`MaybeProcessKey` (`src/chunk5.s:7196`); the high bit drives a self-modified
`jmp ($0000)` dispatch through `KeyTable`.
`KeyTable` (chunk4) is a 32-entry table of 16-bit addresses; the index is
`(key - '`') * 2`. Keys A-Z, 0-9 and the major punctuation each map to a
specific handler:
| Key | Handler |
|-----|---------|
| `,` | KeyDecrease |
| `.` | KeyIncrease |
| `1` | Select1 (also magnetos: off) |
| `2` | Select2 (magnetos: right) |
| `3` | MagnetosLeft |
| `4` | SelectRadarView (magnetos: both) |
| `5` | Select3DView (magnetos: start) |
| `8` | SlewPitchUp |
| `9` | SlewPitchDown |
| `W` | DeclareWar |
| `Z` | SlewResetAngles |
| `X` | DropBomb |
| `Y` | FlapsUp |
| `N` | FlapsDown |
| `V` | TrimUp |
| `R` | TrimDown |
| `K` | ExitDemoMode |
| `S` | SaveModeToLibrary |
| `+` | ReadModeFromLibrary |
| `Q` | CoursePlotting |
| `Space` | BrakesOrGuns |
| `/` | ToggleThrottle |
| Ctrl+A | ADF |
| Ctrl+B | AltimeterAdjust |
| Ctrl+C | ComRadio |
| Ctrl+D | HeadingAdjust |
| Ctrl+F | FuelTankSelect |
| Ctrl+H | LessThrottle |
| Ctrl+I | CarbHeat |
| Ctrl+L | ToggleLights |
| Ctrl+M | MagsAndMixture |
| Ctrl+N | NavRadio |
| Ctrl+P | TogglePause |
| Ctrl+T | TransponderOrTransferParams |
| Ctrl+U | MoreThrottle |
| Ctrl+V | VORS |
| Ctrl+X | Transponder |
### 12.2 Paddles / Joystick
`ReadPaddles` (`src/chunk5.s:7100`) implements the classic Apple II paddle-read by
triggering `PTRIG` ($C070) and polling `PADDL0/PADDL1` until each discharges. The
result is `(X, Y) = (paddle0_count, paddle1_count)`. If paddle 0 saturates (X
wraps to 0), the routine pops its return address and returns to the caller's
caller — a "paddle saturated, abort the calling context" signal.
`ScalePaddleValue` clamps to ±127 with overflow saturation.
`CalibrateJoystickIfButtonDown` reads the paddle button to detect calibration mode.
### 12.3 `InputMode` State Machine
`InputMode` ($FA) holds the currently-edited instrument / control:
| Value | What's being edited |
|-------|---------------------|
| $02 | Radar View (Zoom Out / In) |
| $03 | Magnetos / Mixture (1/2/3 keys, chunk3 mixture patch) |
| $04 | COM Radio upper digits |
| $05 | COM Radio lower digits |
| $06 | NAV Radio upper digits (`$0A63` picks NAV1/NAV2) |
| $07 | NAV Radio lower digits |
| $08-$0B | Transponder digit 0..3 |
| $0C | VOR OBS course (`$0A71` picks VOR1/VOR2) |
| $0D-$0F | ADF frequency digit 0..2 (chunk3 patch path) |
| $10 | Fuel Tank Select |
`KeyDecrease` / `KeyIncrease` route ±1 to the corresponding handler via the
appropriate digit-edit routine (`DecComOrNavLowerDigits`, `DecNavDigits`,
`IncComOrNavLowerDigits`, `IncNavDigits`). Each handler updates the in-memory
frequency / position, then calls the relevant `Draw*` routine.
---
## 13. 64K Patch Mechanism
### 13.1 PatchTable Structure
`PatchTable` (`src/chunk5.s:11486`) is a flat 5-byte-per-entry table:
```
.addr address-to-patch
.byte $20 (JSR) or $4C (JMP)
.addr destination
```
Followed by `.word $0000` as a sentinel.
### 13.2 Apply64KPatchTable
`Apply64KPatchTable` (chunk5) walks the table after the 64K probe succeeds. For
each entry it writes 3 bytes at the patch site (replacing the JSR/JMP opcode +
target). Many patch sites are `JSR NoOp` stubs in 48K mode — patched into
real JSRs.
### 13.3 Patched Hooks
| Hook | 48K version | 64K target |
|------|-------------|------------|
| ADF | NOP NOP NOP | JMP ADFKeyboardHook (chunk3) |
| P64K_1 | JSR NoOp | JSR RequestADFStationLookup |
| SceneryOpADFRecord | LDA #$09 (= skip 9 bytes) | JMP LookupADFStation |
| P64K_2 | JSR NoOp | JSR UpdateADFIndicator |
| KeyDecrease | LDA InputMode | JSR KeyDecreasePatch |
| KeyIncrease | LDA InputMode | JSR KeyIncreasePatch |
| P64K_3 | JSR NoOp | JSR DrawViewOverlays |
| P64K_4 | JSR NoOp | JSR UpdateAltimeter10K |
| P64K_5 | JSR NoOp | JSR RealityModeHook |
| P64K_6 | JSR NoOp | JSR UpdateInstrumentLights |
| LA55A (UpdateSimpleEngine) | LDA $0A6F (start of 48K engine) | JMP UpdateEngineWithMagneto |
| P64K_7 | JMP DrawMessageWhite | JMP DrawMagnetoStateHook |
| ApplyMagnetoState | LDY #$00 | JMP SetMagnetoFromA |
| SelectRadarView | (48K body) | JMP SelectRadarViewPatch |
| Select3DView | (48K body) | JMP Select3DViewPatch |
| P64K_8 | JSR NoOp | JSR HideOrShowInstruments |
| L1EAD/L1EB0/...L1EC1 (chunk4 thunks) | JMP $20XX | JMP SceneryLoaderEntry1..7 |
| CoursePlotting | (rts) | JMP CoursePlottingMenu |
| P64K_9 | JSR NoOp | JSR UpdateCoursePlotter |
| P64K_A | NOP | JSR DrawSlewOverlays |
| LA60C | (NOPs) | JMP DrawATISMessage |
| L6026 | RTS | JMP UpdateCOMMessageChunks |
| LA5E1 (ComputeDayPhase48K) | LDX #$01 | JMP ComputeDayPhase |
| L87A5 | JSR NoOp | JSR ApplyWind |
| P64K_B | JSR NoOp | JSR ComputeWindComponents |
| P64K_C | JSR DemoMode48K | JSR DemoMode64K |
| L87BE (ShowSimpleCrashMessage) | (full body) | JSR HandleCrashOrSplash |
After patching, the patch table also writes language-card vectors and the
`Has64K = 1` flag, then completes init.
---
## 14. WW1 Ace Mode
WW1 Ace is a dogfight mode bundled with the simulator. Most of the *logic* lives
in a special scenery file loaded via `LoadSceneryFile1` (= `PatchSlot_PreMode`),
so this code base only contains the *engine* hooks.
### 14.1 Mode Entry
`WW1AceMode` (chunk4 single byte) is non-zero when the mode is active. The
disassembled code does not explicitly set this — it's enabled by either the
mode-library load (`+` key / `ReadModeFromLibrary`) bringing in a WW1 mode
preset, or by scenery-load code in chunk3.
The `W` key (`DeclareWar`, `src/chunk5.s:8180`) sets `WarDeclared` — a separate
gate that, combined with `WW1AceMode != 0`, enables enemy AI.
### 14.2 Per-Enemy State
Six enemies at offsets:
```
$A972 Enemy 1 (header + state, 28 bytes)
$A98E Enemy 2
$A9AA Enemy 3
$A9C6 Enemy 4
$A9E2 Enemy 5
$A9FE Enemy 6
```
Stride is $1C (28 bytes). The status byte at offset +0 is documented in the war
report messages as:
* `0` = SHOT DOWN
* `1` = RETURNING OR HOME
* `2` = ATTACKING
The rest of the 28-byte record (position, velocity, attack timer) is read/written
by scenery bytecode we don't disassemble. The engine just exposes the storage.
### 14.3 Bomb Drop
`DropBomb` (X key, `src/chunk5.s:8281`) requires:
* `WW1AceMode != 0`
* `WW1AceBombsStr != '0'` (= "BOMBS: 5" string has digits left)
* `$0A56/$0A57` (= bomb timer) is zero (no bomb currently falling)
When triggered, decrements the bomb count digit and copies the current altitude
high bytes into `$0A54/$0A55` as the bomb fall timer. `TickBombTimer`
(`src/chunk5.s:12671`) is called per frame to decrement the timer and redraw
"Time on target" digits at `$A87B`.
### 14.4 Gun Fire
`FireGuns` (= L90B2, `src/chunk5.s:8390`) is the in-air branch of `BrakesOrGuns`
(Space key). When `WW1AceMode` is active and `$0A54/$0A55` (overloaded as bullet
count when no bomb is falling) is non-zero, sets a 3-tick gun-burst counter at
`$0A60`.
`PatchSlot_Gunsight` is called every frame from `MainLoop` when
`WW1AceMode != 0`. The 64K patch table rewrites it to draw the gunsight overlay
on the viewport.
### 14.5 War Report
`ShowWarReport` (chunk3) is triggered by `$08C8`. It composes a damage summary:
* Kills: `$0898` (3-digit) → `str_enemy_shot_down`
* Bomb hits: `$A81B` → `str_bomb_hits`
* Damage taken: `$08A4` → `str_damage_by_enemy`
* Each enemy's status digit at `$A972..$A9FE` → `str_enemyN_status`
Then `ClearViewportsToBlack` + `DrawMultiMessage(msg_war_report)` + 9 follow-up
messages + `TogglePauseRelay` (= pauses the sim until any key).
The war-report trigger flag `$08C8` is set in exactly one in-binary site:
`TrimDown` (the R key handler, `src/chunk5.s:8478`) does `inc $08C8`. This is
likely a debug / developer-mode trigger — pressing R while flying in WW1 mode
forces the war report. Otherwise the flag is set by scenery bytecode when a
kill / mission-end event occurs.
### 14.6 Scoring Display
`WW1AceScore` (chunk4) holds the current score; `WW1AceScoreStr`
("SCORE:000" in the bomb-bay overlay) is the displayed value. `WW1AceBombsStr`
("BOMBS: 5") is the bomb count.
When `$0838 == $02` (= respawn with reload), `ResetAircraftSystems`
(`src/chunk5.s:9035`) reloads `$0A54 = $64` (bombs) and `WW1AceBombsStr = '5'`.
**Kill detection, score updates, bomb-hit detection, and enemy AI all happen
inside the scenery bytecode for the WW1 mode** — they're not in this
disassembly. We verified this by grepping for every absolute address in the
$A972..$AA19 range and the $0898 / $A81B / $08C8 cells across all four chunks +
complete.s. The binary contains zero writes to the per-enemy state table; only
`ShowWarReport` reads enemy status bytes (offset 0 of each 28-byte slot), and
no in-binary code increments kills (`$0898`) or bomb hits (`$A81B`). The
`$1A SceneryOpWriteWord` and `$25 SceneryOpStoreImmWord` opcodes are the
mechanism — scenery files in WW1 mode carry inline destinations pointing at
these cells.
---
## 15. Self-Modifying Tricks
The original makes extensive use of self-modifying code for performance. The most
notable patterns:
### 15.1 Self-Modified Opcode Sites
Routines like `DrawColorLine`, `DrawColorSpan`, `PlotColorPixel`, and
`DrawIndicatorDialNeedle` have ~20 self-modified opcode bytes between them. The
modifications are done by `SetPixelDrawMode` (= colour selection) and by the
`SceneryOpDayOnly` / `SceneryOpModeWhite` opcodes (= day/night switching).
Common patterns:
* INC/DEC swap for step direction (`L7A06`, `L7A74` in DrawColorLine).
* ORA/AND swap for additive vs masked colour writes.
* STY/LDA → BPL skip for "skip this paint at night".
* INX/DEX swap for pixel-step direction in `DrawIndicatorDialNeedle`.
### 15.2 Self-Modified Operand Sites
`L7889` in `PolygonScanFill_EdgeIntercept` is `STA $02,X` whose operand byte is
also a *counter* incremented each iteration — the address-byte advance and the
buffer-write are folded into one. `L7977/L7984` in `PlotColorPixel` are operand
bytes pointing into the colour mask tables.
`L7D77/L7D78` in `PerspectiveDivide` rewrites the absolute-address byte of the
lookup-table fetch so the same code serves both the X-projection and Y-projection
divides — the only difference is which 128-byte table is read.
### 15.3 Byte-Overlap Tricks
Several places use the 6502 trick where a single byte is read both as a 1-byte
opcode (when entered earlier) and as an operand (when entered at the byte's
address). Documented sites:
* **LA2E9 / LA2EA in `ClampVORDeflection`** — the byte `$E0` at LA2E9 starts a
`CPX #$FE` instruction whose operand is the byte `$FE` that's also the opcode
of `INC $0E90,X` when read from LA2EA.
* **LB14E in `ClearATISScrollRegion`** — `.byte $AD` is the opcode for `LDA abs`.
When entered before LB150 the byte stream reads as a clean "lda $0978 / and #$7F /
sta $0978" sequence; when entered AT LB150 the same bytes read as "ora #$29 /
.byte $7F / sta $0978".
* **`.byte $F0 $3A` in `IntegrateClimbRate`** — at $896D the byte stream encodes
`beq L89A9` (= the rts at the end of the routine) but the disassembler can't
emit it as `beq` because $89A9 isn't a label at disassembly time. We've
documented this and converted it to `beq L89A9` (still byte-identical).
* **`.byte $85` in `SceneryFindSectionByDefault`** — the byte `$85` is `STA zp`
whose operand is the next byte. Entered before, reads as `lda #$00 / sta $20 /
.byte $92 / .byte $A7 / jmp LA773`. Used to pre-zero `LAA52` before falling
into the search loop.
### 15.4 "JSR self then fall-through" pattern
`IntegrateClimbRate` uses the trick `L8956: jsr L8959 / L8959: ldy #$00 / ...`.
The `jsr L8959` executes the body once, then the RTS returns to L8959+3 — but
because L8959 is immediately after the JSR, control falls through into the same
body for a second execution. The end result: the inner step runs twice per call
with no loop counter.
### 15.5 Day/Night Patching
`SceneryOpDayOnly` ($1C) writes `BPL <skip>` opcodes at two paint sites in
`DrawColorLine`. Day-only scenery objects (cars, runway lights) emit
`SceneryOpDayOnly` before drawing themselves and `SceneryOpModeWhite` after —
during night frames, the BPL skips suppress the actual byte writes, so the
object isn't visible. During day frames the opcodes are still STY/LDA so the
draw works normally. The trick avoids any per-pixel branch on the day/night flag.
---
## 16. Mode Library
`ModeLibraryAction` (chunk4 `$0381`, single byte: `1 = read`, `2 = save`) is a
*deferred-action flag*, not an immediate I/O trigger. The mechanism:
1. `+` key (`ReadModeFromLibrary`, `src/chunk5.s:8360`) → `ldx #$01 / stx
ModeLibraryAction`.
2. `S` key (`SaveModeToLibrary`, `src/chunk5.s:8366`) → `ldx #$02 / stx
ModeLibraryAction`.
3. At the top of `MainLoop` (`src/chunk5.s:6788-6791`), the check
`lda EditModeFlag / ora ModeLibraryAction / beq :+ / jsr PatchSlot_PreMode`
fires the pre-mode hook on any frame where either flag is non-zero.
4. `PatchSlot_PreMode` (`src/chunk5.s:11917`) is `jsr LoadSceneryFile1` (=
load the scenery file at descriptor `LA611` = sector `$0625`) with 3 BRK
pads for runtime widening.
5. The loaded scenery file contains the actual save / load implementation as a
sequence of `$1A SceneryOpWriteWord` / `$25 SceneryOpStoreImmWord` opcodes
that read or write the aircraft state cells. The action discrimination
(read vs save) happens inside that scenery bytecode by reading
`ModeLibraryAction`.
There is **no disk-IO code in chunks 2-5 that implements the library save** —
all serialization is the scenery bytecode reading/writing through standard
scenery opcodes. The "mode library" is therefore really just a slot in a
specifically-organized scenery file that the engine can read in or rewrite by
re-running the same scenery section after the in-RAM state has been mutated.
The original disassembly comment about `$8758/$875B/$8761/$AA03` self-modifying
the patch slot was speculative or stale — those addresses contain unrelated
JMP aliases and ASCII text ("OR M..." for "OR MONITOR"), no slot rewrite code
is present in the shipped binary.
---
## 17. Known Unknowns
After the research passes in §1-16, the remaining open questions are short:
### 17.1 WW1 Ace Enemy AI
The 6-enemy state table at `$A972..$A9FE` is updated entirely by scenery
bytecode (verified: zero in-binary writes). Kill detection, attack patterns,
position updates, and score increments live in a WW1-specific scenery file
loaded via `PatchSlot_PreMode → LoadSceneryFile1`. To fully reverse-engineer
the dogfight logic would require disassembling that scenery file's opcode
stream — outside the scope of the binary disassembly.
### 17.2 Some Opcode Edge Cases
A few scenery-data records (the COM `$1E` variable-length records, the curve
subdivision counters in `$2B`) only fully make sense when seen with real
scenery byte streams. The handlers in chunk5 are byte-faithful to the source,
but their exact intended payload formats would benefit from cross-referencing
with the on-disk scenery files.
### 17.3 Easter Eggs
`TrimDown` (R key) does `inc $08C8` which triggers `ShowWarReport`. This
appears to be either a developer test-bind or an intentional easter egg.
Similarly, `WarDeclared` gets transitioned to `3` in an unreached code block
at `src/chunk5.s:12590` annotated "unused???" — possibly an aborted
post-war or game-over screen.
---
## Appendix A: Reading the Disassembly
The source files under `src/`:
* `complete.s` — top-level glue; defines absolute-address symbols
* `chunk2.s` / `chunk3.s` / `chunk4.s` / `chunk5.s` — disk chunks
* `macros.inc` — utility macros (LDAX, STAX, ADD16, etc.)
* `asm.cfg` — ca65 linker config (single MAIN segment)
To build:
```
export PATH=/tmp/cc65/bin:$PATH # cc65 toolchain
make # assemble all chunks
make validate # diff against orig/ for byte-identical
```
`make validate` is the gold standard — any change must keep it passing.
## Appendix B: References to External Material
* The original Sublogic Flight Simulator II user manual (1984) documents the
keyboard mapping, modes, and aircraft model. Many of our key-handler names
derive from manual terminology.
* `qkumba/a2stuff/fs2/` on GitHub is the upstream of this disassembly — qkumba's
ProDOS port re-packages the original FS2 binary into a ProDOS-bootable image
and the disassembly source we're working from.
* `port/` (in this repo) is the partial C re-port — when in doubt about an
algorithm's bit-exact behaviour, the C port's `chunk5Transform.c`,
`chunk5Setup.c`, etc. are bit-perfect oracles.