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

64 KiB
Raw Blame History

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
  2. Boot & Initialization
  3. Main Loop
  4. Zero Page Atlas
  5. Aircraft State Model
  6. Physics
  7. Scenery System
  8. 3D Pipeline
  9. Rasterization
  10. Instrument Panel
  11. Engine Model
  12. Input
  13. 64K Patch Mechanism
  14. WW1 Ace Mode
  15. Self-Modifying Tricks
  16. Mode Library
  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 SceneryInterpreterEntrySceneryInterpreterStep 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 rtss.

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_NextRowinc $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: $A81Bstr_bomb_hits
  • Damage taken: $08A4str_damage_by_enemy
  • Each enemy's status digit at $A972..$A9FEstr_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.