44 KiB
FS2 Scenery Database Opcode Reference
Complete reference for the chunk5 scenery interpreter opcodes, derived from
the source disassembly at /home/scott/claude/flight/src/chunk5.s. Each
record in the scenery DB starts with a 1-byte opcode followed by a fixed
or variable number of parameter bytes.
The interpreter (SceneryInterpreterStep at chunk5.s:1264) reads one byte
from the cursor ($8B/$8C), dispatches via SceneryOpcodeTable (= 70
2-byte addresses at chunk5.s:1165). Opcodes with bit 7 set OR codes in
[$46..$7F] BOTH trigger SceneryStreamEnd (= terminator) -- the dispatch
predicate is cmp #$46 / bmi SceneryDispatch, so $46..$7F fall through to
the same end-of-stream handler as $80..$FF.
Total record length includes the opcode byte itself.
"source" vs "MAME-patched"
This document distinguishes two views of the chunk5 binary:
-
"source" — the original 48K Apple ][+ image, as it appears in the disassembly at
/home/scott/claude/flight/src/chunk5.s. This is what shipped on disk. -
"MAME-patched" — the post-boot binary as actually observed in
port/sceneryRam_FS2.1.bin(a 64K RAM snapshot from a running MAMEapple2gsafter FS2 boot). The reference screenshotport/screenshots/mame_meigs.pngalso comes from that same MAME instance, so to match the reference the port must replicate the runtime behavior — not just the source-faithful behavior.
Where the divergences come from (revised). An earlier version of
this doc attributed the source-vs-RAM-dump differences to
Apply64KPatchTable. That turned out to be wrong: Apply64KPatchTable
patches ~30 sites in the $6000..$AE00 range — instrument hooks,
ADF lookup, course plotter, magneto / engine model, day phase, ATIS
dispatch, the chunk4 scenery-loader thunks, etc. — but it does not
touch the scenery-opcode dispatch table, the polygon/clipper kernel,
the matrix at $78..$89, the camera ZP $66..$6B, the section-base
accumulator, EmitClippedLine, or any of the TransformVertex*
routines. Those addresses are unchanged between the source image and
the runtime image.
So why does the captured RAM look different at $4A..$52 vs $2A..$2F,
at $2C vs $29, at $35/$36 vs $38/$39?
The MAME RAM snapshot was taken mid-flight, after the scenery
dispatcher had already walked into $A800 and executed a sequence of
opcodes. Scenery bytecode itself can mutate chunk5's code area via
$1A SceneryOpWriteWord and $25 SceneryOpStoreImmWord. These two
ops accept any 16-bit destination, including absolute addresses inside
chunk5. The runtime image therefore differs from the source not because
of one-time patching at boot, but because of continuous scenery-driven
self-modification as the dispatcher walks sections.
This has practical consequences for the port:
- The port can faithfully implement the SOURCE semantics of every
opcode and still produce different rendering output from MAME if
the scenery files it loads contain
$1A/$25writes that target ZP slots used by the source code paths. The port may need to apply the same writes when it sees those opcodes, but only as a side effect — not as a baked-in alternate layout. - Reading the RAM snapshot as "the right" view is misleading. The
RAM is the state AFTER scenery has executed
Nopcodes. A clean source-faithful port that walks the same scenery files should converge on the same observable state. - The differences listed in older sections of this doc (
$4A..$52vs$2A..$2F, etc.) are real OBSERVATIONS of the RAM but unreliable as STATEMENTS about the engine. The engine reads from the source-defined locations. Wherever the runtime appears to write to a different location, that's a scenery$1A/$25artifact and needs to be traced through the scenery byte stream, not modeled as an alternate engine layout.
The older "source vs MAME-patched" divergence table below is preserved for historical reference and may still be useful when comparing the port's runtime against a captured RAM image. Read it as "these are the addresses where the RAM dump deviated from what a source-faithful run would produce," not as "the engine implements two layouts."
| Aspect | Source (chunk5.s) | RAM-snapshot observation |
|---|---|---|
| Section base accumulator | $4A..$52 (3 axes × 24-bit MID/HI/LO) |
$2A..$2F (3 axes × 16-bit LE) |
| L631D scale cache | $35/$36 |
$38/$39 |
| Polygon-mode flag (set by $2F, checked by $29/$41) | $2C |
$29 |
| Y-axis scratch (in $07/$24 setup) | $1B/$1C |
$1A/$1B |
| Z-axis scratch (in $07/$24 setup) | $1E/$1F |
$1C/$1D |
EmitClippedLine tail |
JMP SceneryInterpreterStep |
RTS (likely from scenery $1A overwriting the JMP opcode) |
| Op handler addresses | per source labels (e.g. $6AAD) |
offset (likely the result of multi-step scenery-driven mutation) |
| Chunk3 callbacks ($05 ADF, $0E 64K-call, $1D NAV, $1E COM, $03 rotated transform) | mostly no-ops on 48K | wired up to chunk3 routines via Apply64KPatchTable (this row IS a real Apply64KPatchTable patch) |
Where this document says "source" or "RAM-snapshot", that's what's meant. Code and addresses cited as just "chunk5.s:line" refer to the source disassembly.
Vertex emit / line draw family
$00 SceneryOpEmitV1XformAndPlot (xform-A V1 + single-pixel plot)
Length: 7 bytes ($00 X_lo X_hi Y_lo Y_hi Z_lo Z_hi)
Reads 6 stream bytes as a 16-bit X, Y, Z vertex. Transforms via
TransformVertex80C5 into V1 ($CB..$D0). If V1's outcode is 0 (= in
frustum), projects to screen and PLOTS A SINGLE PIXEL via
PlotColorPixel. Used for star fields, runway threshold dots, etc.
$01 SceneryOpEmitV1Xform80C5 (xform-A V1, no draw)
Length: 7 bytes ($01 X_lo X_hi Y_lo Y_hi Z_lo Z_hi)
Same as $00 but no pixel plot. Just transforms V1, classifies, and stores
shadow at $07..$10. Used to set the start endpoint for a subsequent
$02 line emit.
$02 SceneryOpEmitV2Xform80C5 (xform-A V2 + line v1->v2)
Length: 7 bytes ($02 X_lo X_hi Y_lo Y_hi Z_lo Z_hi)
Transforms V2 via TransformVertex80C5. If polygon-mode flag ($2C,
MAME-patched: $29) is non-zero, append V2 to PrimVerts polygon array.
Otherwise tail-jump to EmitClippedLine to draw a line from V1 to V2.
$40 SceneryOpEmitV1Xform7EBC (xform-B V1)
Length: 5 bytes ($40 X_lo X_hi Z_lo Z_hi)
Like $01 but uses TransformVertex7EBC (= xform-B). Stream encodes only
X and Z (16-bit each); Y is implicit from base accumulator at $2A..$2F
(MAME-patched) or $4A..$52 (source). Used for ground/runway polygons
where vertices share a base Y.
$41 SceneryOpEmitV2Xform7EBC (xform-B V2 + line v1->v2)
Length: 5 bytes ($41 X_lo X_hi Z_lo Z_hi)
Like $02 but xform-B. Same polygon-mode-vs-line-emit branch. After EmitClippedLine, V1 is replaced by V2 (= chained polyline).
$42 SceneryOpRefreshCachedXform7EBC (cache-update V2 with xform-B)
Length: 6 bytes ($42 vertex_idx X_lo X_hi Z_lo Z_hi)
Reads vertex_idx, points ($2D) at cached vertex slot ($0140 + idx*8).
Snapshots V2 ($D2..$DA) to $DB shadow, runs xform-B on stream bytes into
V2, writes transformed V2 back to the cache slot. Used to refresh dynamic
vertices (e.g. moving aircraft hash marks).
$31 SceneryOpRefreshCachedXform80C5 (cache-update with xform-A)
Length: 8 bytes ($31 vertex_idx X_lo X_hi Y_lo Y_hi Z_lo Z_hi)
Same as $42 but xform-A (full XYZ stream).
$32 SceneryOpVertexCachedV1 (load cached vertex into V1)
Length: 2 bytes ($32 vertex_idx)
If $2C set (= polygon mode), copies cached vertex via
EmitPrimaryVertex. Otherwise loads cached 8-byte record into V1
($CB..$D2) and shadow ($09..$10).
$33 SceneryOpVertexCachedV2 (load cached vertex into V2 + line)
Length: 2 bytes ($33 vertex_idx)
Like $32 for V2. If polygon mode off, tail-jumps to EmitClippedLine to draw v1->v2.
$35 SceneryOpVertexCachedDraw (load cached + plot pixel)
Length: 2 bytes ($35 vertex_idx)
Loads cached vertex into V1, then if in-frustum plots a single pixel. Same plot path as $00.
$2B SceneryOpEmitCurve (8-segment cubic curve via subdivision)
Length: 9 bytes ($2B v1_X_lo v1_X_hi v1_Z_lo v1_Z_hi v2_X_lo v2_X_hi v2_Z_lo v2_Z_hi)
Reads V1 stream bytes, transforms via xform-B. Backs cursor by 1 (so V2
reads from same offset advance). Reads V2 stream bytes, transforms.
Subdivides V2-V1 into 8 equal segments via repeated halving, emits each
segment as a clipped line. Used for cockpit gauge needle arcs and curved
runway markers. Self-modifies L6B4F to RTS during emission.
$06 SceneryOpDrawLine (raw screen-space line, no transform)
Length: 5 bytes ($06 X1 Y1 X2 Y2)
Copies 4 stream bytes directly into $E9..$EC and calls DrawColorLine.
No vertex transform. Used by 2D scenery (radar overlays, instrument panel
lines).
Polygon control
$29 SceneryOpCopyToD2 (polygon fill OR single line emit)
Length: 1 byte ($29)
If $2C (MAME: $29) flag is set: takes the Op29PolygonFillBranch
which decrements $B5 (vertex count), and if vertex count > 0 calls
PolygonScanFillSetup to scan-fill the accumulated PrimVerts polygon
via DrawColorSpan. After fill, clears the flag.
If flag is 0: takes the Op29LineEmitBranch — copies 9 bytes from
$07.. (= V1 shadow) into $D2.. (= V2 slot), advances cursor by 1,
then jumps to EmitClippedLine to draw a single line from V1 to V2.
$2F SceneryOpResetState (start polygon mode)
Length: 1 byte ($2F)
Sets $2C = $FF (= polygon mode ON, so subsequent $02/$41 ops
accumulate vertices instead of drawing lines). Clears $B5 (= polygon
vertex count) to 0. In MAME-patched binary the flag is at $29 instead
of $2C.
$1B SceneryOpModeWhite (force WHITE color, restore drawer)
Length: 1 byte ($1B)
Self-modifies the line/pixel kernel back to its standard plot opcodes
(undoes any night-mode BPL skips from $1C). Calls SetPixelDrawMode with
A=$03 (= HIRES_WHITE1).
$1C SceneryOpDayOnly (suppress draws at night)
Length: 1 byte ($1C)
If $083C bit 0 == 0 (night), patches DrawColorLine kernel with BPL
opcodes so subsequent line/pixel emits skip the paint write. Daytime is
a no-op.
$12 SceneryOpSetColor (set draw color from table index)
Length: 2 bytes ($12 code)
Reads next byte (0..15) as index into ToHiresColorTable, calls
SetPixelDrawMode with the resulting hires color. Mapping:
| code | hires | typical use |
|---|---|---|
| $00 | BLACK1 | sky/null |
| $01 | GREEN | ground (day) |
| $02 | VIOLET | water (day) |
| $03 | GREEN | |
| $04 | VIOLET | |
| $05 | BLACK1 | building shadow |
| $06 | VIOLET | runway edge |
| $07 | VIOLET | building |
| $08 | BLACK1 | |
| $09 | WHITE1 | aircraft wing/tail |
| $0A | BLACK1 | |
| $0B | GREEN | |
| $0C | VIOLET | |
| $0D | WHITE1 | |
| $0E | VIOLET | haze |
| $0F | WHITE1 | "city" (Hancock tower, runway centerline) |
Coordinate frame management
$07 SceneryOpEnterLocalFrame (enter sub-record frame, no stash)
Length: 14 bytes ($07 variant 6×16-bit-anchor)
Variant byte selects axis-permutation cascade
(0=FrameVariantRolShift, 2=FrameVariantSwap, 4=FrameVariantAsl4,
5+=FrameVariantPassthrough, others fall straight through to
FrameSetupEpilogue). The 6 anchor coords (= 3 axes × 16-bit) encode
the section's origin. ComputeSectionCameraDelta SBC chain computes
camera-vs-section delta into $66/$68/$6A. FrameSetupEpilogue calls
RecomputeSectionBase (= former L631D) to refresh the base accumulator.
$24 SceneryOpPushOriginWithStash (enter sub-record frame, with stash)
Length: 8 bytes ($24 variant 3×16-bit-anchor)
Like $07 but only 3 anchor coords (skips the lo-precision SBC for
$5A/$5E/$62). Pre-populates $18/$1B/$1E from aircraft full position
before L6BB0 cascade. Used inside polygon batches where the parent has
already set up the frame and only needs scaling.
Subroutines
$18 SceneryOpSubInvoke (recursive sub-record)
Length: 3 bytes ($18 offset_lo offset_hi)
Pushes current cursor + 3 (= return address) on stack as artificial RTS
target. Computes sub-stream cursor = current + signed offset. Sets
$8B/$8C to sub-stream. Calls JSR SceneryInterpreterStep. After return
(= when an op RTSes), restores cursor and JMPs to interpreter to continue
parent stream. Increments $08E1 (sub-depth counter).
$19 SceneryOpReturn (RTS — exit current dispatcher)
Length: 1 byte ($19)
RTS immediately. Returns to whoever called SceneryInterpreterStep,
either the main loop or $18 SubInvoke handler. The handler's first byte
IS $60 (RTS), nothing else.
$0B SceneryOpJumpRelative (relative jump within stream)
Length: 3 bytes ($0B offset_lo offset_hi)
Reads signed 16-bit offset, sets cursor = cursor + offset, continues interpreting from the new position.
$0E SceneryOpCall64K (chunk3 callback if 64K, recursion-pop if 48K)
Length: 0 bytes advance (the opcode itself is 1 byte in the stream but the handler does NOT advance the cursor past it).
If Has64K flag set, JMP to SceneryOp64KCallback (= chunk3 entry that
processes more chunk5-style records). On 48K: falls through into
SceneryOpReturn which is a bare RTS — and that RTS pops one level of
dispatcher recursion. If $0E is reached at the TOP level the RTS returns
to whoever called ProcessScenery (= the main loop). If reached from
inside a $18 SubInvoke recursion, the RTS returns to the parent
section's dispatcher.
$03 SceneryOpCall64K_2 (chunk3 SceneryRotatedTransform if 64K)
Length: 6 bytes on 48K (skip), variable on 64K
If 64K, JMP to SceneryRotatedTransform in chunk3 (= 3D rotated
transform helper). On 48K, advance 6 and continue.
Header / data load
$0D SceneryOpHeader (load section header + trigger demand-load)
Length: 6 bytes ($0D byte1 byte2 byte3 byte4 byte5)
Copies 5 inline payload bytes into $08E5..$08E9 (= section ID, sector
count, destination address, cache slot index). If high bit of byte5
(= $08E9) is clear, cumulatively advances the saved section-base
cursor at $08E7 by the current dispatcher cursor $8B. Masks
$08E9 to a cache slot index (0..3), then calls
SceneryHeaderLoadIfMiss via the SceneryHeaderLoadTrampoline at
L8776. On a cache miss, that routine invalidates newer slots and
DMA-fetches the section bytecode from disk via
SceneryHeaderRunSection (which sets L1E03 = $08E7 so the loaded
bytes land at the saved cursor's address). On a cache hit, returns
immediately. Total record = 6 bytes (1 opcode + 5 payload).
$11 SceneryOpSkip1 (skip 1 byte)
Length: 1 byte total (= just the opcode; no payload).
Advance cursor by 1.
$09 / $0A SceneryOpSkip3 (skip 3 bytes)
Length: 3 bytes each (= opcode + 2 unused payload bytes).
Advance cursor by 3.
Memory ops
$1A SceneryOpWriteWord (*dst = *src)
Length: 5 bytes ($1A dst_lo dst_hi src_lo src_hi)
Reads 2 bytes from *src (= memory at the src pointer) and writes them
to *dst. Used to copy state between scenery's 16-bit slots (e.g.
copying a saved color register).
$25 SceneryOpStoreImmWord (*dst = imm16)
Length: 5 bytes ($25 dst_lo dst_hi imm_lo imm_hi)
Writes the immediate 16-bit value into *dst.
Conditional jumps / culls
$13 SceneryOpJumpIfBeyondXY (cull XY box, jump alt path on fail)
Length: 9 bytes ($13 jumpOff_lo jumpOff_hi 2×(ptr_lo ptr_hi lo hi))
Reads jump target and 2 (pointer, low_bound, high_bound) triples — one
for each of two axes. Compares the value at each pointer against the
bounds. On failure (= camera outside box) jumps to the alternate target
via L6EE6. Otherwise advance 9 and continue.
$14 SceneryOpJumpIfBeyondXYZ (cull XYZ box, jump alt on fail)
Length: 14 bytes ($14 jumpOff_lo jumpOff_hi 2×(triple) + Z_lo Z_hi)
Like $13 but adds explicit Z comparison against $60/$61 (or
ZoomLevel+1/+2 in radar view) using the supplied Z bound at $98/$99.
$20 SceneryOpCullIfOutside1 (1-axis bounding cull)
Length: 9 bytes
Calls TestSceneryRange once. On failure redirects via L6EE6.
$21 SceneryOpCullIfOutside2 (2-axis bounding cull)
Length: 15 bytes
Calls TestSceneryRange twice.
$22 SceneryOpCullIfOutside3 (3-axis bounding cull)
Length: 21 bytes
Calls TestSceneryRange three times.
$04 SceneryOpCullByOutcodeList (cull when all listed vertices share outcode bit)
Length: variable ($04 jumpOff_lo jumpOff_hi vertex_idx... <terminator>)
Initializes $DC = $F8. For each vertex_idx in the stream (until a byte
with bit 7 set), points ($2D) at cached vertex, ANDs the vertex's
outcode high byte into $DC. If after all vertices $DC != 0 (= all
share at least one off-screen half-plane), jumps to the alternate target.
$23 SceneryOpJumpIfBitsClear (mask AND test, jump on no-bits-set)
Length: 7 bytes ($23 jumpOff_lo jumpOff_hi ptr_lo ptr_hi mask1 mask2)
If (mask1 AND *(ptr+0)) == 0 AND (mask2 AND *(ptr+1)) == 0, jump.
Used to gate scenery on aircraft state flags (landing-gear, lights, etc.).
$28 SceneryOpJumpIfWordCompare (compare two pointers, jump on match)
Length: 8 bytes ($28 mode jumpOff_lo jumpOff_hi ptr1_lo ptr1_hi ptr2_lo ptr2_hi)
Loads mode (0=eq, 1=signed-lt, 2=signed-lt-alt). Compares 16-bit
*ptr1 vs *ptr2. On match jumps via L6EE6.
Station records (NAV/COM/ADF radio)
$05 SceneryOpADFRecord (ADF station)
Length: 9 bytes on 48K (skip), variable on 64K
48K: just advance 9. 64K: patched to JMP LookupADFStation in chunk3
which matches against the user's tuned ADF frequency and copies the
station descriptor on hit.
$1D SceneryOpNAVRecord (NAV1/NAV2 station)
Length: 11 bytes ($1D freq_lo freq_hi 8×descriptor)
Tries the record's 2-byte frequency against $08F7 (NAV1 tune) and
$08F5 (NAV2 tune) via MatchNAVFreq. On a hit, copies the 8-byte
descriptor (X, Y, Z, ranges...) into $08F9 (NAV1 slot) or $0901
(NAV2 slot), and ORs the active bit into $08F4.
$1E SceneryOpCOMRecord (COM/airport)
Length: variable (= byte 1 holds record length)
Reads record length from byte 1. If record's COM frequency matches the
user's tuned COM ($089F/$08A0), copies the airport position into
$0905+, latches a pointer to the inline airport name string at $092A,
and triggers the ATIS message renderer. Otherwise skips by the length.
Invalid / terminator opcodes
$08, $0C, $0F, $10, $15, $16, $17, $1F, $26, $27, $2A, $2C, $2D, $2E, $30, $34, $36-$3F, $43, $44, $45 SceneryOpInvalid
When dispatched, falls through to SceneryStreamEnd: clears scenery
in-progress flags ($0916, $08FF, $0E3D) and RTSes. Effectively
terminates the current stream.
Any opcode with bit 7 set ($80-$FF) OR opcode >= $46 also triggers
SceneryStreamEnd.
Quick reference — record lengths
| op | name | bytes | side effects |
|---|---|---|---|
| $00 | EmitV1+Plot | 7 | xform-A V1, plot pixel |
| $01 | EmitV1 xform-A | 7 | xform-A V1, no draw |
| $02 | EmitV2 xform-A | 7 | xform-A V2, draw line v1->v2 |
| $03 | Call64K_2 | 6 | chunk3 callback (64K only) |
| $04 | CullByOutcodeList | var | jump if shared off-screen half |
| $05 | ADFRecord | 9 | match tuned ADF freq |
| $06 | DrawLine | 5 | raw 2D line at screen coords |
| $07 | EnterLocalFrame | 14 | section frame setup, no stash |
| $0B | JumpRelative | 3 | cursor += signed16 |
| $0D | Header | 6 | section header + demand-load |
| $0E | Call64K | 0 | chunk3 callback (64K); 48K = RTS pops recursion |
| $11 | Skip1 | 1 | bare opcode, no payload |
| $12 | SetColor | 2 | set draw color from table |
| $13 | JumpIfBeyondXY | 9 | XY cull, jump alt |
| $14 | JumpIfBeyondXYZ | 14 | XYZ cull, jump alt |
| $18 | SubInvoke | 3 | recursive sub-stream |
| $19 | Return | 0 | RTS — pops dispatcher recursion (no cursor advance) |
| $1A | WriteWord | 5 | *dst = *src (word) |
| $1B | ModeWhite | 1 | set color WHITE, restore drawer |
| $1C | DayOnly | 1 | night → patch drawer to skip |
| $1D | NAVRecord | 11 | match tuned NAV1/NAV2 freq |
| $1E | COMRecord | var | match tuned COM, ATIS draw |
| $20 | CullIfOutside1 | 9 | 1-axis bounding cull |
| $21 | CullIfOutside2 | 15 | 2-axis bounding cull |
| $22 | CullIfOutside3 | 21 | 3-axis bounding cull |
| $23 | JumpIfBitsClear | 7 | mask AND test, jump if zero |
| $24 | PushOriginWithStash | 8 | section frame setup, stashed |
| $25 | StoreImmWord | 5 | *dst = imm16 |
| $28 | JumpIfWordCompare | 8 | cmp *p1 *p2, jump on match |
| $29 | CopyToD2 | 1 | polygon fill OR line emit |
| $2B | EmitCurve | 9 | 8-segment subdivided curve |
| $2F | ResetState | 1 | polygon mode ON, vertex count=0 |
| $31 | RefreshCachedXform80C5 | 8 | reload cached vertex xform-A |
| $32 | VertexCachedV1 | 2 | load cached V1 |
| $33 | VertexCachedV2 | 2 | load cached V2 + line |
| $35 | VertexCachedDraw | 2 | load cached + plot |
| $40 | EmitV1 xform-B | 5 | xform-B V1, no draw |
| $41 | EmitV2 xform-B | 5 | xform-B V2, draw line v1->v2 |
| $42 | RefreshCachedXform7EBC | 6 | reload cached vertex xform-B |
Coordinate frames
xform-A (TransformVertex80C5 at chunk5.s:5776) uses a full 6-byte (X,
Y, Z) stream and the 16-bit ZPScale multiplier for all 9 matrix
entries. The routine itself advances the dispatcher cursor $8B by 7
(opcode + 6-byte payload), so the surrounding opcodes ($00/$01/$02)
declare their record length as 7 bytes.
xform-B (TransformVertex7EBC at chunk5.s:5425) uses a 4-byte (X, Z)
stream — Y is implicit from the section base accumulator at $4A..$52
(source) or $2A..$2F as observed in the RAM snapshot. Uses the 8-bit
op_l1818 multiplier. The routine advances the cursor by 5 (opcode +
4-byte payload), giving $40/$41 a 5-byte record length.
The 3x3 rotation matrix lives at $78..$89:
$78/$79= X-out-from-X-delta$7A/$7B= Y-out-from-X-delta$7C/$7D= Z-out-from-X-delta$7E/$7F= X-out-from-Y-delta (= matrix row 2 used by L631D)$80/$81= Y-out-from-Y-delta$82/$83= Z-out-from-Y-delta$84/$85= X-out-from-Z-delta$86/$87= Y-out-from-Z-delta$88/$89= Z-out-from-Z-delta
Camera ZP at $66..$6B:
$66/$67= camera X delta (post-section-anchor subtract)$68/$69= camera Y delta$6A/$6B= camera Z delta
Section base accumulator (= L631D output):
- Source layout:
$4A..$52as 24-bit MID/HI/LO per axis - MAME-patched layout:
$2A..$2Fas 16-bit LE per axis
RecomputeSectionBase (= former L631D) fires from FrameSetupEpilogue
(= former L6D28) when $68/$69 differs from the cache at $35/$36
(source) or $38/$39 (MAME-patched).
Renamed labels (= what we changed in chunk5.s)
The disassembly originally used L<addr> placeholder labels for branch
targets. We've added semantic names for the scenery-interpreter family
(L kept as .refto aliases so existing references still resolve).
Frame setup ($07/$24 family)
| Old | New | What it is |
|---|---|---|
| L631D | RecomputeSectionBase |
matrix*scale → base accumulator |
| L6363 | ScaleSignedC2 |
signed multiply for one base-axis |
| L6379 | ScaleSignedC2_DoMultiply |
multiply step inside ScaleSignedC2 |
| L639C | ScaleSignedC2_Exit |
RTS |
| L6BB0 | ComputeSectionCameraDelta |
$07/$24 SBC chain |
| L6BC3 | ComputeSectionCameraDelta_X |
X-axis SBC |
| L6BEB | ComputeSectionCameraDelta_Y_Radar |
Y-axis radar branch |
| L6BFE | ComputeSectionCameraDelta_Y_NonRadar |
Y-axis non-radar branch |
| L6C10 | ComputeSectionCameraDelta_Y_NonRadarStash |
Y-axis stash variant |
| L6C1E | ComputeSectionCameraDelta_Z |
Z-axis SBC |
| L6C31 | ComputeSectionCameraDelta_Z_Upper |
Z-axis upper-byte SBC |
| L6C48 | FrameVariantDispatch |
variant lookup + JMP |
| L6C53 | FrameVariantPassthrough |
variant 5+ (= identity) |
| L6C6E | FrameVariantSwap |
variant 2 (= lo/hi swap) |
| L6C89 | FrameVariantAsl4 |
variant 4 (= asl×4) |
| L6CCE | FrameVariantRolShift |
variant 0 (= rol-shift) |
| L6D28 | FrameSetupEpilogue |
$07/$24 cache-check + L631D fire |
| L6D32 | FrameSetupEpilogue_CacheCheck |
scale cache check |
| L6D40 | FrameSetupEpilogue_FireRecompute |
call to L631D |
| L6D43 | FrameSetupEpilogue_UpdateCache |
$36 := $69 |
| L6B8A | PushOriginWithStash_NonRadar |
$24 non-radar Y scratch |
| L6B92 | PushOriginWithStash_RunChain |
$24 stashed-mode chain entry |
Polygon fill kernel ($29 + L6F98)
| Old | New | What it is |
|---|---|---|
| L67CE | Op29LineEmitBranch_CopyLoop |
shadow→V2 copy loop |
| L67DD | Op29PolygonFillBranch |
$29 with $2C set |
| L67E8 | Op29PolygonFillBranch_ClearAndAdvance |
clear $2C tail |
| L6F98 | PolygonScanFillSetup |
polygon fill kernel entry |
| L7095 | PolygonClipTopPass |
clip pass 2 (top edge) |
| L7190 | PolygonClipRightPass |
clip pass 3 (right edge) |
| L728B | PolygonClipBottomPass |
clip pass 4 (bottom edge) |
| L7826 | PolygonScanFillRow |
per-row scan-line emitter |
EmitClippedLine + curve
| Old | New | What it is |
|---|---|---|
| L6AF7 | EmitClippedLine_ClipV2 |
snapshot+clip V2 phase |
| L6AFD | EmitClippedLine_SnapshotV2Loop |
V2→shadow copy loop |
| L6B0D | EmitClippedLine_ProjectV1IfNeeded |
conditional V1 project |
| L6B15 | EmitClippedLine_ProjectV2IfNeeded |
conditional V2 project |
| L6B1C | EmitClippedLine_PrepareEndpoints |
load $E9..$EC |
| L6B2C | EmitClippedLine_DoDraw |
jsr DrawColorLine |
| L6B2F | EmitClippedLineCleanup |
V1 restore tail |
| L6B35 | EmitClippedLineCleanup_FromShadow |
restore V1 from $DB |
| L6B3F | EmitClippedLineCleanup_FromV2 |
restore V1 from $D2 (= unmodified V2) |
| L6B41 | EmitClippedLineCleanup_FromV2Loop |
copy loop |
| L6B48 | EmitClippedLineCleanup_ClearFlags |
$8A = $08C4 = 0 |
| L6B4F | EmitClippedLineTail |
self-modified JMP/RTS |
| L6A03 | EmitCurve_ReadV2 |
$2B V2 read |
| L6A0C | EmitCurve_AlignExponents |
scale-align V1 vs V2 |
| L6A20 | EmitCurve_HalveV2 |
V2 halve branch |
| L6A2A | EmitCurve_BuildStep |
(V2-V1)/16 step build |
| L6A69 | EmitCurve_SegmentLoop |
per-segment emit loop |
Vertex emit / cache ($00/$01/$02/$32/$33/$35/$40/$41/$42)
| Old | New | What it is |
|---|---|---|
| L6839 | ProcessVertex2_LineMode |
$2C clear → classify+project V2 |
| L6843 | ProcessVertex2_Exit |
shared RTS |
| L6874 | ProcessVertex1_LineMode |
$2C clear → classify+project V1 |
| L687E | SnapshotVertex1ToShadow |
save V1 to $07.. |
| L6880 | SnapshotVertex1ToShadow_Loop |
copy loop |
| L6893 | EmitV1Common |
$01/$40 setup (xform-A vs B select) |
| L689C | ResumeAfterVertexEmit |
dispatcher resume after silent emit |
| L68AA | EmitV2Common |
$02/$41 setup |
| L68C7 | VertexCachedV1_LineMode |
$32 line-mode load |
| L68CF | VertexCachedV1_CopyLoop |
cache → V1 copy |
| L68E4 | VertexCachedV1_Advance |
RTS to dispatcher |
| L68FB | VertexCachedV2_LineMode |
$33 line-mode load |
| L6901 | VertexCachedV2_CopyLoop |
cache → V2 copy |
| L6911 | VertexCachedV2_Emit |
tail-jump to EmitClippedLine |
| L6952 | RefreshCachedXform_Common |
$31/$42 shared body |
| L695D | RefreshCachedXform_SnapshotV2 |
save V2 |
| L6971 | RefreshCachedXform_WriteCache |
write transformed V2 to cache |
| L6973 | RefreshCachedXform_WriteLoop |
copy loop |
| L697D | RefreshCachedXform_RestoreV2 |
restore V2 from snapshot |
| L698A | SetVertexPointerFromIdx |
cached-vertex slot lookup (idx in A) |
| L69B5 | VertexCachedDraw_LoadLoop |
$35 cache → V1 copy |
| L69D5 | EmitV1AndPlotTail |
$00/$35 frustum-test tail |
| L69DC | EmitV1AndPlotTail_DoPlot |
actually plot the pixel |
| L69E3 | EmitV1AndPlotTail_Resume |
resume dispatcher |
xform-A (TransformVertex80C5) internals
| Old | New | What it is |
|---|---|---|
| L80D2 | XformA_SBCChain |
stream→delta SBC chain |
| L810E | XformA_SBCChain_StoreZHi |
store Z high byte |
| L8110 | XformA_AutoScaleLoop |
auto-scale shift loop |
| L81D9 | XformA_OverflowRecoverX |
X overflow path |
| L820F | XformA_OverflowRecoverY |
Y overflow path |
| L821E | XformA_OverflowRecoverZ |
Z overflow path |
xform-B (TransformVertex7EBC) internals
| Old | New | What it is |
|---|---|---|
| L7EAD | XformBOverflowRecoverXZ |
X+Z overflow recovery |
| L7F1A | XformBAutoScaleLoop |
left-shift loop |
| L7F5C | XformBOverflowRecoverZ |
Z-only overflow recovery |
| L7F64 | XformBOverflowRecoverX |
X-only overflow recovery |
| L7F7F | XformBHalveBaseAccumulators |
post-overflow halve epilogue |
| L7F96 | XformBMatrixMultiply |
matrix-multiply tail entry |
| L8091 | XformBStoreOutput |
vertex result writeback |
| L80B0 | XformBHalveAccumulators |
post-multiply overflow halver |
Conditional jumps / culls
| Old | New | What it is |
|---|---|---|
| L6DB9 | CullByOutcodeList_ResetAccum |
reset $DC AND mask |
| L6DBB | CullByOutcodeList_NextVertex |
next-vertex top |
| L6DCF | CullByOutcodeList_AndOutcode |
AND outcode bits into mask |
| L6DD9 | CullByOutcodeList_End |
end-of-list test + branch |
| L6DF0 | CullSucceedAndContinue |
shared continue |
| L6E07 | JumpIfBeyondXYZ_Z_NonRadar |
non-radar Z bounds test |
| L6E0D | JumpIfBeyondXYZ_Z_TestHi |
high-byte Z test |
| L6E14 | JumpIfBeyondXYZ_Continue |
inside-bounds path |
| L6EBF | TestSceneryRange_TestLow |
low-bound test |
| L6ECE | TestSceneryRange_LowOverflow |
low overflow gate |
| L6ED0 | TestSceneryRange_TestHigh |
high-bound test |
| L6EE1 | TestSceneryRange_HighOverflow |
high overflow gate |
| L6EE3 | TestSceneryRange_Pass |
inside-range RTS |
| L6EE4 | TestSceneryRange_StripAndJump |
strip JSR + take alt jump |
| L6EE6 | JumpToFetchedTarget |
conditional-jump take-branch path |
| L6F16 | JumpIfBitsClear_NoJump |
$23 no-jump path |
| L6F55 | JumpIfWordCompare_Mode1Overflow |
$28 mode 1 V-flag gate |
| L6F57 | JumpIfWordCompare_Mode1Jump |
$28 mode 1 take-jump |
| L6F5A | JumpIfWordCompare_Mode2 |
$28 mode 2 entry |
| L6F6C | JumpIfWordCompare_Mode2Overflow |
$28 mode 2 V-flag gate |
| L6F6E | JumpIfWordCompare_Mode2Jump |
$28 mode 2 take-jump |
| L6F71 | JumpIfWordCompare_Mode0 |
$28 mode 0 (= equality) |
| L6F84 | Op28NoJump |
$28 word-compare no-match path |
Misc op handlers
| Old | New | What it is |
|---|---|---|
| L6063 | MatchNAVFreq_CopyLoop |
NAV descriptor copy |
| L6076 | MatchNAVFreq_SetActiveBit |
OR active flag into $08F4 |
| L607C | MatchNAVFreq_Exit |
RTS |
| L609F | SceneryOpCOMRecord_CopyDescriptorLoop |
COM descriptor copy |
| L60BD | SceneryOpCOMRecord_AdvanceAndContinue |
skip past record |
| L60FD | SceneryOpHeader_RunSetup |
mask + jsr L8776 |
| L7AF2 | SceneryOpDayOnly_Exit |
shared advance-and-continue |
Setup / view projection
| Old | New | What it is |
|---|---|---|
| L612D | SetupViewProjection_NotRadar |
non-radar entry |
| L6155 | SetupViewProjection_SideOrForwardView |
ViewDirection 0..n |
| L617C | SetupViewProjection_NegateAngle |
angle negation branch |
| L6199 | SetupViewProjection_BuildXZBasis |
sin/cos build |
| L61F0 | SetupViewProjection_BuildMatrix |
converged matrix builder |
| L6210 | SetupViewProjection_NormaliseAngle |
angle normalisation |
Polygon clipping passes (new this round)
| Old | New | What it is |
|---|---|---|
| L741C | SwapVertices_Loop |
V1↔V2 byte swap loop |
| L742C | ClipVertex2ToFrustum_Exit |
empty-outcode RTS |
| L742D | ClipVertex2ToFrustum_TestRight |
bit 6 → ClipRight |
| L743D | ClipVertex2ToFrustum_TestLeft |
bit 5 → ClipLeft |
| L744F | ClipVertex2ToFrustum_TestBottom |
bit 4 → ClipBottom |
| L7461 | ClipVertex2ToFrustum_TestTop |
bit 3 → ClipTop |
| L746D | ClipVertex2ToTop_RetryAfterHalve |
overflow-recovery entry |
| L74EB | ClipVertex2ToBottom_RetryAfterHalve |
"" |
| L7573 | ClipVertex2ToLeft_RetryAfterHalve |
"" |
| L75F1 | ClipVertex2ToRight_RetryAfterHalve |
"" |
Outcode classifiers (new this round)
| Old | New | What it is |
|---|---|---|
| L738A | ClassifyVertex1_TestRight |
X+Z half-plane |
| L7399 | ClassifyVertex1_TestLeft |
Z-X half-plane |
| L73A8 | ClassifyVertex1_TestBottom |
Y+Z half-plane |
| L73B7 | ClassifyVertex1_TestTop |
Z-Y half-plane |
| L73C8 | ClassifyVertex1_StoreAndExit |
RTS path |
| L73D3 | ClassifyVertex2_TestRight |
"" for V2 |
| L73E2 | ClassifyVertex2_TestLeft |
"" |
| L73F1 | ClassifyVertex2_TestBottom |
"" |
| L7400 | ClassifyVertex2_TestTop |
"" |
| L7411 | ClassifyVertex2_StoreAndExit |
"" |
Outcode bits (= shared by both classifiers):
- bit 7: behind viewer (Z < 0)
- bit 6: right of right frustum plane (X+Z < 0)
- bit 5: left of left frustum plane (Z-X < 0)
- bit 4: below bottom frustum plane (Y+Z < 0)
- bit 3: above top frustum plane (Z-Y < 0)
FlipPagesFillViewport + sky/ground fill
| Old | New | What it is |
|---|---|---|
| L63A2 | FlipPagesFillViewport_RotateRowTable |
rotate HiresTableHi for page swap |
| L63BB | FlipPagesFillViewport_FlipToHires |
STA HISCR |
| L63BE | FlipPagesFillViewport_RunHorizonPoly |
$0AB9 horizon-line entry |
| L644A | FlipPagesFillViewport_StoreGroundColor |
save ground tint |
| L6457 | FlipPagesFillViewport_StoreSkyColor |
save sky tint |
| L6467 | FlipPagesFillViewport_PrepRow |
call SetEvenAndOddColors |
| L646D | FlipPagesFillViewport_DetermineExtents |
min/max screen-Y for horizon |
| L6482 | FlipPagesFillViewport_FillFullViewport |
no-horizon-line path |
| L6485 | TrackHorizonExtent |
min/max helper |
| L648B | TrackHorizonExtent_TestMax |
max-side test |
| L6491 | TrackHorizonExtent_Exit |
RTS |
| L6492 | FlipPagesFillViewport_HorizonEmitted |
horizon-line setup |
| L64B9 | FlipPagesFillViewport_AboveDiag_TestSwap |
sky/ground swap test |
| L64BD | FlipPagesFillViewport_AboveDiag_DoSwap |
"" do |
| L64C0 | FlipPagesFillViewport_AboveDiag_Fill |
fill upper rows |
| L64CC | FlipPagesFillViewport_DiagFill_TestSwap |
"" for diagonal rows |
| L64D9 | FlipPagesFillViewport_DiagFill_TestSwap2 |
"" alt path |
| L64E3 | FlipPagesFillViewport_DiagFill_DoSwap |
swap |
| L64E6 | FlipPagesFillViewport_DiagFill_Fill |
call FillMixedViewportRows |
| L64FC | FlipPagesFillViewport_BelowDiag_TestSwap |
below-diag swap test |
| L6506 | FlipPagesFillViewport_BelowDiag_DoSwap |
"" do |
| L6509 | FlipPagesFillViewport_BelowDiag_Fill |
fill lower rows |
| L6515 | FlipPagesFillViewport_FullSky |
end + UpdateArtificialHorizon |
| L6555 | FillViewportRows_SolidStart |
optimized solid-color start |
| L6557 | FillViewportRows_SolidLoop |
unrolled fill loop |
| L6567 | FillViewportRows_ColorFill |
call DrawSkyGroundRowUnrolled |
| L656A | FillViewportRows_Tail |
row decrement + loop |
| L6637 | DrawSkyGroundRow_NoSplit |
edge case at column $27 |
| L668B | DrawSkyGroundRow_FillRightSide |
DrawColorSpan right-of-transition |
| L6695 | DrawSkyGroundRow_TestFillColor |
test before left-side fill |
| L669D | DrawSkyGroundRow_FillLeftSide |
DrawColorSpan left-of-transition |
| L66A4 | DrawSkyGroundRow_Exit |
RTS |
DrawColorLine + PlotColorPixel + DrawColorSpan paint sites
| Old | New | What it is |
|---|---|---|
| L7921 | DCSPixelOraOp |
DrawColorSpan ORA self-mod |
| L7922 | DCSPixelOraOperand |
"" operand |
| L792D | DCSPixelAndOp |
DrawColorSpan AND self-mod |
| L792E | DCSPixelAndOperand |
"" operand |
| L7976 | PlotColorPixel_OraOp |
PlotColorPixel ORA self-mod |
| L7977 | PlotColorPixel_OraOperand |
"" operand |
| L7982 | PlotColorPixel_AndOp |
PlotColorPixel AND self-mod |
| L7983 | PlotColorPixel_AndOperand |
"" operand |
| L7A1A | DCLYMajor_OraMaskOp |
DCL Y-major ORA |
| L7A1B | DCLYMajor_OraMaskOperand |
|
| L7A26 | DCLYMajor_AndMaskHiOp |
split-byte hi half AND |
| L7A27 | DCLYMajor_AndMaskHiOperand |
|
| L7A2E | DCLYMajor_AndMaskLoOp |
non-split AND |
| L7A2F | DCLYMajor_AndMaskLoOperand |
|
| L7A8E | DCLXMajor_OraMaskOp |
DCL X-major ORA |
| L7A8F | DCLXMajor_OraMaskOperand |
|
| L7A9A | DCLXMajor_AndMaskOp |
DCL X-major AND |
| L7A9B | DCLXMajor_AndMaskOperand |
DrawColorLine internals (new this round)
| Old | New | What it is |
|---|---|---|
| L79A3 | DrawColorLine_PostSwap |
after X1≤X2 swap |
| L79BF | DrawColorLine_TestZero |
single-point shortcut |
| L79E4 | DCLPrepYMajor |
Y-major axis setup |
| L79FE | DCLYMajor_DecPixelInByte |
byte-bit transition |
| L7A4D | DCLPrepXMajor_AddCycles |
X-major cycle adjust |
| L7A55 | DCLPrepXMajor_AddOverhead |
"" overhead |
| L7A60 | DCLPrepXMajor_InitError |
"" init error |
| L7AF2 | SceneryOpDayOnly_Exit |
shared advance-and-continue |
Polygon scan-fill row body (new this round)
| Old | New | What it is |
|---|---|---|
| L7684 | PolygonScanFill_ProjectVertices |
per-vertex 3D→2D project |
| L7699 | PolygonScanFill_ProjectVertexLoop |
"" loop top |
| L76C3 | PolygonScanFill_TestMaxX |
screen-X max accumulate |
| L76C9 | PolygonScanFill_StoreYHi |
screen-Y store |
| L76D3 | PolygonScanFill_TestMaxY |
screen-Y max accumulate |
| L76D9 | PolygonScanFill_NextVertex |
loop step |
| L7724 | PolygonScanFill_BuildEdgeList |
edge-list builder loop |
| L77EA | PolygonScanFill_HorizEdge |
special-case horizontal edge |
| L783A | PolygonScanFill_SetupRowEmit |
setup per-row scan |
| L784A | PolygonScanFill_RowTop |
per-row scan top |
| L785D | PolygonScanFill_EdgeLoop |
edge walk for current row |
| L787B | PolygonScanFill_EdgeIntercept |
interpolate X intercept |
| L789B | PolygonScanFill_NextEdge |
loop step |
| L78B2 | PolygonScanFill_SortIntercepts |
bubble-sort the X buffer |
| L78B5 | PolygonScanFill_SortInner |
"" inner loop |
| L78C6 | PolygonScanFill_SortInnerStep |
"" step |
| L78CE | PolygonScanFill_EmitSpansLoop |
DrawColorSpan-per-pair loop |
| L78E0 | PolygonScanFill_NextRow |
row advance |
| L78EB | PolygonScanFill_FinalCost |
work cost accumulate |
Section loader (new this round)
| Old | New | What it is |
|---|---|---|
| L8776 | SceneryHeaderLoadTrampoline |
$0D Header → demand-load |
| LA64C | SceneryHeaderLoadIfMiss_InvalidateNewerSlots |
cache invalidation |
| LA656 | SceneryHeaderLoadIfMiss_RunLoad |
actually issue load |
| LA66A | SceneryHeaderLoadIfMiss_Hit |
cache hit RTS |
| LA6F9 | SceneryCopyLoadedSection |
$4000-$5FFF → $2000-$3FFF |
| LA712 | SceneryCopyLoadedSection_PageLoop |
"" 256-byte loop |
What's left
Remaining L<addr> clusters across the chunks are aircraft physics integration, instrument panel update routines (DrawHeading, DrawDME, UpdateAltimeterPose, UpdateMagneticHeading, etc.), demo mode logic, crash detection / handling, joystick / keyboard input handling, magnetic compass / VOR / DME / ATIS internals, AltDrawSkyGroundRow detailed body, ApplyArtificialHorizonMask, and the ZPScale 16-bit multiplier family in chunk4. PerspectiveDivide is named at the entry but its 7-iteration unrolled shift-subtract body has internal labels (L7C3D, L7C9E, etc.) that are best understood as "iteration N of the divide" rather than getting individual semantic names.
Each rename requires understanding the surrounding code — speculative renames would mislead future readers. Add new entries here as more routines get reverse-engineered.
Algorithm understanding (= what reverse-engineering revealed)
DrawColorLine ($795A) is bit-level Bresenham
Apple II hires has 7 color pixels per byte. The high bit (bit 7) selects palette (= violet/green or blue/orange depending on parity); bits 0..6 hold 7 color pixels. DrawColorLine paints ONE color pixel = ONE bit per loop iteration via self-modified ORA/AND mask opcodes (= installed by SetPixelDrawMode).
Two inner loops:
- Y-major (
DCLYMajorTopat L79F0): for vertical-ish lines. Y steps every iteration; X (column + bit-in-byte) steps when the Bresenham error overflows. - X-major (
DCLXMajorTopat L7A67): for horizontal-ish lines. X steps every iteration; Y conditionally.
The "split-byte boundary" handling at bit-3 (DCLYMajor_DecPixelInByte
at L79FE) is because 7 pixels per byte means transitions at bits 0
and 6 wrap to the previous/next byte cleanly — but bit 3 needs special
care for the high-bit palette plane.
For our port: hiresDrawLine should produce the same bit pattern as
DrawColorLine, OR we accept that pixel-pair-level rasterization will
look slightly different (= no NTSC color fringing).
PolygonScanFill ($6F98) uses 3D clipping, not 2D scanline
PolygonScanFillSetup receives the polygon as 3D-XYZ vertices (= the
post-xform output stored in PrimVerts). It runs four 3D clipping
passes against the viewing frustum half-planes (Left/Top/Right/Bottom),
each pass alternating PrimVerts <-> SecVerts. Each clipping pass
INTRODUCES NEW VERTICES at the frustum-edge intersections.
After clipping, PolygonScanFill_ProjectVertices (L7684) projects
every clipped vertex (including the new intersections) to screen
coords. Then PolygonScanFill_BuildEdgeList (L7724) builds the
per-edge data (top row, row count, top column, column step per row)
and PolygonScanFillRow (L7826) iterates rows and emits scan-line
spans via DrawColorSpan.
This is why our port's polygon fill produces the wrong shape:
Our port's rendererFillPolygon takes pre-projected screen coords from
the vertex emit ops and rasterizes via standard 2D scanline edge
intersection. It skips the 3D clipping entirely. When a polygon's
original 3D vertices all map to a small screen Y range (= e.g. all near
row 55-56 because they're all at similar Z), our fill produces just
1-2 scan rows.
But chunk5's 3D clipping introduces frustum-edge intersection vertices that, when projected, expand the polygon's screen-Y range. A polygon whose 3D vertices all sit at large Z but extend in Y can produce intersection vertices at the near-Z plane (= mapping to row 99 after projection). The resulting clipped polygon spans rows 56..99 — the WEDGE shape that's the runway centerline at boot Meigs.
To fix: port PolygonScanFillSetup's 4 clipping passes faithfully.
The infrastructure is in place (= polyXs/polyYs accumulator) but
should be replaced with 3D-XYZ vertex storage and the clipping should
run BEFORE projection. Then the existing scanline rasterizer would
see the correct vertex set.
RecomputeSectionBase ($631D) computes 24-bit base from matrix*scale
Source layout: $4A..$52 as 3 axes × 24-bit MID/HI/LO. MAME-patched
moves this to $2A..$2F as 3 axes × 16-bit LE.
For each axis: base[i] = -((matrix_row_2[i] * scale) >> 15) where
scale is $68/$69 (= camera-vs-section Y delta). The base is then
read by xform-B as the Y-axis-implicit base for vertices that don't
have explicit Y in stream.
This was the source of our long-running base-divergence bug: source chunk5.s describes the $4A..$52 layout, but MAME's running binary uses $2A..$2F because the patcher rewrites the relevant LDA/STA addresses.
xform-B chained accumulator
TransformVertex7EBC reads BOTH the persistent base ($2A..$2F) AND the
running auto-scale exponent ($2F at $35/$36). After the matrix
multiply (= XformBMatrixMultiply at L7F96), output is stored to the
caller's destSlot ($CB for V1, $D4 for V2) AND the accumulator slots
$18..$1D persist for the next transform.
The auto-scale shift loop (XformBAutoScaleLoop at L7F1A) keeps all 5 high bytes ($9F = X delta hi, $A3 = Z delta hi, $19/$1C/$1F = base high bytes for X/Y/Z) within the [$40..$BF] band. Each shift up by 1 increments the exponent at $2F. After the multiply, post-overflow recovery (= L7F7F XformBHalveBaseAccumulators) shifts the accumulator back down to maintain scale alignment.