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