# 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 ` 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 ` 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 Unreached `WarDeclared = 3` Block `src/chunk5.s:12634` carries an "Unused???" annotation over a block that transitions `WarDeclared` from `$01` to `$03`, calls `DrawMultiMessage` twice, and busy-waits through a 65k-iteration delay. No code path reaches it in the current disassembly — possibly an aborted post-war or game-over screen that was never wired up. --- ## 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.