63 KiB
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. Lxxxxare raw labels from the disassembly; semantic names likeMainGameEntryare aliases we've added. Both resolve to the same byte address.- Citations are
file:lineagainstsrc/. - "ZP" = zero page (
$00..$FF).
Table of Contents
- System Overview
- Boot & Initialization
- Main Loop
- Zero Page Atlas
- Aircraft State Model
- Physics
- Scenery System
- 3D Pipeline
- Rasterization
- Instrument Panel
- Engine Model
- Input
- 64K Patch Mechanism
- WW1 Ace Mode
- Self-Modifying Tricks
- Mode Library
- 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) betweenHorizonANDMaskandTogglePauseRelayin 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
NoOpplaceholders. - 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, swappingNoOpstubs 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:
ldx #$3F / txs— reset the stack.jsr LoaderNoOpSuccess— open / handshake the disk loader (chunk4).jsr ReloadGameChunks— re-pull chunk4 (sectors $00..$06 to $0200) and chunk5 (sectors $08..$19 to $6000). The 32 bytes atLA7E0(= the scenery dispatcher entry point that chunk5 overwrites in its own image) are stashed viaLABCCthen restored.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.jsr ProbeLCMemory— 64K detection (see below).jsr SceneryCopyLoadedSection(=LA6F9) — finalize the first scenery section.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:
jsr Apply64KPatchTable— no-op on 48K, runs the patch list on 64K.jsr InitGraphicsScreens— sets the IIE soft switches for hires page 1 visible, double-buffer to page 2.jsr PromptColorOrBW— color/monochrome prompt (only matters for the dither tables).jsr InitInstruments— paints the static cockpit panel bitmaps.jsr InitInstrumentSaveBuffers— captures the panel bitmap's hires bytes into the per-instrument save buffers in chunk3 ($E800-$FC78); see §10.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 modePatchSlot_Gunsight— WW1 gunsight overlayPatchSlot_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 viaSetupViewProjection.
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:
- Reads
$09A5-$09A8(body-velocity X/Z) and$09D5/$09D6(Y). - Adds the world-frame velocity components to
$5A-$65(position). The world-frame components are computed earlier inComputeFlightDerivedValuesby multiplying the body velocity by the inverse rotation matrix. - If altitude
$5F/$60would go below ground ($0000/$03), clamps it. - If the aircraft just touched down (
$089Enon-zero), callsClampAltitudeOnTouchdownto recompute altitude from climb rate. - Updates the live pitch/bank/yaw at
$6C-$71by integrating angle deltas. - Sets
OnGroundFlagbased on the altitude clamp. - 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
$09ACenters 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
$09ADcrosses ±90°, flips signs of$09AE/$09B0/$09E5to 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:
- Sets
$8B/$8Cto the section entry fromLA7E0(a 16-bit address). - Calls
SceneryInterpreterEntry→SceneryInterpreterStepto fetch the next opcode. SceneryDispatch(asl A / lda SceneryOpcodeTable,X / jmp (L00A5)) jumps to the handler. Each handler advances$8B/$8Cpast the record's bytes and tail- jumps back toSceneryInterpreterEntry.- 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:
- World — the absolute coordinate frame; aircraft
$5A-$65lives here. - Scenery-section — translated by the section anchor, optionally permuted by
$07/$24variant. - Camera-relative — vertices after
TransformVertexsubtracts$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:
- Read the delta bytes from
($8B),y. - Subtract
$66-$6Bto get camera-relative coordinates. - Auto-scale: if any |hi byte| ≥ $40, halve all 3 axes and increment
$2F(scale exponent). Repeats until the smallest fits. - Multiply by the 3×3 matrix at
$78..$89via the chunk4ScaleC2ByC4primitive. - Apply
L8234range-check halve to keep the projected result in -127..127. - 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:
- ANDs the two outcodes; if non-zero, both ends share a clip plane → cull.
- ORs them; if non-zero, at least one is outside → call
ClipBothVerticesToFrustum. - Calls
ClipVertex2ToFrustumif just V2 is outside. - Projects to screen via
ProjectV1ToScreen/ProjectV2ToScreenand emits the line viaDrawColorLine. - After the draw, restores V1 from V2's shadow at
$DBso 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:
- Look up the X / Y axis perspective table (
kPerspXTableat $7D80 /kPerspYTableat $7E00) via a self-modifiedLDA L7D77,X(chunk5.s:5219). The table maps a 7-bit signed quotient → final screen-pixel offset. PerspectiveDividedoes 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 inL00A5as 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:
-
PolygonScanFill_ProjectVertices— projects each clipped vertex to screen viaProjectVertex, stashes screen-X inSecVertXHiand screen-Y inSecVertYHi, tracks min/max row + column across all vertices, and adds an estimated work cost to$32/$33. -
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 rowPrimVertXHi= row count - 1PrimVertYHi= top screen columnPrimVertZLo/ZHi= column delta per row (signed Q8.8)PrimVertYLo= byte-bit half-row adjust flag
- delta = 0 → horizontal edge (handled by
-
PolygonScanFill_SetupRowEmit— sets$25 = max edge index,$B1 = top row, then runs the per-row scan loop. -
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-modifiedSTA $02,Xsite). - If
PrimVertXHihas the $FF horizontal-edge sentinel, callDrawColorSpanfor the captured horizontal span directly.
- If the edge crosses the current row, capture its X intercept into the sort
buffer at
-
PolygonScanFill_SortIntercepts— bubble-sort the captured X values in$02..$0Fso spans emit left-to-right. -
PolygonScanFill_EmitSpansLoop— for each consecutive pair of sorted intercepts, callDrawColorSpanto emit the filled span. -
PolygonScanFill_NextRow—inc $B1, loop.
9.2 DrawColorLine (Bresenham)
Standard Bresenham with two interesting wrinkles:
- 8-quadrant via swap + sign — ensure
X1 ≤ X2by swapping endpoints, then derive |dx|, |dy|. The Y-step direction is encoded by self-modifying theINC $EA/DEC $EAopcode atDCLYMajorRowStepbased 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
AltColorPixelToByteTableandPixelToBitNumberTable. - 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/2andAndMaskTable1/2are the masks for each colour group.HiresTableLo/Hiprovides per-row hires page-1 base addresses (320 bytes × 192 entries).HiresPageDeltais 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:
- Read the current value (e.g. RPM, throttle position).
- Compare against the "last drawn" cell (one cell per instrument: chunk4
LastDrawnElevatorPos,LastDrawnAileronPos,LastDrawnSlipSkid,LastDrawnRudderPos,LastDrawnThrottle,LastDrawnFlaps,LastDrawnTrim,LastDrawnMixture,LastDrawnFuelLeft,LastDrawnFuelRight,LastDrawnOilTemp,LastDrawnOilPressure). - If unchanged → RTS.
- Else: redraw the OLD value (XOR mode → undraws it) then redraw the NEW value (XOR mode → draws it).
- 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:
- Pick the pixel-list table based on
IndicatorDialNeedleStyle[X](thin or thick). - 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)
- 0..$15: upper-left (
- Configure the inner pixel-emit loop's 4 self-modified opcode sites
(
L1A01/L1A04/L1A32/L1A38) for the chosen quadrant via theSetUpForINX/DEX/INC/DEChelpers. - 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
Drawhelper 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):
- Fills the upper $1F rows with the "top half" colour (orange when upright, blue when inverted).
- If |pitch| ≥ $20 the horizon line is off-screen — just apply bezel masks.
- 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/$EAand$EB/$EC. - Self-modifies
DrawSkyGroundRowto jump toAltDrawSkyGroundRowand invokesFillMixedViewportRowsto rasterise the slanted horizon line via Bresenham. - Fills the area below the horizon with the "bottom half" colour.
- Applies the round-bezel
ApplyArtificialHorizonMaskto 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):
- Multiplies by $24F4 (= ~0.144) to scale altitude to dial angle.
- Modulo $0370 reduce by repeated subtract (full revolution).
- Multiplies by $0CCC and adds $16 → 10,000-foot hand at
$29. - Modulo $58 reduce → 1,000-foot hand at
$28. - The 100-foot hand uses the chunk2
UpdateAltimeter10Kroutine.
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$0990as 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 atCHTSlewFraction/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:
- Picks RPM curve from throttle (
$0A6F). - Clamps to fuel-tank-available: if the selected tank is empty (X register loaded
from
$0994left or$0997right is zero), RPM target = 0. - Applies a Reality Mode idle floor of $0D.
- Every 32 ticks, burns fuel from the selected tank by the throttle-scaled rate.
- 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:
-
RPM curve selection — combines throttle, carb-heat penalty (
$0A58), prop-spinup offset (EngineSpinupCounterafter restart), mixture-fault clamp ($0991 & $03= lean fault), and single-magneto degradation. Result is a curve index intoRPMCurveIdle(when engine off) orRPMCurveRun. -
Reality-mode idle floor — same $0D floor as the 48K version.
-
CHT gauge (
$099C) — target from engine state + mixture fault. Slews by $0A per call (when enabled by$FC & $40). -
Oil temp gauge (
$099D) — same shape as CHT, slews by $64. -
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 & $04clamps 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 DOWN1= RETURNING OR HOME2= 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 != 0WW1AceBombsStr != '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,L7A74in 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$E0at LA2E9 starts aCPX #$FEinstruction whose operand is the byte$FEthat's also the opcode ofINC $0E90,Xwhen read from LA2EA. -
LB14E in
ClearATISScrollRegion—.byte $ADis the opcode forLDA 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 $3AinIntegrateClimbRate— at $896D the byte stream encodesbeq L89A9(= the rts at the end of the routine) but the disassembler can't emit it asbeqbecause $89A9 isn't a label at disassembly time. We've documented this and converted it tobeq L89A9(still byte-identical). -
.byte $85inSceneryFindSectionByDefault— the byte$85isSTA zpwhose operand is the next byte. Entered before, reads aslda #$00 / sta $20 / .byte $92 / .byte $A7 / jmp LA773. Used to pre-zeroLAA52before 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:
+key (ReadModeFromLibrary,src/chunk5.s:8360) →ldx #$01 / stx ModeLibraryAction.Skey (SaveModeToLibrary,src/chunk5.s:8366) →ldx #$02 / stx ModeLibraryAction.- At the top of
MainLoop(src/chunk5.s:6788-6791), the checklda EditModeFlag / ora ModeLibraryAction / beq :+ / jsr PatchSlot_PreModefires the pre-mode hook on any frame where either flag is non-zero. PatchSlot_PreMode(src/chunk5.s:11917) isjsr LoadSceneryFile1(= load the scenery file at descriptorLA611= sector$0625) with 3 BRK pads for runtime widening.- The loaded scenery file contains the actual save / load implementation as a
sequence of
$1A SceneryOpWriteWord/$25 SceneryOpStoreImmWordopcodes that read or write the aircraft state cells. The action discrimination (read vs save) happens inside that scenery bytecode by readingModeLibraryAction.
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 symbolschunk2.s/chunk3.s/chunk4.s/chunk5.s— disk chunksmacros.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'schunk5Transform.c,chunk5Setup.c, etc. are bit-perfect oracles.