; ============================================================================
; $0800-$6EFF - main game program (tracks 22-27, sectors 0-17 each)
; ============================================================================
; Entry point $0800 (JMP $0A6C). Contains the loader API, start-up sequence, raster interrupt, input
; handling, the battlefield and console screens, the unit simulation and the command interpreter.

        .setcpu "6502"
        .include "c64.inc"
        .include "zeropage.inc"

; ---- references to code/data outside this file ----
D_0104                   := $0104
bamDiskIdChar0           := $02A2
bamDiskIdChar1           := $02A3
bamDiskSerial            := $02AB
basicVectorHijack        := $0300
checksumXorConstant      := $0400
mapNameSeedTable         := $0401
play3Col28               := $0410
play3Col44               := $0420
play3Row10               := $0430
play3Row12               := $0432
play4Grp12               := $0464
play4Row12               := $04C8
play4Row13               := $04C9
play4Row20               := $04D0
play4Row21               := $04D1
play4Row35               := $04DF
play4Row43               := $04E7
chatMessageLine          := $0548
D_0549                   := $0549
switchToRequestedScreen  := $06BA
runComcenStunnedBlackout := $0709
D_077E                   := $077E
unitGlyphBaseTable       := $0799
D_07BE                   := $07BE
terrainColourTable       := $07EA
generateBattlefieldMap   := $6F04
applyDroneBlast          := $72A1
steerDrone               := $731E
drawRadarGlyph           := $7396
getRadarCellGlyph        := $74C3
radarSideBranchOpcode    := $750A
missileLineFromComcen    := $7717
clearMapNameField        := $7836
startGameSession         := $78D1
scrollRadarOnComcenMove  := $7959
mapCellToRadarGrid       := $7A13
isRadarGridCellInRange   := $7A2B
redrawRadarMapWindow     := $7A6B
waitForSpaceKey          := $7DB8
handleMainMenuChoice     := $7DE3
getDroneMapPosition      := $81FE
showDroneAlert           := $820F
fillDroneViewWindow      := $821F
drawShotLineInViewport   := $82BF
drawStatsTab             := $8518
drawComcenSchematic      := $8632
handleStarKeyToggle      := $8658
filmViewWordTable        := $86FB
msgShowViewWord          := $8726
msgSpeedDigit            := $8730
overlayVariantFlag       := $87FF
sprite0Shape             := $8A00
sprite1Shape             := $8A40
sprite2Shape             := $8A80
sprite2ShapeRow7         := $8A95
sprite3Shape             := $8AC0
sprite4Shape             := $8B00
D_8C00                   := $8C00
D_8D00                   := $8D00
D_8E00                   := $8E00
D_8F00                   := $8F00
vicCtrl1Shadow           := $9000
vicCtrl2Shadow           := $9001
vicMemPtrShadow          := $9002
vicBorderShadow          := $9003
vicBg0Shadow             := $9004
vicBg1Shadow             := $9005
vicBg2Shadow             := $9006
spriteColourShadow       := $9008
sprite1ColourShadow      := $9009
sprite2ColourShadow      := $900A
sprite3ColourShadow      := $900B
sprite4ColourShadow      := $900C
spriteXShadow            := $9010
sprite1XShadow           := $9011
sprite2XShadow           := $9012
sprite3XShadow           := $9013
sprite4XShadow           := $9014
spriteYShadow            := $9018
sprite1YShadow           := $9019
sprite2YShadow           := $901A
sprite3YShadow           := $901B
sprite4YShadow           := $901C
spriteXMsbShadow         := $9020
spriteEnableShadow       := $9022
spriteXExpandShadow      := $9023
spriteYExpandShadow      := $9024
spriteBuildBuffer        := $9025
outgoingCmdRing          := $9065
outgoingCmdRingEnd       := $908C
outgoingCmdWriteIndex    := $908D
outgoingCmdReadIndex     := $908E
mergedCmdBuffer          := $908F
mergedCmdReadIndex       := $90B7
glyphBuildBuffer         := $90B8
prevKeyCode              := $90E9
newKeyEvent              := $90EA
inputIdleFrames          := $90EE
textCentreWidth          := $90EF
lastStepRowDelta         := $90F4
lastStepColDelta         := $90F5
drawCellSaveCol          := $90F6
drawCellSaveRow          := $90F7
selectedUnit             := $90F8
menuTabIndex             := $90F9
currentUnit              := $90FA
currentScreen            := $90FB
stepDelayTimer           := $90FC
unitProcessOrder         := $90FD
unitOrderIndex           := $9162
linkPollDelay            := $9163
unitBaseOrder            := $9164
shotLineTimer            := $91C8
joyRepeatMask            := $91C9
joyHoldCounter           := $91CA
gameClock                := $91CB
activeSpriteIndex        := $91CC
menuItemIdList           := $91CE
menuItemCount            := $91D4
menuSelectedItem         := $91D5
menuFirstRow             := $91D6
infoPanelUnit            := $91D7
pickedCellCol            := $91D8
pickedCellRow            := $91D9
currentUnitCol           := $91DA
currentUnitRow           := $91DB
boomerTargetTable        := $91DC
fireTargetUnit           := $91F0
fireTargetDistance       := $91F1
circleStepsLeft          := $91F2
spritePlotMode           := $91F3
groupStatusTable         := $91F4
highlightedUnit          := $9204
highlightedGroupKey      := $9205
selectedGroupKey         := $9206
unusedFlag9207           := $9207
lastShownClock           := $9208
uiLevel                  := $9209
groupBoxActive           := $920A
droneHeading             := $920B
droneControlFlags        := $920D
exchangeAppliedFlag      := $920E
frameWidth               := $9221
frameHeight              := $9222
linkTimeoutCounter       := $9223
cellBackgroundColour     := $9226
scanUnitTerrain          := $9227
scanUnitType             := $9228
scanUnitTerrainHeight    := $9229
comcenStunStatus         := $922A
overviewUnitPattern      := $922B
currentUnitCloakBits     := $922C
groupBoxRightOffset      := $922E
groupBoxLeftOffset       := $922F
groupBoxBottomOffset     := $9230
groupBoxTopOffset        := $9231
viewRedrawTimer          := $9234
comcenUnit               := $9236
radarOwnUnitColour       := $9237
radarEnemyUnitColour     := $9238
radarTerrainColour       := $9239
pendingRedrawGroup       := $923A
pendingRedrawUnit        := $923B
commandSliceIndex        := $923C
attackerType             := $923D
markerSpriteTimer        := $923E
gameOverFlags            := $923F
hitDamage                := $9240
comcenSystemStatus       := $9241
comcenBattleStatus       := $9242
comcenRadarStatus        := $9243
comcenDroneStatus        := $9244
comcenCloakedFlag        := $9245
rleMarkerByte            := $9246
rleUnpackFlag            := $9247
lastTypedKey             := $9248
messageQueue             := $9249
messageQueueHead         := $9251
messageQueueTail         := $9252
incomingChatBuffer       := $9253
incomingChatScrollSrc    := $9254
incomingChatLength       := $9277
typedLineBuffer          := $9278
chatState                := $929C
lastOrderedGroupKey      := $929D
pendingGroupOrderCount   := $929E
pendingOwnCmdCount       := $929F
rawKeyCode               := $92A0
persistentMessageId      := $92A1
sideScoreLo              := $92A2
side1ScoreLo             := $92A3
sideScoreHi              := $92A4
side1ScoreHi             := $92A5
recyclerMode             := $92A6
recyclerCol              := $92A8
recyclerRow              := $92AA
terrainPointsBySide      := $92AC
dronesLeft               := $92AE
missilesLeft             := $92B0
comcenRevealTimer        := $92B2
comcenSpeedClass         := $92B4
opponentComcenSpeedClass := $92B5
exchangeActivityFlag     := $92C2
savedViewOriginCol       := $92C3
savedViewOriginRow       := $92C4
menuBlinkState           := $92C5
manualCheckTries         := $92C7
screenShakeTimer         := $92C8
gameEndCountdown         := $92C9
comcenStatusSnapshot     := $92D6
stepIntervalFrames       := $92E3
shiftHeldFlag            := $92E4
commodoreHeldFlag        := $92E5
filmNameLength           := $92E6
mergedCmdLength          := $92EB
mergedCmdSplitIndex      := $92EC
mergedCmdOwnerFlag       := $92ED
currentCommandLength     := $92EE
lastQuarterRound         := $92EF
diskIdIndex              := $92F0
filmEndPtr               := $92F1
filmEndPtrHi             := $92F2
setupToggleFlag          := $92F3
spriteScrollFineX        := $92F4
spriteScrollFineY        := $92F5
droneViewOriginCol       := $92F6
droneViewOriginRow       := $92F7
missileFlightTick        := $92F8
droneArrivalFlags        := $92F9
lastQuarterFlag          := $92FA
radarSweepTick           := $92FB
revealAllUnits           := $92FC
defaultMainMenuItem      := $92FD
soloTrainerAtGameStart   := $92FE
bitmapRowLoTable         := $9300
bitmapRowLoTableFrom3    := $9303
bitmapRowHiTable         := $9319
bitmapRowHiTableFrom3    := $931C
bitmapColLoTable         := $9332
bitmapColLoTableFrom3    := $9335
bitmapColHiTable         := $935A
bitmapColHiTableFrom3    := $935D
screenRowLoTable         := $9382
screenRowLoTableFrom3    := $9385
screenRowHiTable         := $939B
screenRowHiTableFrom3    := $939E
fontColorTable           := $93B4
titleColorRow00Cell16    := $9810
windowFrameTileSets      := $9824
windowFrameTileSetRaised := $982D
windowFrameTileSetOctagon := $9836
windowFrameTileSetPanel  := $983F
windowFrameTileSetSolid  := $9848
windowFrameTileSetBlank  := $9851
windowFrameTileSetDither := $985A
windowFrameTileSetBevelA := $9863
windowFrameTileSetBevelB := $986C
windowFrameTileSetBevelC := $9875
titleScreenRow16Col18    := $9E92
titleScreenRow16Col36    := $9EA4
titleScreenRow20Col02    := $9F22
titleScreenRow20Col23    := $9F37
titleScreenRow20Col33    := $9F41
titleScreenRow21Col03    := $9F4B
titleScreenRow21Col13    := $9F55
titleScreenRow21Col23    := $9F5F
mapRowAddrLoTable        := $BFB0
mapRowAddrHiTable        := $BFD8
bootEntry                := $C000
bootReadSectorLoop       := $C008
printChar                := $C01A
restoreRegsAXY           := $C030
printNewline             := $C041
bootDriveDataBit         := $C06A
bootWaitClkAck           := $C075
setCursorRow             := $C097
updateCursorPointers     := $C099
setFullScreenWindow      := $C0D7
saveRegsAXY              := $C0E8
bootReceiveSectorData    := $C0EF
printTableEntry          := $C100
printStringCentred       := $C121
printNextStringCentred   := $C12B
printString              := $C133
printStringAtPointer     := $C137
printDecimal             := $C17D
D_C22B                   := $C22B
plotGlyphDoubleSize      := $C24B
waitFrames               := $C37F
leftoverSetC397          := $C397
clearMessageLine         := $C39F
showMessageString        := $C3D8
advanceFillPointer       := $C3F9
restoreTextWindow        := $C40E
saveTextWindow           := $C41B
checkUiContinue          := $C451
checkSetupUiContinue     := $C470
updateStatusMessage      := $C4EF
queueMessageUnlessFilm   := $C560
queueMessage             := $C566
redrawStatusMessage      := $C5D7
clearChatStatusLine      := $C62A
resetMessageQueue        := $C6EF
showStatusMessage        := $C6FF
handlePauseKey           := $C70A
handleTextEntryKey       := $C7D5
showWorkingMessage       := $C853
promptPressButtonWhenReady := $C85B
exitIfGameEnded          := $C86D
playSound                := $C89A
requestSound             := $C8A8
dequeueSoundRequest      := $C8E9
updateComcenMoveSound    := $C8FA
setMessageSlot           := $C924
unitPictureHiTable       := $CD00
showUnitPictureSprites   := $CD0A
D_D900                   := $D900
D_DA00                   := $DA00
D_DB00                   := $DB00
commJumpTable            := $E000
commLinkControl          := $E003
getLinkByte              := $E006
putLinkByte              := $E009
pollLinkStatus           := $E00C
isOriginateMode          := $E011
D_E013                   := $E013
D_E014                   := $E014
commKeyEntry             := $E015
exchangeFlags            := $E01D
rxPacketLength           := $E01E
txPacketLength           := $E01F
rxPacketBuffer           := $E020
rxPacketArg1             := $E021
txPacketBuffer           := $E027
txPacketArg1             := $E028
hostOutCount             := $E02E
commBuildId              := $E030
nmiChainVector           := $E031
nmiChainVectorHi         := $E032
ciaIcrShadow             := $E033
nmiSuspendRequest        := $E039
nmiSuspendAck            := $E03A
isLinkActive             := $E03B
linkStatus               := $E03C
linkStatusSample         := $E03D
runTrainerRoundHook      := $E079
trainerUnitStepHook      := $E0E8
handleDisplayToggleKey   := $EEB9
unitColTable             := $F640
unitFlagsTableAlias      := $F688
unitRowTable             := $F6A4
unitTypeTableAlias       := $F6EC
unitFlagsTable           := $F708
unitTypeTable            := $F76C
unitDestColTable         := $F7D0
unitPathColTableAlias    := $F818
unitDestRowTable         := $F834
unitPathColTable         := $F898
unitPathRowTable         := $F8FC
unitEnergyTableAlias     := $F944
unitStunTable            := $F960
unitEnergyTable          := $F9C4
unitMovePointsTable      := $FA28
unitTerrainUnderAlias    := $FA70
unitHitFlashTable        := $FA8C
unitTerrainUnderTable    := $FAF0
unitDisplayFlagsTable    := $FB54
initLineStepper          := $FBB8
stepLine                 := $FBFA
multiply8x8              := $FCCD
divideSigned16by8        := $FCEC
nextScenarioRandom       := $FD4A
nextGameRandom           := $FD5D
ownPlayerName            := $FFDE
opponentPlayerName       := $FFE8
nmiVector                := $FFFA
nmiVectorHi              := $FFFB
irqVector                := $FFFE
irqVectorHi              := $FFFF

; Contents
; --------
;    $0800  gameEntry                    the game's front door.
;    $0804  loadSectors                  the game's own copy of the fast loader.
;    $0843  writeSectors                 the write half of the fast loader: sends A consecutive sectors from the address
;                                        set by setLoadDest to track Y starting at sector X, one page per sector.
;    $085C  setLoadDest                  names the memory the next transfer works on.
;    $0863  writeOneSector               writes a single sector: command $60, then the track and the sector number, then
;                                        the 256 bytes.
;    $088F  sendSectorData               pushes the 256 bytes at (wrSrcPtr) to the drive one handshaked byte at a time,
;                                        then falls into finishTransfer to read back the status byte.
;    $089C  sendByteToDrive              the slow, fully handshaked, full-duplex byte exchange with the drive; the other
;                                        end is exchangeByteWithHost ($034B) in the drive code.
;    $08EB  readOneSector                reads one sector into the load destination.
;    $08FE  readSectorBody               the body of a sector read.
;    $0943  finishTransfer               shared tail of a read and a write: puts the sprite enable register back the way
;                                        it was and collects the drive's status byte, which arrives on the ordinary handshaked path.
;    $0950  receiveSectorData            the fast half of the protocol and the only cycle-counted code in the game.
;    $09A6  manualLookupCheck            the manual look-up copy protection, and it is disabled in this build: the JSR
;                                        that called it at $0A9B was assembled as BIT ($2C instead of $20), so it is never entered.
;    $0A6C  mainStart                    the cold start of the game, run once.
;    $0AA3  returnToMainMenu             the panic exit back to the main menu, used whenever the game has to abandon
;                                        whatever it was doing: the end of a game, a lost link, the SHIFT + C= + F7 abort in the raster IRQ, and the
;                                        fall-through from mainStart.
;    $0AC8  startGameFromSetup           the hand-over from the setup screens to the battle, entered by JMP from the
;                                        setup overlay ($7E3F) once both sides have agreed on a game.
;    $0B4F  loadCommTailBuild1           not a routine of its own: the tail of startGameFromSetup, entered with X/Y
;                                        already set at $0B4B.
;    $0BB5  initIrqAndInputState         brings the interrupt and input state back to a known point.
;    $0BD8  disableCiaInterrupts         masks every interrupt source in both CIAs and acknowledges whatever was already
;                                        pending.
;    $0BE7  initInterruptVectors         finishes the interrupt set-up: it points the BRK escape of the raster handler
;                                        at the normal IRQ exit and parks both the hardware NMI vector and the comm module's private copy of it on the bare RTI
;                                        at $1298.
;    $0C02  setNmiVectorRti              points the hardware NMI vector at the bare RTI at $1298.
;    $0C0D  jumpToCd00IfPatched          dead code.
;    $0C1C  resetGameVariables           wipes the game's variables back to a cold state: zero page $15-$7F and $8C-$FF,
;                                        the whole variable block $9008-$92FF, the eight-entry message queue and the last-key bytes.
;    $0C66  installRasterIrq             installs rasterIrqHandler as the interrupt routine.
;    $0C8C  showModemSetupPrompt         puts a caller-supplied line above 'PRESS SPACE WHEN READY.' and waits for the
;                                        space bar.
;    $0CA0  resetMessagesAndBeep         clears the message queue, shows message slot $20 immediately, beeps twice a
;                                        third of a second apart and makes slot $21 the line the status bar falls back to once slot $20 has had its time.
;    $0CBD  waitForAnswerOriginateKey    the ANSWER/ORIGINATE question at the start of a modem game.
;    $0CE8  buildOwnerNameString         assembles the 21-character line that names the two players on the menu panel,
;                                        out of the two 10-byte name fields at the top of memory: ownPlayerName ($FFDE), copied out of the disk's BAM by
;                                        checkDiskId, and opponentPlayerName ($FFE8), which the modem driver fills in from the other machine.
;    $0D49  setJoystickRepeatDelay       the joystick auto-repeat used by every menu and by the map scrolling.
;    $0DB7  scanKeyboard                 the game's own keyboard matrix scanner; there is no KERNAL to ask, since the
;                                        ROMs are banked out.
;    $0E45  testKeyMask                  tests one named key for scanKeyboard: it selects the row given in Y, reads the
;                                        columns twice until two reads agree, and masks out the single column bit the caller passed in A.
;    $0E56  pollKeyboardEvent            the keyboard front end, run once per frame from the raster IRQ (and only from
;                                        there).
;    $0EB1  checkFirePressed             the game's 'has the player clicked?' primitive; almost every interactive loop
;                                        spins on it with BNE.
;    $0ED5  checkFirePressedInteractive  checkFirePressed plus a 'may the player act at all?' gate, used by the loops
;                                        that turn a click into an order.
;    $0EE5  isPlayerInputAllowed         the gate half of checkFirePressedInteractive without the button test: it jumps
;                                        into that routine at $0EDA with A = 0, so it answers 'may the local player still act?' - Z=1 when no film is being
;                                        watched and the game has not ended.
;    $0EEA  isFireHeld                   the level-triggered version of checkFirePressed: no ring-buffer check, no
;                                        debounce reload and no click sound, so it keeps answering 0 for as long as the button is held (once it has been seen
;                                        released).
;    $0EFE  stopAllSound                 Silences every SID voice by requesting sound id 0, which requestSound turns
;                                        into
;    $0F03  loadStartupOverlays          One-shot start-up loader: fast-loads track 30 sectors 0-15 into $C000-$CFFF
;    $0F16  loadCommModule               Loads the opponent module behind the $E000 jump table and initialises it.
;    $0F31  loadCommBuild2               Loads the modem-driver build of the comm module with interrupts off.
;    $0F41  loadCommFinish               Shared tail of the two comm-module load paths: does the final loadSectors with
;                                        the
;    $0F44  initCommModule               Cold-initialises the freshly loaded comm module and grafts the game's own
;                                        keyboard
;    $0F55  loadSectorsDelayed           Wrapper around loadSectors that first burns 8 raster frames through the $C000
;    $0F5F  suspendCommModule            Asks the comm module to pause before the game touches the disk: the fast loader
;    $0F71  resumeCommModule             Counterpart of suspendCommModule: when the link is up it clears the request
;                                        byte
;    $0FB1  checkDiskId                  Verifies which disk is in the drive.
;    $1018  diskErrorExit                Loads the address of msgDiskError ('DISK ERROR.') and falls into
;    $101C  showDiskMessageAndFail       Common failure exit of the disk routines: error beep, put the message on
;    $1030  ensureOverlayAResident       Makes sure the $6F00-$87FF overlay variant A (map generator, setup and menu
;    $1047  loadOverlayA                 Reads the $6F00-$87FF overlay variant A: track 31 sectors 0-16 to $6F00-$7FFF
;                                        and
;    $1060  loadStringsAndCommTail       Reads the modem status-message string block (track 29 sectors 9-10) into
;    $1070  loadCommTailBuild2           Reads the last page group of the modem driver (track 34 sectors 12-15) into
;    $1080  loadOverlayBIfNotResident    Loads the $6F00 overlay variant B only when it is not already in memory.
;    $1086  loadOverlayB                 Reads the $6F00-$87FF overlay variant B (the comcen missile and drone screens):
;                                        track
;    $10AB  loadUnitTemplatesAndSetup    Reads track 18 sectors 8-14 into $F000-$F6FF, then falls into
;    $10BB  loadSetupBlock               Reads the saved setup block (track 18 sectors 15-16) into $0200-$03FF: the
;                                        terrain
;    $10CB  suspendCommForDiskAccess     Standard prologue for disk access: pause the comm module, then set
;    $10D1  resumeCommAfterDiskAccess    Epilogue for disk access: restart the comm module, forget the persistent
;    $10DC  waitForGameDiskInserted      Blocks until the OZ game disk is in the drive.
;    $10F1  rasterIrqHandler             The game's only interrupt handler, entered through the RAM vector $FFFE at
;                                        raster
;    $1293  irqExit                      Pulls Y, X and A back off the stack and falls into irqRti.
;    $1298  irqRti                       A bare RTI.
;    $129C  updateFrameTimers            Per-frame housekeeping called from the raster IRQ.
;    $130A  stepRandomSeed               Called from the IRQ every frame.
;    $131B  blinkCursorColour            Blinks the map cursor.
;    $1331  handleChatSend               The outgoing half of the modem chat ('message to opponent'), run every frame
;                                        from
;    $1399  receiveChatCharacter         The incoming half of the chat, called every frame from the IRQ.
;    $13C4  appendChatCharacter          Appends character A to the 36-byte incoming message buffer at $9253 and
;    $1414  resumeMainMenuScreen         Re-entry into the console screen (screen 3, the F7 screen with the STATS /
;    $141D  enterMainMenu                Full entry into the console screen (screen 3): picks the default tab, draws the
;    $1439  mainMenuLoop                 The console screen's main loop (screen 3, tabs STATS / REPAIR / MISC).
;    $14F0  snapshotComcenStatus         Remembers what the REPAIR tab is currently showing: copies the three comcen
;    $1507  selectDefaultTab             Chooses which console tab should be showing.
;    $153B  showMainMenu                 Builds the console screen from scratch: resets the glyph plotter's parameter
;                                        block,
;    $1565  drawMenuFrame                Draws the console's two nested frames from the predefined window table: window
;                                        $18
;    $156F  clearMenuTextWindow          Sets the console panel's text colour and window bounds and blanks the window.
;    $1575  setMenuTextWindow            Picks the console text colour from the comcen stun state (red on the REPAIR tab
;    $1578  setMenuWindowBounds          Sets the text window that the console panels print into: rows 7 to 21, left
;    $158F  clearMenuWindowPlain         Blanks the whole console-menu panel with text colour 0 (black text, no
;                                        highlight EOR mask).
;    $1599  drawGameSetupSummary         Draws the fixed header of the console STATS tab (menu tab 0): the rules word
;                                        STANDARD/CUSTOM, the game-type name, an optional '.
;    $1643  drawMenuTabBar               Draws the three console tabs of screen 3: prints the labels STATS, REPAIR and
;                                        MISC on row 3 starting at column 10, each in its own colour, then paints the 2x2 tab box behind each of them in its
;                                        normal colour, and finally resets the bitmap/colour draw modes to 'replace'.
;    $168C  setTabHighlightColour        Redraws console tab X in its bright colour: looks the colour up in
;                                        tabHighlightColourTable and joins setTabNormalColour's common tail.
;    $1691  setTabNormalColour           Redraws console tab X: stores the chosen colour and the tab index into the two
;                                        self-modified operands, sets the character-block origin to row 1 / tabColumnTable[X], draws the 2x2 tab-box shape from
;                                        $9804 and then fills those 2x2 screen-RAM colour cells with the tab colour.
;    $16B7  blinkSelectedTab             Called once per pass of mainMenuLoop.
;    $16D6  printEnergyLabel             Prints the fixed word 'ENERGY' of the REPAIR tab at window column $1C, row 8
;                                        (absolute column 30 inside the menu panel whose left edge is 2), in the current menu text colour.
;    $16EB  setMenuTextColour            Chooses the text colour byte for the console menu screen: normally $61 (white
;                                        text, highlight EOR mask 6 matching the blue panel), but $21 while the REPAIR tab is open and our own comcen is
;                                        stunned, because drawRepairTab then paints the panel red instead of blue.
;    $16FF  printEnergyValue             Prints the comcen's energy as a percentage at column $22, row 9 of the REPAIR
;                                        tab, right-aligned in a three-character field, but only when the value has changed since the last call.
;    $175A  drawRepairTab                Builds the whole REPAIR tab: clears the panel in blue ($66) or, if our comcen
;                                        is stunned, red ($22); unpacks the comcen schematic picture into the bitmap; paints a 20x10 colour block behind it;
;                                        resets the blink and energy bookkeeping; draws the four system markers (only when not stunned); prints the ENERGY
;                                        label and value; and, when stunned, prints 'COMCEN STUNNED!'.
;    $17DA  repairTabAction              Fire-button handler of the REPAIR tab (entry 1 of menuItemHandlerTable at
;                                        $1535, reached through the self-modified JSR at $148D).
;    $188B  toggleSystemMarker           One blink step for the marker of the comcen system currently highlighted in the
;                                        REPAIR WHICH menu: flips blinkPhase and then either draws the marker (new phase 0) or erases it with the schematic
;                                        background colour $16 (new phase 1).
;    $189D  drawSystemMarker             Draws the status marker of comcen system Y on the REPAIR tab schematic.
;    $1919  eraseSystemMarker            Fills the whole 3x3 colour-cell block of system Y's marker with the colour byte
;                                        in X.
;    $192D  sendEnergyRepairCommand      Queues the 3-byte command $9A (cmdAddEnergy) with the comcen's unit index and
;                                        the repaired amount, so that both machines add the same energy to that unit.
;    $193C  attemptSystemRepair          Resolves one repair attempt on the comcen system highlighted in the REPAIR
;                                        WHICH menu.
;    $19B0  blinkRepairMarker            Blinks the marker of the selected comcen system for about one second while a
;                                        repair attempt resolves.
;    $19C9  printAttemptingRepair        Positions the REPAIR panel's own message row (window column 10, screen column
;                                        12, screen row 20) and prints 'ATTEMPTING REPAIR...' there, then restores the text window the caller had.
;    $19D6  clearRepairMessage           Erases the REPAIR panel's message row by printing 20 copies of the blank glyph
;                                        zp_3E over it, then restores the caller's text window.
;    $19E1  setRepairMessageWindow       Shared prologue of printAttemptingRepair and clearRepairMessage: saves the
;                                        caller's 12-byte text state, picks the menu text colour, applies the menu panel geometry (left column 2, width $24,
;                                        rows 7..$16) and places the cursor at window column 10 = screen column 12 of screen row 20.
;    $19F3  printOptionsList             Prints the body of the MISC tab.
;    $1A49  miscTabAction                Fire-button handler of the MISC tab (entry 2 of menuItemHandlerTable at $1535)
;                                        and, through the JMP at $0AC5, the top of the whole main-menu loop at cold start.
;    $1ADC  endGameToMainMenu            Tears a finished game session down and prepares the main menu.
;    $1B33  hangUpModem                  Convenience entry: hang up and leave linkSessionState at 0 (no session).
;    $1B35  hangUpModemSetState          Drops the modem link and stores A as the new linkSessionState.
;    $1B5F  openCommLink                 Brings the link up if it is not already.
;    $1B74  openLinkAndWaitForConnection openCommLink followed by waitForConnection (fall-through).
;    $1B77  waitForConnection            On the modem build, pins the status message 'WAITING FOR CONNECTION...', spins
;                                        in waitForCarrier until the modem reports carrier, then clears the voice-pause bit of pauseState and blanks the
;                                        message line.
;    $1B9A  waitForCarrier               Polling loop that runs until the comm module reports carrier (bit 6 of the
;                                        debounced carrier state $E03C).
;    $1BC1  showMessageWaitForSpace      Pins status message A on the message line and blocks until the player presses
;                                        the space bar.
;    $1BF7  voicePauseReconnect          The VOICE PAUSE round trip: hang up so both players can talk on the phone, wait
;                                        five seconds, then bring the line back up and wait for carrier.
;    $1C08  hangUpAndPause               Hangs up for a voice pause and then waits about five seconds.
;    $1C17  disconnectFromOpponent       Ends a link session cleanly: tells the opponent with command $8C reason 5
;                                        (disconnect), pushes the packet out immediately and hangs up.
;    $1C2C  runMainMenu                  Runs the main menu whose text printOptionsList has just drawn: five items, or
;                                        six when a link session is up and DISCONNECT/SET VOICE is offered.
;    $1C7B  isAsymmetricGameType         Tests the game type held in gameTypeOptions bits 0-2 and returns Z=1 for
;    $1C87  restoreSettingsAfterFilm     Undoes what WATCH GAME FILM overwrote.
;    $1CC1  setViewVerticalLimit         Sets viewRowLimit, the exclusive map row limit the centre cell of the
;    $1CD3  scrollViewBy                 Moves the 7x5-cell tactical view by one map cell: X = column delta, Y = row
;                                        delta,
;    $1D4E  scrollViewToUnit             Scrolls the battlefield view until unit X sits under the centre cursor, one map
;    $1D97  setLineTargetToUnit          Points the line stepper's end point at unit X.
;    $1DE4  scrollViewBitmap             Shifts the whole 7x5-cell tactical view one map cell (16 pixels = 2 characters)
;    $1F4E  keepGroupBoxInsideMap        Nudges the view origin one cell at a time until the whole group formation
;    $1FC3  enterMapScreen               Entry point of the battlefield screen (game mode 0).
;    $1FD0  runMapMainLoop               The interactive loop of the battlefield screen and of the unit-placement phase.
;    $20F4  scrollViewportByDirection    Turns a direction code 0-7 into a column/row step and scrolls the view
;    $2185  runTargetCursorLoop          Modal loop that lets the player drive the cursor to a cell and then acts on
;    $2264  previewPathToCursor          Idle-time animation: while the player leaves the controls alone with a move
;    $22A2  showUnitInfoPanel            Fills the info panel under the tactical view (text window rows 12-20, columns
;    $2367  checkUserInterrupt           The 'may this passive display keep running?' test used by the idle loops of the
;                                        info panel and the path preview.
;    $2384  printUnitStatusLines         Fills in the value column of the unit info panel for selectedUnit ($90F8): the
;                                        group name on row 13, the energy percentage on row 17, the weapon state on row 18, the action on row 19 and one flag
;                                        word (BLITZ / CLOAKED / DUG IN) on row 20, then adds up to two '*' zone markers in front of the unit name on row 12.
;    $24E4  animatePathMarkerToDestination Sets the line-stepper target to the ordered destination of selectedUnit and
;                                          falls into animatePathMarkerStep, so the mini-map marker crawls from the unit towards where it has been sent.
;    $24F3  animatePathMarkerStep        Advances the mini-map path marker by one cell every 4 frames, but only after
;                                        the player has been idle for 90 frames.
;    $2540  resetPathMarker              Puts the path marker back at the start: sprite 1 off, marker cell and pass
;                                        counter back to the selected unit, and sprite 1's shape reloaded.
;    $256F  resetPathMarkerToUnit        Reloads the marker's pass counter with 5 and moves the marker cell onto the
;                                        selected unit's map position, then falls into positionMarkerSprite to place sprite 1 there.
;    $2585  positionMarkerSprite         Converts a map cell to mini-map sprite coordinates (column*4+$1F, row*4+$39, or
;                                        the group-box mapping when the mini-map is zoomed) and stores them in sprite 1's position shadows, which the raster
;                                        IRQ copies to the VIC.
;    $258F  recenterViewportOnUnit       Makes sure the selected unit sits under the battlefield cursor.
;    $25B7  consumeSpaceKey              Eats a pending SPACE.
;    $25C4  callSetupOverlayHook         Trampoline into overlay variant A at $8658 (handleStarKeyToggle), the per-frame
;                                        '*' key handler of the non-battle phases: it toggles setupToggleFlag $92F3 bit 7 (DESTROY mode during unit placement,
;                                        CUSTOM rules on the setup screen) and reports C=1 when it consumed the key.
;    $25C7  getViewportOriginPointer     Builds a map pointer for the top-left cell of the 7x5 battlefield view.
;    $25E3  getViewportRowPointer        Map pointer for the cell (viewOriginCol, Y).
;    $25FA  readMapCell                  The map accessor of the whole game: forms mapCellPtr = rowAddress[Y] + X from
;                                        the 40-entry row address tables at $BFB0/$BFD8 (built by the start-up code at $0339 as $F000 + 40*row) and returns the
;                                        byte of cell (column X, row Y) of the 40x40 battlefield.
;    $260D  readCellUnderCursor          Reads the map cell under the battlefield cursor.
;    $261C  clearInfoPanel               Selects the unit info panel as the text window and blanks it with the text
;                                        engine's window clear, using the current textColour zp_3D and fill glyph zp_3E.
;    $2622  setInfoPanelWindow           Defines the text window that covers the unit info panel: columns 25-38, rows 12
;                                        to 20 (row 21 is the exclusive limit).
;    $2636  clearScreenAndRedrawGameScreen Rebuilds the battlefield display from scratch: sets the VIC up for the
;                                          multicolour bitmap and wipes it grey, then falls into redrawGameScreen.
;    $2639  redrawGameScreen             Draws the whole battlefield screen: the three panel frames (mini-map, tactical
;                                        view, info panel), the bottom status bar, the 40x40 overview map, the 7x5 tactical view and finally the current status
;                                        message.
;    $268F  drawMiniMapFrame             Draws window 0: the 22x22 character frame at column 0, row 0 that surrounds the
;                                        20x20 strategic mini-map.
;    $2698  drawViewportFrame            Draws window 1: the 16x12 character frame at column 24, row 0 around the
;                                        tactical view, then falls into clearViewportTextArea to blank the interior.
;    $26A1  clearViewportTextArea        Declares the interior of the tactical view (rows 1-10, columns 25-38, i.e.
;    $26B8  drawInfoPanelFrame           Draws window 2: the 16x11 character frame at column 24, row 11 around the unit
;                                        info panel.
;    $26C1  drawViewport                 Redraws the tactical view: 5 rows of 7 map cells, each cell a 16x16 pixel
;                                        multicolour tile occupying 2x2 character cells, into character rows 1-10 and columns 25-38.
;    $2772  renderMapTile                Builds the 16x16 pixel multicolour tile of one map cell in the 32-byte buffer
;                                        at $04C8-$04E7 and writes the cell's four screen-RAM colour bytes.
;    $28E1  drawMainWindowFrame          Draws the frame of the bottom bar: window 3 on the battlefield screen, window 4
;                                        on every other screen.
;    $28F1  computeGameResult            Turns the state at the end of a game into the two final point totals and the
;                                        result message.
;    $29B3  setOtherPlayerPoints         Writes Y as the 16-bit point total of the side OTHER than X and returns with X
;    $29C1  shakeScreenScroll            One frame of the screen-shake effect used when a comcen is hit.
;    $2A08  patchUnitSideBranches        Re-points the two 'is this unit mine?' branches at the viewpoint currently
;    $2A1A  printOptionWord              Prints one of the three 4-byte DAMAGE option words held at $0652 in the message
;    $2A38  setBitmapDrawMode            Chooses how a glyph's eight bitmap bytes are combined with what is already in
;                                        the
;    $2A47  setColourDrawMode            The colour-plane twin of setBitmapDrawMode: chooses how drawColourByte is
;                                        combined
;    $2A56  resetDrawModes               Puts both planes back to mode 0 (plain replace) after a highlight or overlay
;                                        effect.
;    $2A64  fillScreenColourRect         Paints an X columns by Y rows rectangle of the multicolour bitmap's colour
;                                        cells
;    $2A9F  calcCellPointers             Loads the character cell coordinates from scratch18 (row) and scratch19
;                                        (column) and
;    $2AA3  calcCellPointersXY           Turns a character cell (Y = row, X = column) into the two pointers every
;                                        plotter
;    $2AC3  drawCharAt                   Draws character A at screen row scratch18, column scratch19: computes the cell
;                                        pointers and
;    $2AC8  plotGlyph                    Draws one 8x8 glyph of the game's own font (114 glyphs at $9426, 8 bytes each)
;                                        into the
;    $2B18  drawCharBlock                Draws a rectangular block of characters at windowOriginRow/windowOriginCol.
;    $2B75  drawWindowInnerCorners       Draws the small hollow-ring glyph $38 (font bytes at $95E6) in the four cells
;    $2BB7  drawTallGlyph                Draws one of the ten two-cell-tall symbols whose character-code pairs live at
;                                        $9810
;    $2BCE  xorColourBlock2x2            Flashes one battlefield tile by EORing the colour byte A into the 2x2
;                                        screen-RAM
;    $2C7F  drawWindowFrame              Draws one of the 27 predefined windows selected by X.
;    $2CA1  drawFrameBox                 Draws a rectangular frame from a 9-byte tile set at
;                                        windowOriginRow/windowOriginCol,
;    $2D1D  drawFrameColumn              Repeats the tile at offset Y of the current tile set down column scratch19,
;                                        starting
;    $2D2F  drawFrameRow                 Repeats the tile at offset Y of the current tile set along row scratch18,
;                                        starting at
;    $2D41  initBitmapScreen             Builds the game's display from scratch: a full-screen text window, grey border
;                                        and
;    $2DBD  setTerrainTilePtr            Points glyphPtr at the 8-byte terrain tile definition number A in the table at
;    $2DDB  clearMessageLineAndScreen    Wipes the status/message line first, then falls into
;    $2DDE  clearCurrentScreenWindows    Re-clears the content areas of whichever screen is active, dispatching on
;    $2E0F  magnifyWindow2x              Redraws the character rectangle at windowOriginRow/windowOriginCol, frameWidth
;                                        by
;    $2F34  setMagnifyCellPointers       Helper of magnifyWindow2x.
;    $2F6A  initSpritePointers           Rewrites the eight sprite pointers in the last bytes of the video matrix,
;    $2FA5  clearAllSpriteData           Zeroes $8A00-$8BFF, the 64-byte shape blocks of all eight sprites, in one
;                                        256-pass
;    $2FB2  clearSpriteShape             Zeroes the 64-byte shape of sprite A at $8A00 + A*64.
;    $2FCF  setCursorShapeSquare         Gives sprite 0, the map cursor, the 18-byte (6 rows of 24 pixels) hollow square
;    $2FD3  installCursorShape           Shared tail of setCursorShapeSquare and setCursorShapeCross: copies the 18-byte
;    $2FF3  setCursorShapeCross          The same as setCursorShapeSquare but installs the thick plus/cross shape at
;                                        $9E80
;    $2FFA  setCursorShapeBlock          Loads the small 4x4 solid block at $9E92 into the sprite named by
;    $3023  mapCellToSpriteXY            Converts a battlefield cell (X = column 0-39, Y = row 0-39) into the sprite
;    $303A  mapCellToSpriteXYRaw         The plain cell-to-sprite conversion for the overview map: spriteX =
;    $3049  positionCursorSprite         Puts the sprite named by activeSpriteIndex ($91CC) over the cursor cell of
;    $306C  drawLineInPathSprite         Draws a straight line into the shape of sprite 3 ($8AC0), which is the game's
;    $308A  plotPathCross                Marks the end of a path/shot line by plotting the four orthogonal neighbours of
;                                        the
;    $30A1  plotPathSpritePixel          Sets one pixel in the shape of sprite 3 at $8AC0.
;    $30C0  buildGroupFormationSprite    Builds the 'formation ghost' shown while a whole group is being ordered
;    $31A3  enableSprites                ORs the sprite mask in A into spriteEnableShadow ($9022), the shadow the raster
;                                        IRQ
;    $31AF  plotSpritePixel              Sets, clears or toggles one pixel of a sprite shape.
;    $31CA  setSpritePixel               Entry point of plotSpritePixel that forces the ORA (set) operation.
;    $3239  drawCircleInSprite           Draws a circle of radius X (1-10) centred on pixel (12,10) - the middle of a
;    $3266  computeCircleOctantPoints    Expands one octant point (dx,dy) of a circle into the coordinates of all
;    $32BF  plotCircleOctantPoints       Plots the eight mirrored points prepared by computeCircleOctantPoints into
;    $32EA  hideSprite1                  Clears bit 1 of the sprite enable shadow and writes the result straight to
;    $32F6  hideSprite0Dead              Dead code: a byte-for-byte copy of hideSprite1 that clears bit 0 (sprite 0)
;    $3302  showCrosshairSprite          Loads the 63-byte diamond crosshair (the second shape of the pair at $9EA4,
;    $3306  showCursorBoxSprite          Same as showCrosshairSprite but copies the first shape of the pair ($9EA4-
;    $3340  loadDirectionShapeSprite     Copies sprite shape number A (0-7, one per facing direction) from the
;    $3383  showComcenCrossMarker        Puts the small cross marker at $9F22 into rows 7-13 of sprite 2, colours it
;    $33B1  gridCellToSpriteCoords       Converts a cell of overlay B's 21x17 radar grid (one map cell per 8x8
;    $33C8  showExplosionMarker          Shows the explosion marker for a weapon effect at map cell (X,Y).
;    $3405  startExplosionMarkerTimer    Two-instruction tail shared by both paths of showExplosionMarker: arms
;    $340B  getUnitSpriteCentreOnGrid    Maps a battlefield cell onto overlay B's radar grid and returns the
;    $341F  updateExplosionMarker        Keeps the explosion marker (sprite 4) on the right cell of the drone camera
;    $3456  drawComcenRingSprite         Builds the comcen marker of the drone screen: two concentric rings (radius 2
;    $3458  drawRingsInSprite4           Shared body of drawComcenRingSprite: clears sprite 4 and draws A concentric
;    $347F  orderSelectedUnitToCursor    Issues a MOVE order for the selected unit to the cell under the cursor:
;    $34A4  updateUnitAtDestinationFlag  Sets bit 7 of unitDisplayFlags ($FB54) for the unit in cmdParam0 when
;    $34C6  applyMoveOrder               Executor of command $80 MOVE (unit, column, row).
;    $34D5  setUnitDestination           Stores a destination for unit Y.
;    $355D  clampMapCoord                Clamps a signed map coordinate to the battlefield: negative becomes 0, anything
;                                        from
;    $3568  markAllUnitsVisible          Sets bits 6 and 7 (spotted this cycle, currently visible) of unitEnergy for
;    $3576  ageUnitVisibility            Run once per unit-processing cycle, before the spotting passes: every unit that
;    $35C3  scanVisibleEnemies           Builds the list of enemy units that unit Y can see (X bit 7 clear, the spotting
;    $3746  checkLineOfSightStep         Feeds the terrain class of one more cell into the running line-of-sight test.
;    $375D  initLineBetweenUnits         Points the line walker at unit Y's cell and falls into initLineFromUnit, which
;                                        sets the start to unit X's cell.
;    $3767  initLineFromUnit             Sets the line walker start to unit X's cell, runs initLineStepper ($FBB8) and
;                                        reports the principal direction of the line: 0 = north or 1 = south for a row-major line, 2 = east or 3 = west for a
;                                        column-major one.
;    $378B  setScanFilterOppositeSide    Configures the unit filter of collectUnitsInRadius so that only units of the
;                                        side opposite to unit Y are collected, by writing the compare value and the branch opcode of the test at $37FE/$3800.
;    $379E  collectAllUnitsInRadiusDead  Unreferenced alternate entry of collectUnitsInRadius that turns the side filter
;                                        into 'accept every unit' (compare against unit 0 with a BCC) before falling into the scan.
;    $37AA  collectUnitsInRadius         Scans the square of half-width A around map cell (X,Y), clipped to the 40x40
;                                        battlefield, and lists every cell holding a unit that passes the side filter patched by setScanFilterOppositeSide: the
;                                        unit index goes into $0400[n] and its squared distance from the centre into $0432[n].
;    $3870  distanceSquared              Squared distance between two map cells: |dCol|^2 + |dRow|^2 taken from
;                                        squaresTable.
;    $38C2  resolveAttack                Resolves one shot of unit X at unit Y.
;    $3A5A  drawShotLineSprite           Draws the shot on the 4-pixel-per-cell overview map using hardware sprite 3.
;    $3B22  applyHitToUnit               Takes A points of damage off unit Y and deals with everything that follows.
;    $3B52  applyHitToUnitNoAlert        Alternate entry of applyHitToUnit that skips the block above, i.e.
;    $3BDF  reduceUnitEnergyBy           Stores A as the damage in hitDamage $9240 and falls into reduceUnitEnergy.
;    $3BE2  reduceUnitEnergy             Subtracts hitDamage $9240 from the energy of unit Y, keeping the two visibility
;                                        flags in bits 6-7 and stopping at 0.
;    $3BFC  recordUnitHit                Records the visible effect of a hit on unit Y: the damage, capped at 20, is
;                                        stored as $80|damage in the hit-effect byte $FA8C that drives the flash and the PINNED status word, the same number of
;                                        movement points is taken off the signed counter $FA28 (clamped at -127), and bit 1 of the display flags asks for the
;                                        console entry to be refreshed.
;    $3C20  orderAttackUnitAtCursor      Fire-button action of the attack cursor.
;    $3C50  setBoomerTargetCmd           Execution half of command $87 SET TARGET.
;    $3C5D  countBoomersBelow            Counts the BOOMERs (type 2) with an index below X, which is the ordinal of
;                                        boomer X among all boomers and therefore its slot in boomerTargetTable.
;    $3C74  getBoomerTarget              Reads the target lock of unit X.
;    $3C85  selectBoomerTarget           Picks what boomer Y shoots at this round.
;    $3CF2  animateRingBurst             Expanding-then-contracting ring drawn into hardware sprite 1.
;    $3D57  rollComcenSystemDamage       Rolls for collateral damage to the local comcen's three repairable systems
;                                        after it has been hit.
;    $3D8A  initAllUnits                 Resets all 100 unit records from their start positions and puts every living
;                                        unit back on the battlefield map, then clears the 16 per-group state bytes.
;    $3DA0  initUnitRecord               Resets the order state of one unit: its path position and its destination are
;                                        snapped to the cell it stands on, the destination is marked 'idle', the zone-scored bits and the stun counter are
;                                        cleared, the group membership is kept and the facing and side bits are rebuilt.
;    $3DDA  placeUnitOnMap               Second half of the unit reset: restores the dug-in bit from the type byte,
;                                        gives the unit a fresh movement allowance and full energy, copies the stationary and group bits into the display
;                                        flags, remembers the terrain of the cell it stands on and writes $80|index into that map cell.
;    $3E23  recycleDeadUnits             Once per round each side that owns a recycler gets one destroyed robot back.
;    $3EBD  removeUnitFromMap            Lifts unit X off the battlefield map: the terrain byte the unit was hiding is
;                                        written back into its cell, the unit is marked as 'not on the map' (unitTerrainUnderTable = $FF) and its
;                                        hit-flash/pinned countdown is cleared.
;    $3EC2  removeUnitLeavingCell        Same as removeUnitFromMap but the caller chooses what stays behind in the
;                                        unit's cell (A) instead of the saved terrain.
;    $3EE2  applyBlitzStepCost           Charges a blitzing unit for the step it has just taken: it loses one energy
;                                        point and gains 25 movement points (SET BLITZ = 'move extra fast'), and its next shot gets the charge bonus
;                                        (unitFlagsTable bit 7).
;    $3F27  drawRecyclerOnMap            Puts the recycler glyph ($67) on the battlefield map at side Y's recycler
;                                        position and hands the caller everything drawMapCell needs to show it.
;    $3F57  checkEnemyRecyclerSpotted    Reveals the opponent's recycler the first time one of our own uncloaked robots
;                                        gets within 3 cells of it: it draws the recycler glyph on the map, queues message 8 'SPOTTED RECYCLER!' and then
;                                        patches its own first byte to RTS so the test never runs again.
;    $3F97  initialFacingForUnit         Returns the facing a unit starts with: 0 (up, towards the enemy) for the local
;                                        player's units and 1 (down) for the opponent's, since each player sees his own side at the bottom of the map.
;    $3FA3  mirrorMapCoordinate          Returns 39 - A, mirroring a map column or row across the 40x40 battlefield.
;    $3FAC  haltStunnedUnit              Cancels the orders of a unit that is stunned (a drone hit stuns everything
;                                        around it): its waypoint and destination are snapped back to the cell it stands on, it is marked stationary and it is
;                                        dropped out of its group so the group does not wait for it.
;    $3FE8  haltAllUnitsAndReveal        Game-over clean-up: repairs the three comcen system status bytes, stops all 100
;                                        units (stationary, nothing fired, nothing pinned, waypoint snapped back onto the unit) and reveals the whole board by
;                                        marking every unit as spotted.
;    $4027  endGame                      Ends the battle once the caller has stored the reason in gameEndReason $0BA8
;                                        ($08 = the clock ran out, $A0/$C0 = one side's comcen was knocked out).
;    $403B  markComcenRevealed           Launching a radar sweep or a drone gives the launching comcen away: it sets
;                                        that side's comcen-reveal timer to 6 rounds and forces the visibility bits of that comcen so the other player can see
;                                        it on the map.
;    $406F  waitFramesOrInput            Interruptible pause that keeps the battle running: it advances the game one
;                                        frame at a time for up to A frames and returns early on a fresh fire-button press, on the joystick leaving its centre
;                                        position, or when the game-end countdown starts.
;    $4094  runGameFrames                Advances the game by A frames: each pass waits for one raster frame and then
;                                        runs one updateGameFrame.
;    $40B2  updateGameFrame              The heartbeat of the game, called once per frame from every main and
;                                        user-interface loop.
;    $4213  processUnitStep              Processes exactly one unit of the round order; this is an internal entry of
;                                        updateGameFrame reached by the branch at $4206, not by a JSR, and it ends by falling into the shared tail at L_4537
;                                        which bumps unitOrderIndex.
;    $4559  tryStepTowardTarget          walks the line stepper one cell from the current cell ($93,$92) toward the
;    $45E6  finishRound                  end of a round, reached by JMP from updateGameFrame ($4210) once all 100 units
;                                        have
;    $4751  moveUnitToCell               puts currentUnit into the cell ($93,$92) that tryStepTowardTarget just
;                                        accepted:
;    $47ED  shuffleUnitOrder             exchanges two randomly chosen entries of unitBaseOrder ($9164, the 100 unit
;    $4805  randomUnitIndex              returns a unit index 0-99 drawn uniformly from the scenario random generator:
;    $480F  followDirectedUnit           keeps the battlefield view locked on the unit the console is following.
;    $4860  updateHitFlashTimers         ages the hit markers of all 100 units.
;    $48A2  drawUnitAtPosition           redraws unit Y where it stands: builds the map byte $80|unit and falls into
;    $48A5  drawValueAtUnit              draws cell value A at the position of unit Y.
;    $48B1  updateIdleFlag               recomputes bit 7 of unitDestCol ($F7D0) for currentUnit: it is set when the
;    $48DD  rebuildGroupTables           recomputes the 16 entries of groupStatusTable ($91F4), one per group key
;    $4987  averageGroupMoveVector       averages the outstanding movement of one group.
;    $4A02  followGroupMovement          Gives the unit that is joining a group the movement the rest of the group
;                                        already has: averageGroupMoveVector ($4987) returns the average (destination - position) vector of the members and the
;                                        unit's new destination becomes its own cell plus that vector.
;    $4A35  sendGroupMoveToCursor        Fire-button action of the battlefield target cursor while a whole group is
;                                        selected (uiLevel $9209 >= 2).
;    $4A52  moveGroupKeepingFormation    Executor of command $81, run in lock step on both machines.
;    $4AA3  addSelectedUnitToGroup       The 'ADDED' half of the group membership editor.
;    $4AD4  joinUnitToGroup              Executor of command $88.
;    $4AF2  removeSelectedUnitFromGroup  The 'REMOVED' half of the membership editor.
;    $4B04  isPointWithinGroupRange      Range test that stops a group from spreading over more than 8 cells.
;    $4B5E  computeGroupBounds           Scans all 100 unit slots and accumulates the member count and the bounding box
;                                        of one group.
;    $4BBF  showTooFarMessage            Error feedback shared by the two group range checks: selects and clears the
;                                        14-column info panel (screen columns 25-38, rows 12-21), plays sound 3 (the rejection beep) and prints 'TOO FAR!' from
;                                        the $C000 text overlay on screen row 16, in whatever colour the caller left in textColour.
;    $4BD3  orderGroupNewPosition        NEW POSITION action of the custom-formation editor.
;    $4C59  setGroupMemberDestination    Executor of command $98.
;    $4C6B  syncLocalGroupFlags          Copies the authoritative group bits of unit X (unitFlagsTable & $7C: group
;                                        number, side and member bit) into the local flag copy unitDisplayFlagsTable, keeping that byte's own bits 0, 1 and 7.
;    $4C84  rotateGroupFormation         Executor of command $A3 - the SWEEP LEFT / SWEEP RIGHT / REVERSE items of the
;                                        FORMATION menu.
;    $4D9F  getCommandLength             Looks the total length (command byte plus its parameters) of command A up in
;                                        commandLengthTable and leaves it in currentCommandLength as well as in A.
;    $4DA9  serviceCommandExchange       Per-frame entry point of the lock-step command exchange, called from the main
;                                        loop of every screen and from the overlays.
;    $4DB7  processCommandExchange       The heart of the lock-step protocol.
;    $4E91  executeTurnCommands          Runs every command of one exchange.
;    $4F05  mergeIncomingPacket          Appends the packet just received from the opponent ($E020, $E01E bytes) to the
;                                        merged command buffer, marking the block as remote.
;    $4F5E  advancePacketCursor          Helper of both packet mergers: keeps the command byte in A, looks its length up
;                                        and leaves packetReadIndex pointing at the next command in the packet.
;    $4F6B  mergeOutgoingPacket          Appends the copy of the packet this machine just sent ($E027, $E01F bytes) to
;                                        the merged buffer, marking the block as own, so the local commands are executed at exactly the same point in the
;                                        sequence as on the opponent's machine.
;    $4FC4  readCmdArgs3                 Argument reader for the 4-byte commands $80, $81, $8A, $8B, $98, $9C and $A6:
;                                        takes the first parameter into cmdParam0 and falls through into readCmdArgs2, which picks up the other two.
;    $4FD0  readCmdArgs2                 Argument reader for the 3-byte commands $87, $88, $8E, $90, $9A, $A3 and $A5,
;                                        and the tail of readCmdArgs3.
;    $4FE4  mirrorCmdCoordsIfRemote      Point mirror for the map coordinates carried by a command.
;    $4FF7  readCmdArg1                  Argument reader for all the 2-byte commands - the per-unit and per-group orders
;                                        $82-$86, $89, $8C, $91, $93-$96, $9B, $9D, $9F-$A2 and $A4.
;    $5005  mirrorCmdCoordsIfRemoteDec   Coordinate mirror for the drone, which covers a 2x2 block of cells and is
;                                        addressed by its top-left corner.
;    $5011  cmdMoveUnit                  Handler of command $80 MOVE (4 bytes: unit, column, row), the ordinary move
;                                        order given by clicking a cell with a single unit selected.
;    $501A  cmdMoveGroupRelative         Handler of command $81 (4 bytes: unit, column, row) - the move order given
;                                        while a whole group is selected.
;    $5023  cmdCloakOn                   Handler of command $82 CLOAK ON (2 bytes: unit).
;    $504B  cmdCloakOff                  Handler of command $84 CLOAK OFF (2 bytes: unit); also used as a subroutine by
;                                        cmdBlitzOn.
;    $5062  cmdBlitzOff                  Handler of command $95 BLITZ OFF (2 bytes: unit); also used as a subroutine by
;                                        cmdCloakOn.
;    $5070  cmdBlitzOn                   Handler of command $93 BLITZ ON (2 bytes: unit), the mirror image of
;                                        cmdCloakOn.
;    $5098  cmdGroupCloakOn              Handler of command $83 (2 bytes: group key).
;    $50AA  cmdGroupBlitzOn              Handler of command $94 (2 bytes: group key).
;    $50BE  cmdGroupBlitzOff             Handler of command $96 (2 bytes: group key) and, at $50C1, the blitz half of
;                                        the patch block that configures the shared group routine.
;    $50D2  cmdGroupCloakOff             Handler of command $85 (2 bytes: group key) and, at $50D4, the cloak half of
;                                        the patch block.
;    $50EB  applyGroupCloakOrBlitz       The shared, heavily self-modified body of the four group cloak/blitz handlers;
;                                        it is only ever reached by branching, never by JSR.
;    $5151  updateComcenCloakLock        The common tail of every cloak and blitz handler and the only writer of
;                                        comcenCloakedFlag: it copies bit 7 of the local player's own comcen entry in unitPathColTable, so $9245 bit 7 is set
;                                        exactly while that comcen is cloaked.
;    $515D  cmdClearTarget               Handler of command $86 CLEAR TARGET (2 bytes: BOOMER unit).
;    $516B  cmdSetTarget                 Handler of command $87 SET TARGET (3 bytes: BOOMER unit, target unit).
;    $5185  cmdJoinGroup                 Handler of command $88 JOIN GROUP (3 bytes: unit, group byte).
;    $519B  cmdLeaveGroup                Handler of command $89 (2 bytes: unit): clears bit 6 ('in a group') of the
;                                        unit's
;    $51AB  cmdPlaceUnit                 Handler of command $8A (4 bytes: unit, column, row): teleports a unit during
;                                        the deployment
;    $51EA  cmdSetCustomMapSeed          Handler of command $A6 (4 bytes: three seed bytes): sets mapFlipFlag ($0B9D) =
;                                        1 and
;    $51EE  cmdSetMapSeed                Handler of command $8B (4 bytes: three seed bytes): mapFlipFlag = 0, the three
;                                        arguments are
;    $520C  cmdComcenRepairedOrSetupReady Handler of the 1-byte command $9E, which means two different things.
;    $523E  cmdSessionControl            Handler of command $8C (2 bytes: reason).
;    $52AE  getPauseClearMask            Returns the AND mask that clears the resuming side's bit of a two-bit
;                                        ready/pause byte.
;    $52C8  cmdDroneMove                 Handler of command $8D (4 bytes: heading, column, row), the opponent's live
;                                        drone telemetry.
;    $532F  saveRegsForCmdReturn         Two-instruction helper for the handlers that are called from the middle of the
;                                        packet
;    $5336  cmdDroneDetonate             Handler of command $8E (3 bytes: column, row).
;    $537B  cmdDroneRelease              Handler of the 1-byte command $8F, queued by releaseDroneControl ($8230) when
;                                        the player
;    $538B  cmdDroneShotDown             Handler of the 1-byte command $92.
;    $53A3  cmdDroneLaunch               Handler of the 1-byte command $97: droneArrivalFlags = 0 for a fresh drone in
;                                        the air.
;    $53C0  cmdMissileStrike             Handler of command $90 (3 bytes), which carries two different payloads.
;    $542E  cmdSelfDestruct              Handler of command $91 (2 bytes: unit; bit 7 of the argument means 'the whole
;                                        group this
;    $5459  destroyUnit                  Removes unit cmdParam0 from play.
;    $5470  cmdMoveKeepGroup             Handler of command $98 MOVE TO GOAL (4 bytes: unit, column, row), the order
;                                        issued to
;    $5479  cmdAddEnergy                 Handler of command $9A (3 bytes: unit, amount), sent by the COMCEN energy
;                                        repair on the
;    $549E  cmdHaltGroup                 Handler of command $9B (2 bytes: group byte), the group HALT order.
;    $54E1  cmdSetRecycler               Handler of command $9C (4 bytes: mode, column, row), sent by overlay A's
;                                        chooseRecycler
;    $5511  cmdChooseSide                Handler of command $9D (2 bytes: the sender's side-preference token), the
;                                        side-selection
;    $553B  sendChooseSide               The sending half of the $9D exchange: copies playerSide into cmdParam0 and into
;    $5548  cmdDigIn                     Handler of command $9F DIG IN (2 bytes: unit): calls setUnitDigBits with $20,
;                                        i.e.
;    $554C  cmdDigOut                    Handler of command $A0 DIG OUT (2 bytes: unit): setUnitDigBits with 0 clears
;                                        the dig-progress
;    $555A  setUnitDigBits               Shared prologue of cmdDigIn and cmdDigOut: patches the new dig bits into the
;                                        ORA operand
;    $5562  applyDigBitsToUnit           The two-line worker shared by the single-unit and the group dig handlers:
;    $556D  cmdGroupDigIn                Handler of command $A1 (2 bytes: group byte): patches the dig bits to $20 and
;                                        runs the
;    $5571  cmdGroupDigOut               Handler of command $A2 (2 bytes: group byte) and the entry of the shared group
;                                        dig loop:
;    $5596  cmdSetFormation              Handler of command $A3 (3 bytes: group leader unit, rotation mode 0-2): reads
;                                        the two
;    $559C  cmdSetGameType               Handler of command $A4 (2 bytes): stores the argument into gameTypeOptions
;                                        ($0BA3), which
;    $55A5  cmdSetupOption               Handler of command $A5 (3 bytes: kind, value), the generic carrier of overlay
;                                        A's
;    $55EC  queueCmdBytePending          Entry used by the 1-byte commands ($8F, $92, $97, $9E): counts one command in
;    $55EF  queueCmdByte                 Appends A to the 40-byte outgoing command ring at $9065, wrapping the write
;                                        index $908D at
;    $560A  queueCmd4                    Queues a 4-byte command: the code in A followed by cmdParam0, cmdParam1 and
;                                        cmdParam2 (used for
;    $560F  queueCmd3                    Queues a 3-byte command: the code in A followed by cmdParam1 and cmdParam2 (the
;                                        commands of
;    $561F  queueCmd2                    Queues a 2-byte command: the code in A followed by cmdParam0 (the commands of
;                                        length 2 are the
;    $562B  sendOutgoingPacket           Builds the packet for the next lock-step exchange and hands it to the comm
;                                        module.
;    $56B1  pollMenuSession              Reached while a link is up but no game is running (commSessionState $0B9C bit
;                                        7): if the
;    $56BB  handleMenuSessionPacket      Menu-level receive path: re-arms the 10-frame link poll delay and returns
;                                        unless a
;    $56F1  completeExchange             Makes sure the packet exchange currently in flight finishes.
;    $572B  readyForNewGameHandshake     Run before a new game over an existing link: finishes any pending exchange,
;                                        puts
;    $577C  buildReadyPacket             Fills the comm module's transmit buffer with the 2-byte packet $8C,$02 (session
;                                        control,
;    $578A  recordExchangeToFilm         Appends one lock-step exchange to the game film held in the RAM under the I/O
;                                        area.
;    $57BE  writeFilmTimestamp           Writes the time stamp of one game-film record at (filmPtr),Y.
;    $57E7  advanceFilmPtr               Adds the record length in Y to the 16-bit film write pointer filmPtr ($BC/$BD),
;                                        so the next record starts where this one ended.
;    $57F2  finalizeFilm                 Closes a game film that was being recorded: appends the end-of-film marker
;                                        record ($FE = the game played itself out, $FF = it was aborted) with a time stamp if the buffer still has room, then
;                                        rewinds and fills in the header at $D000 - total film length, format id, final clock, both 16-bit scores and the
;                                        checksum over $D000-$D032 - and finally leaves filmPtr pointing at the end of the recording again.
;    $585C  setFilmPtrToEnd              Points filmPtr ($BC/$BD) at filmEndPtr ($92F1/$92F2), i.e.
;    $5867  saveFilmEndAndRewind         Remembers the current film position as the end of the recording (filmEndPtr =
;                                        filmPtr) and then rewinds filmPtr to the start of the buffer at $D000, ready for header access.
;    $5874  readFilmByte                 Reads one byte of the game film out of the 4 KB of RAM that lies underneath the
;                                        I/O area ($D000-$DFFF).
;    $5893  writeFilmByte                The write half of readFilmByte: stores A at (filmPtr),Y in the RAM under the
;                                        I/O area, with the same 'sei / kill the CIA2 NMI / $01=$34 / restore' dance.
;    $58B2  copyPageUnderIo              Copies one 256-byte page with the I/O area banked out, used to move the unit
;                                        tables in and out of the film's start-of-game snapshot.
;    $58D5  rewindFilmPtr                Sets filmPtr ($BC/$BD) to $D000, the first byte of the film / saved-game buffer
;                                        in the RAM under the I/O area.
;    $58DE  verifyFilmHeader             Sanity check on a film that has just been loaded from disk: recomputes the
;                                        checksum over header bytes $D000-$D032 and compares it with the stored one at $D033, then checks that the format id at
;                                        $D02F matches this build's gameVersionByte ($0B94).
;    $5900  computeFilmHeaderChecksum    Adds up the 51 header bytes $D000-$D032 into scratch18 ($18).
;    $5915  waitForExchangeIdle          Spins on the per-frame command exchange until the packet exchange in flight has
;                                        completed, which the exchange code signals by clearing linkTimeoutCounter ($9223); checkLinkTimeout sets bit 7 of that
;                                        counter while a packet is outstanding.
;    $591E  compareFilmPtrToEnd          16-bit unsigned compare of filmPtr against filmEndPtr.
;    $5929  writeFilmSetupSnapshot       Writes the start-of-game snapshot into the film header so that a replay can
;                                        rebuild the battlefield exactly: the 20 setup bytes $92A2-$92B5 (scores, recycler mode and position, terrain points,
;                                        drones, missiles, comcen speeds) go to header offsets $34-$47, the eight 100-byte unit arrays $F640-$F95F (column,
;                                        row, flags, type, destination column/row, waypoint column/row) are copied to $D048-$D367, and the game clock is stored
;                                        at $D030.
;    $5983  getFilmSnapshotDestAddr      Returns the constant address $D268 in A (low) and X (high): the highest
;                                        destination page of the unit snapshot inside the film header, and the page just below the start of the command stream
;                                        at $D368.
;    $5988  getFilmSnapshotSrcAddr       Returns the constant address $F860 in A (low) and X (high): the highest source
;                                        page of the unit snapshot, i.e.
;    $598D  setFilmLimitToBufferEnd      Sets filmEndPtr to $DFF0, the highest address a recording may reach.
;    $5998  initFilmStream               Prepares the film for recording once the snapshot is in place: writes the
;                                        offset of the first command record ($0368) into header bytes $D002/$D003 and sets filmPtr to $D368, the first byte
;                                        after the 800-byte unit snapshot.
;    $59B3  isCmdRingNearlyFull          Works out how much room is left in the 40-byte outgoing command ring at
;                                        $9065-$908C and reports C=1 when fewer than 5 bytes are free, which is less than the longest command plus its
;                                        arguments.
;    $59D0  finishFilmPlayback           End of a game film: reached when the replay runs past the last record, when the
;                                        record length byte is $FF or when the aborted-game end marker is read.
;    $5A14  checkLinkTimeout             Watchdog used while the game is waiting for the opponent's packet.
;    $5AD7  drawOverviewMap              Draws the whole strategic overview: the 40x40 battlefield squeezed into the
;                                        20x20 character window at screen (1,1)-(20,20), four map cells (a 2x2 block) per character.
;    $5B6C  drawOverviewQuarterLine      Puts the field markings back on top of an overview character.
;    $5BAD  buildOverviewQuadrant        Colour-aware entry of the overview cell renderer.
;    $5BD4  buildOverviewQuadrantNoColour ORs the 2x4 pixel pattern of map cell A into quadrant X of the 8-byte
;                                         character buffer at $90B8, without touching the colour.
;    $5C7C  drawMapCell                  Redraws a single battlefield cell everywhere it can currently be seen.
;    $5D1A  drawViewportCell             Draws one map cell as a 16x16 pixel tile (2x2 characters) in the 7x5 tactical
;                                        view on the right of the battlefield screen.
;    $5D7F  drawScreen1Cell              Redraws one map cell on screen 1, the comcen radar/scanner map, where the
;                                        window is 21 cells wide by 17 tall at character (3,3) and every cell is a single 8x8 glyph.
;    $5E03  clearOverviewWindow          Points the text window at the 20x20 overview panel (left column 1, top row 1,
;    $5E17  handleOwnUnitClick           Entry taken by the battlefield main loop when the player clicks on one of his
;    $5E24  runUnitOptionsMenu           Single-unit branch of handleOwnUnitClick, also entered by JMP from the group
;    $5FA6  exitUnitMenu                 Shared tail of every unit/group menu path: drops back to UI level 0 and
;                                        releases the
;    $5FB1  addMenuItem                  Appends item id X to menuItemIdList[menuItemCount++] and prints the item's name
;                                        on the
;    $5FD1  runMenuLoopWithFlag          Alternative entry for the console and setup menus: with C = 1 it behaves
;    $5FD7  runMenuLoop                  Menu selector without the unit-idle watch: patches menuIdleSnapshot+1 ($602A)
;                                        to 0 and
;    $5FDC  runMenuLoopWatchUnit         The generic vertical menu selector used by every in-game option menu.
;    $60E5  unhighlightMenuRow           Removes the highlight from the selected menu row, but only if it is currently
;    $60EA  toggleMenuRowHighlight       Flips the highlight of menu row menuFirstRow + menuSelectedItem by EORing
;    $612F  runJoinGroupMenu             The JOIN GROUP picker.
;    $61CF  runGroupOptionsMenu          Group branch of handleOwnUnitClick.
;    $633C  printGroupTitleAndName       Header of the group menu: prints GROUP (printGroupTitle) and, on the next
;    $6358  printGroupTitle              Moves the text cursor to column 4 of the info panel and prints the string GROUP
;    $6366  clearGroupHighlight          Drops the viewport's group highlight and repaints the 7x5 tactical view.
;    $636E  printSettingHeader           Header of the group editor: clears the info panel in colour 4, moves to screen
;    $6387  printFormationCentred        Prints the word FORMATION ($C9D2) centred in the current text window; also
;    $638E  runGroupEditor               Interactive editor for the group in selectedGroupKey, in MEMBERSHIP mode (zp_BB
;                                        >=
;    $64DA  sendHaltGroupCmd             Queues the 2-byte command $9B with the key of the currently selected group.
;    $64E4  printSettingHeaderForLevel   Clears the info panel in the colour that belongs to the current UI
;    $64F6  printSettingWord             Prints the word SETTING ($CB4F) centred in the current text window and ends
;    $6500  promptForDestination         runSetDestinationScreen - The SETTING / DESTINATION screen.
;    $650E  countGroupMembers            Counts the live units that belong to the group whose key is in A.
;    $652E  runRepairWhichMenu           Draws and runs the 'REPAIR WHICH:' menu of the REPAIR console tab.
;    $65A0  runInGameOptionsMenu         Runs the MISC-tab options menu while a battle is in progress.
;    $65E7  waitForGroupRepositionsDone  Before a group screen edits a group, wait until the re-position
;    $65FF  runFormationOptionsMenu      The FORMATION sub-menu of the group options.
;    $6637  soundPlayerTick              The per-frame SID sound driver.
;    $6651  updateSoundVoice             Advances one SID voice by one frame.
;    $66C7  runSoundScript               Interprets one voice's script bytecode until it sets a note duration (or ends).
;    $671C  fetchSoundScriptByte         Reads the next byte of a voice's script through the zero-page pointer at
;    $6728  startSound                   Starts sound A (0-28) on the voices its table row names.
;    $67CB  unpackRleTo0200              Run-length decoder that always unpacks into the two pages $0200-$03FF.
;    $681A  fetchRleByte                 Returns the next byte of the packed stream and advances the pointer.

        .org $0800


; ----------------------------------------------------------------------
; gameEntry - the game's front door.  The boot loader does not return to its caller: it rebuilt the
; stack so that its final RTS lands in verifyProgramChecksum ($0461), and that routine ends with JMP
; $0800.  This is also where the setup block jumps back to after a reset of the game state.
; In:  nothing; $01 = $35 (I/O visible, no ROMs), interrupts pointing at the RTI in the boot loader.
; Out: never returns - control passes to mainStart.
; Called from: boot/eaBootFile $02D5, and game/scratchAndMessages0400 $04AB (the tail of
;     verifyProgramChecksum).
; ----------------------------------------------------------------------
gameEntry:
        jmp     mainStart               ; 0800  the three bytes at $0800 are the published entry point, so the real start-up code can live anywhere


; loaderStatus - sticky error flag of the fast loader.  Bit 7 means 'the DISK ERROR message is
; currently on the status line'; the low bits keep the drive's error code from that failure ($02
; header not found, $03 no sync, $05 checksum, $08 write protected / wrong disk id).  Only loadSectors
; reads or writes it.
loaderStatus:
        .byte   $00                     ; 0803  .  $00 = no error outstanding

; ----------------------------------------------------------------------
; loadSectors - the game's own copy of the fast loader.  Reads A consecutive sectors of track Y,
; starting at sector X, into the address last given to setLoadDest, one sector per page.  Every
; overlay, the battlefield map, the setup block and the trainer's playbook arrive through here.  A
; drive error is not fatal: 'DISK ERROR.' goes up on the status line and the same sector is retried
; forever, so the player can close the drive door or put the right disk back in and the load simply
; continues.
; In:  A = number of sectors, Y = track, X = first sector, loadDestLo/loadDestHi = destination page.
; Out: the sectors in memory, loadDestHi advanced one page per sector, C=0, the caller's I flag
;     restored; ldTrack/ldSector/ldCount clobbered.
; Called from: $0B58, $0F10, $0F38, $0F41, $0F5C in this file, game/mapGenerator6F00 $7B2D and
;     game/trainerAiE000 $E0B4/$E0D6/$ECB3.
; ----------------------------------------------------------------------
loadSectors:
        php                             ; 0804  remember the caller's interrupt flag - the transfer itself is cycle counted
        sei                             ; 0805  no interrupts from here to the RTS: a raster IRQ in the middle of a sector would lose bytes
        sta     ldCount                 ; 0806  sectors still to transfer

; ----------------------------------------------------------------------
; One pass per sector: ask the drive for it, retry until it answers 'status 0', then move the
; destination on one page and step to the next sector of the same track.
; ----------------------------------------------------------------------
loadSectorsLoop:
        jsr     readOneSector           ; 0808  fetch one sector into (loadDestLo/Hi); returns C=1 and the drive's error code in A on failure
        bcc     loadSectorOk            ; 080B  branch when the drive reported no error
        ora     #$80                    ; 080D  keep the error code but set bit 7 = 'the error message is on the screen'
        sta     loaderStatus            ; 080F  remember that so the message can be taken down after a successful retry
        lda     #$A2                    ; 0812  low byte of msgDiskError ($0FA2 = 'DISK ERROR.')
        ldy     #$0F                    ; 0814  high byte of msgDiskError
        jsr     showMessageString       ; 0816  print it centred on the status line (showMessageString $C3D8)
        ldy     ldTrack                 ; 0819  reload the track of the sector that failed...
        ldx     ldSector                ; 081B  ...and its sector number
        jmp     loadSectorsLoop         ; 081D  retry the same sector forever - the loader never gives up


; ----------------------------------------------------------------------
; A sector came in cleanly.  If an earlier attempt had put DISK ERROR on the status line, take it down
; again and put the message the game wants there back.
; ----------------------------------------------------------------------
loadSectorOk:
        lda     loaderStatus            ; 0820  did any earlier attempt put DISK ERROR on the screen?
        bpl     loadSectorNext          ; 0823  no message to take down - go straight on
        and     #$7F                    ; 0825  clear the 'message showing' bit, leaving the last error code behind for a post mortem
        sta     loaderStatus            ; 0827  store the cleared flag
        lda     persistentMessageId     ; 082A  persistentMessageId = the message the status line should fall back to
        jsr     showStatusMessage       ; 082D  reprint it over the DISK ERROR text (showStatusMessage $C6FF)

; ----------------------------------------------------------------------
; Advance to the next sector of the run: one sector forward on the disk, one page forward in memory.
; ----------------------------------------------------------------------
loadSectorNext:
        inc     ldSector                ; 0830  next sector of this track
        inc     loadDestHi              ; 0832  next destination page - one sector is exactly 256 bytes
        ldx     ldSector                ; 0835  sector number for the next request
        ldy     ldTrack                 ; 0837  track number for the next request (never changes inside one call)
        dec     ldCount                 ; 0839  one sector fewer to go
        bne     loadSectorsLoop         ; 083B  loop until the whole run has been read
        plp                             ; 083D  restore the caller's interrupt flag
        clc                             ; 083E  C=0: a read can only ever end successfully, it retries instead of failing
        rts                             ; 083F  back to the caller


; ----------------------------------------------------------------------
; Failure exit of writeSectors: the drive refused a sector (write protected, wrong disk id, or it
; could not find the header).  Unlike a read this is not retried - the caller shows a message and
; gives up.
; ----------------------------------------------------------------------
writeSectorsFail:
        plp                             ; 0840  restore the caller's interrupt flag
        sec                             ; 0841  C=1 = the save failed
        rts                             ; 0842  back to the caller


; ----------------------------------------------------------------------
; writeSectors - the write half of the fast loader: sends A consecutive sectors from the address set
; by setLoadDest to track Y starting at sector X, one page per sector.  The drive encrypts each block
; with the same rolling XOR that a read decrypts with (cryptSectorBuffer $0531 in the drive code).
; This is the only path that ever sends command $60, and it is used solely to save a game film and the
; setup block to the save slots on track 18.
; In:  A = number of sectors, Y = track, X = first sector, loadDestLo/loadDestHi = source page.
; Out: C=0 when every sector went out, C=1 on the first refusal; loadDestHi advanced.
; Called from: game/mapGenerator6F00 $7E85 (save game film) and $8194 (save setup).
; ----------------------------------------------------------------------
writeSectors:
        php                             ; 0843  remember the caller's interrupt flag
        sei                             ; 0844  interrupts off for the whole transfer
        sta     ldCount                 ; 0845  sectors still to write
writeSectorsLoop:
        jsr     writeOneSector          ; 0847  write one sector; C=1 if the drive refused it
        bcs     writeSectorsFail        ; 084A  give up on the first error instead of retrying
        inc     ldSector                ; 084C  next sector of the track
        inc     loadDestHi              ; 084E  next source page
        ldx     ldSector                ; 0851  sector number for the next request
        ldy     ldTrack                 ; 0853  track number for the next request
        dec     ldCount                 ; 0855  one sector fewer to go
        bne     writeSectorsLoop        ; 0857  loop until the whole run has been written
        plp                             ; 0859  restore the caller's interrupt flag
        clc                             ; 085A  C=0 = everything was written
        rts                             ; 085B  back to the caller


; ----------------------------------------------------------------------
; setLoadDest - names the memory the next transfer works on.  It does not use a variable: it patches
; the operand bytes loadDestLo ($09A2) and loadDestHi ($09A3), which readSectorBody and writeOneSector
; then copy into the zero-page pointers the byte loops actually use.  Only the page matters in
; practice, since every transfer is a whole number of 256-byte sectors.
; In:  X = address low byte, Y = address high byte.
; Out: loadDestLo/loadDestHi patched.
; Called from: every overlay load in this file ($0B4F, $0F07, $0F1A, $0FC8, $104B, $1064, $1074,
;     $108D, $10AF, $10BF), game/mapGenerator6F00 ($7B24, $7E7D, $7E8F, $7F7B, $818B) and the trainer
;     ($E12C, $ECAA).
; ----------------------------------------------------------------------
setLoadDest:
        stx     loadDestLo              ; 085C  low byte of the destination (or source) address
        sty     loadDestHi              ; 085F  high byte; loadSectors/writeSectors increment this one as they advance page by page
        rts                             ; 0862  back to the caller


; ----------------------------------------------------------------------
; writeOneSector - writes a single sector: command $60, then the track and the sector number, then the
; 256 bytes. CIA1 timer A is stopped across the transfer, exactly as on the read path, and started
; again afterwards - on this side it is only belt and braces, since the CIA interrupts were masked at
; start-up.
; In:  Y = track, X = sector, loadDestLo/loadDestHi = the page to write.
; Out: C=1 when the drive's status byte is non-zero (through finishTransfer); ldTrack/ldSector set;
;     wrSrcPtr points at the source page.
; Called from: writeSectors $0847.
; ----------------------------------------------------------------------
writeOneSector:
        sei                             ; 0863  no interrupts - the handshake with the drive must not be broken
        sty     ldTrack                 ; 0864  keep the track where the caller's loop can find it again
        stx     ldSector                ; 0866  same for the sector number
        lda     loadDestLo              ; 0868  the page setLoadDest patched in...
        sta     wrSrcPtr                ; 086B  ...becomes the low byte of the zero-page source pointer
        lda     loadDestHi              ; 086D  high byte of the source page
        sta     wrSrcPtrHi              ; 0870  sendSectorData reads the 256 bytes through (wrSrcPtr),y
        lda     #$60                    ; 0872  $60 = the drive's 'write a sector' command (handleWriteCommand $0490 in the drive code)
        jsr     sendByteToDrive         ; 0874  send the command byte
        lda     ldTrack                 ; 0877  the track to write to
        jsr     sendByteToDrive         ; 0879  send it
        lda     ldSector                ; 087C  the sector to write to
        jsr     sendByteToDrive         ; 087E  send it
        ldx     #$00                    ; 0881  CRA bit 0 = 0...
        stx     CIA1_CRA                ; 0883  ...stops CIA1 timer A, the 60 Hz tick the KERNAL left running, so its underflow cannot disturb the exchange
        jsr     sendSectorData          ; 0886  hand over the 256 bytes and collect the status byte
        ldx     #$01                    ; 0889  CRA = $01: timer A on, continuous mode...
        stx     CIA1_CRA                ; 088B  ...restart it now that the drive is done
        rts                             ; 088E  C still holds finishTransfer's verdict


; ----------------------------------------------------------------------
; sendSectorData - pushes the 256 bytes at (wrSrcPtr) to the drive one handshaked byte at a time, then
; falls into finishTransfer to read back the status byte.  The drive receives them the slow way too
; (writeSectorToDisk $04A0), so no cycle counting is needed on this side.
; In:  wrSrcPtr/wrSrcPtrHi = the page to send.
; Out: 256 bytes sent; C=1 if the drive's status byte is non-zero.
; Called from: writeOneSector $0886.
; ----------------------------------------------------------------------
sendSectorData:
        ldy     #$00                    ; 088F  start at the first byte of the page
L_0891:
        lda     (wrSrcPtr),y            ; 0891  next byte of the sector
        jsr     sendByteToDrive         ; 0893  clock it out (the byte coming back is ignored)
        iny                             ; 0896  advance through the page
        bne     L_0891                  ; 0897  loop until Y wraps, i.e. all 256 bytes are gone
        jmp     finishTransfer          ; 0899  read the drive's verdict and return it in C


; ----------------------------------------------------------------------
; sendByteToDrive - the slow, fully handshaked, full-duplex byte exchange with the drive; the other
; end is exchangeByteWithHost ($034B) in the drive code.  Eight times over it samples the drive's bit
; off DATA IN, pulls CLK to acknowledge, puts its own bit on DATA OUT, releases CLK and waits for the
; drive to pull CLK back - one bit each way per handshake, so neither side has to be cycle accurate.
; Every command, track and sector byte goes out this way, and the status byte is collected by
; exchanging a dummy byte. Both shift registers work LSB first (LSR sendByteShift / ROR
; recvByteShift); the phase-1 note saying 'MSB first' was wrong.
; In:  A = the byte to send.
; Out: A = the byte received from the drive, C=0, X=0, sendByteShift = 0, recvByteShift = the received
;     byte.
; Called from: writeOneSector ($0874/$0879/$087E), sendSectorData ($0893), readSectorBody
;     ($0900/$0905/$090A) and finishTransfer ($0949); also from the modem driver and the trainer at
;     $EA1D/$EA29.
; CIA2 port A ($DD00) is the serial bus: bit 4 = CLK OUT, bit 5 = DATA OUT, bit 6 = CLK IN, bit 7 =
; DATA IN, and bits 0-1 are the VIC bank select, which must survive every write (the game runs in bank
; 2, so they read %01).  The C64's outputs are inverted by the bus drivers - writing a 1 pulls the
; line low - while its inputs read the true line level, so a line that somebody is pulling reads back
; as 0.  In the drive it is the other way round, which is why the two sides look mirrored.
; ----------------------------------------------------------------------
sendByteToDrive:
        sta     sendByteShift           ; 089C  the byte to send goes into the output shift register
        ldx     #$08                    ; 089F  eight bits, one per pass of the loop
L_08A1:
        bit     CIA2_PRA                ; 08A1  poll the serial port; V = bit 6 = CLK IN
        bvc     L_08A1                  ; 08A4  wait until the drive releases CLK - by then its bit is sitting on DATA
        lda     CIA2_PRA                ; 08A6  read the port so the VIC bank bits can be written back unchanged
        and     #$DF                    ; 08A9  clear bit 5: release our DATA OUT so the line shows only what the drive drives
        sta     CIA2_PRA                ; 08AB  let go of DATA
        lda     CIA2_PRA                ; 08AE  sample the port with the drive's bit on DATA IN
        cmp     #$80                    ; 08B1  C = bit 7 = the drive's data bit (line high = a 1 bit)
        ror     recvByteShift           ; 08B3  shift it in from the top; after eight rounds the first bit has reached bit 0, i.e. LSB first
        ora     #$10                    ; 08B6  set bit 4 = pull CLK low: 'I have taken your bit'
        sta     CIA2_PRA                ; 08B8  assert CLK
        and     #$BF                    ; 08BB  clear bit 6 in the copy: that is what the port reads back now that CLK is low
L_08BD:
        cmp     CIA2_PRA                ; 08BD  compare the live port against that snapshot
        beq     L_08BD                  ; 08C0  wait until it differs - the drive toggles DATA as its acknowledgement ($0364 in the drive code)
        lsr     sendByteShift           ; 08C2  shift the next outgoing bit (LSB first) into the carry
        lda     #$00                    ; 08C5  a 0 bit: leave DATA OUT clear so the line stays high
        bcc     L_08CB                  ; 08C7  branch when the bit to send is 0
        lda     #$20                    ; 08C9  a 1 bit: bit 5 = DATA OUT, which pulls the line low; the drive reads its inputs inverted and sees a 1
L_08CB:
        ora     CIA2_PRA                ; 08CB  merge with the live port so the bank bits and CLK OUT survive
        sta     CIA2_PRA                ; 08CE  put the bit on the DATA line
        and     #$EF                    ; 08D1  clear bit 4 = release CLK: 'the bit is on the line, take it'
        sta     CIA2_PRA                ; 08D3  release CLK
L_08D6:
        bit     CIA2_PRA                ; 08D6  poll the port again; V = CLK IN
        bvs     L_08D6                  ; 08D9  wait until the drive pulls CLK low as its acknowledgement ($037F in the drive code)
        eor     #$20                    ; 08DB  toggle our own DATA OUT bit - the handshake the drive waits for at $0389
        sta     CIA2_PRA                ; 08DD  write the toggled value
        dex                             ; 08E0  one bit done
        bne     L_08A1                  ; 08E1  round again until all eight bits have been exchanged
        lda     recvByteShift           ; 08E3  return whatever the drive sent back (the status byte, when that is what we came for)
        clc                             ; 08E6  C=0 - readSectorBody depends on this: its ADC #$2F must add exactly $2F
        rts                             ; 08E7  back to the caller


; sendByteShift - the outgoing shift register of sendByteToDrive; LSR pushes bit 0 out first, so it
; holds 0 when the byte has gone.
sendByteShift:
        .byte   $00                     ; 08E8  .  outgoing bit buffer

; recvByteShift - the incoming shift register of sendByteToDrive; ROR feeds bits in at the top, so
; after eight of them the first bit received sits in bit 0.  The second byte, $08EA, is never
; referenced - a pad byte.  The
; $BE the register holds in the disk image is just what the assembler left behind.
recvByteShift:
        .byte   $BE,$00                 ; 08E9  ..  incoming bit buffer; the $BE it holds in the disk image is just whatever the assembler left behind

; ----------------------------------------------------------------------
; readOneSector - reads one sector into the load destination.  It stops CIA1 timer A for the duration
; (the 60 Hz tick the KERNAL left running) so that absolutely nothing can interfere with the
; cycle-counted receive loop, and restarts it afterwards.
; In:  Y = track, X = sector, loadDestLo/loadDestHi = destination page.
; Out: 256 bytes in memory, ldTrack/ldSector set, C=1 and A = the error code when the drive reports a
;     problem.
; Called from: loadSectors $0808.
; ----------------------------------------------------------------------
readOneSector:
        sei                             ; 08EB  interrupts off - from here on the transfer is counted in cycles
        sty     ldTrack                 ; 08EC  keep the track for the retry logic in loadSectors
        stx     ldSector                ; 08EE  and the sector number
        ldx     #$00                    ; 08F0  CRA bit 0 = 0...
        stx     CIA1_CRA                ; 08F2  ...stop CIA1 timer A while the sector comes in
        jsr     readSectorBody          ; 08F5  do the actual request and receive
        ldx     #$01                    ; 08F8  CRA = $01: timer A running, continuous mode...
        stx     CIA1_CRA                ; 08FA  ...start it again
        rts                             ; 08FD  C and A carry the drive's verdict back to loadSectors


; ----------------------------------------------------------------------
; readSectorBody - the body of a sector read.  It sends command $80 with the track and sector, parks
; the sprites, works out the raster constant the receive loop uses to dodge bad lines, waits for the
; drive to announce that the sector is decoded, and then runs the fast 2-bits-per-port-read receive
; loop.
; In:  ldTrack, ldSector, loadDestLo/loadDestHi.
; Out: 256 bytes at the destination, savedSpriteEnable = the sprite enable register as it was,
;     ldCia2Bits = the VIC bank bits, the operand at $095F patched, C=1 on a drive error.
; Called from: readOneSector $08F5.
; ----------------------------------------------------------------------
readSectorBody:
        lda     #$80                    ; 08FE  $80 = the drive's 'read a sector' command (anything that is not $C0 or $60 in commandDispatchLoop $0313)
        jsr     sendByteToDrive         ; 0900  send the command byte
        lda     ldTrack                 ; 0903  the track to read
        jsr     sendByteToDrive         ; 0905  send it
        lda     ldSector                ; 0908  the sector to read
        jsr     sendByteToDrive         ; 090A  send it
        lda     VIC_SPR_ENA             ; 090D  the sprite enable register as the game left it...
        sta     savedSpriteEnable       ; 0910  ...saved so the display can be put back together afterwards
        lda     CIA2_PRA                ; 0913  read CIA2 port A...
        and     #$03                    ; 0916  ...and keep only bits 0-1, the VIC bank select (%01 = bank 2, where the bitmap and screen live)
        sta     ldCia2Bits              ; 0918  the receive loop rewrites the port from this on every strobe, so the bank must never move
        ora     #$20                    ; 091A  add bit 5 = DATA OUT: pull DATA low, which tells the drive 'not ready for a byte yet'
        sta     CIA2_PRA                ; 091C  this also releases CLK OUT and the ATN and RS-232 lines
        lda     VIC_CTRL1               ; 091F  $D011 holds the vertical fine scroll in bits 0-2...
        and     #$07                    ; 0922  ...isolate it: a bad line happens whenever (raster AND 7) equals that value
        adc     #$2F                    ; 0924  $2F = one line before the first display line ($30); C is 0 here because sendByteToDrive ended with CLC
        sta     L_095E+1                ; 0926  patch the SBC operand of the receive loop at $095F, so the loop can test 'is the next line a bad line?'
L_0929:
        bit     CIA2_PRA                ; 0929  poll the serial port; V = CLK IN
        bvs     L_0929                  ; 092C  wait until the drive pulls CLK low = 'the sector is read, decrypted and ready to go' ($044D in the drive code)
        lda     #$00                    ; 092E  no sprites at all...
        sta     VIC_SPR_ENA             ; 0930  ...so the VIC cannot steal cycles with sprite DMA while the sector comes in
        lda     loadDestLo              ; 0933  copy the patched destination address...
        sta     ldDestPtr               ; 0936  ...into the zero-page pointer the receive loop stores through
        lda     loadDestHi              ; 0938  high byte of the destination page
        sta     ldDestPtrHi             ; 093B  (ldDestPtr),y then walks the whole 256-byte sector
        jsr     receiveSectorData       ; 093D  take the 256 bytes
        jmp     finishTransfer          ; 0940  put the sprites back and read the drive's status byte


; ----------------------------------------------------------------------
; finishTransfer - shared tail of a read and a write: puts the sprite enable register back the way it
; was and collects the drive's status byte, which arrives on the ordinary handshaked path.  Status 0
; means success; anything else is a DOS error code ($02 header not found, $03 no sync, $05 data
; checksum, $08 write protected or wrong disk id).
; In:  savedSpriteEnable.
; Out: A = the status byte, C=1 when it is non-zero, sprites restored.
; Called from: readSectorBody $0940 and sendSectorData $0899, both by JMP, so its RTS returns to their
;     callers.
; ----------------------------------------------------------------------
finishTransfer:
        lda     savedSpriteEnable       ; 0943  the sprite enable register as it was before the transfer...
        sta     VIC_SPR_ENA             ; 0946  ...restored, so the cursor and marker sprites come back
        jsr     sendByteToDrive         ; 0949  exchange a dummy byte (the sprite mask happens to be in A) to collect the status byte
        beq     L_094F                  ; 094C  status 0 = no error, leave C=0
        sec                             ; 094E  C=1 tells the caller the sector failed
L_094F:
        rts                             ; 094F  A still holds the status byte


; ----------------------------------------------------------------------
; receiveSectorData - the fast half of the protocol and the only cycle-counted code in the game.  The
; drive does not handshake per bit here: it writes four port values per byte at fixed intervals
; (sendSectorToHost $044B), each carrying two data bits on CLK and DATA, and the only synchronisation
; is per byte - the host releases DATA to ask for the next one and then samples four times at fixed
; cycle offsets.  That is why sprites are off, CIA1 timer A is stopped, interrupts are disabled and
; each byte only starts on a raster line that is not immediately before a bad line. The four samples
; are folded together with LSR/EOR so that the two bits of sample 1 end up in bits 0-1 and those of
; sample 4 in bits 6-7; the VIC bank bits shift out of the bottom on their own, except in the last
; sample, which is why they are EORed out explicitly at $0984.
; In:  ldDestPtr/ldDestPtrHi = destination page, ldCia2Bits = the VIC bank bits, the SBC operand at
;     $095F.
; Out: 256 bytes stored at (ldDestPtr); the bus left with DATA pulled low.
; Called from: readSectorBody $093D.
; ----------------------------------------------------------------------
receiveSectorData:
        bit     CIA2_PRA                ; 0950  poll the serial port; V = CLK IN
        bvc     receiveSectorData       ; 0953  wait until the drive releases CLK again - it does that just before every byte ($0466 in the drive code)
        ldy     #$00                    ; 0955  index of the first byte of the sector

; ----------------------------------------------------------------------
; One pass per byte of the sector.  The pass first waits for a safe raster line, then releases DATA to
; ask for the byte and samples the port four times, two bits at a time.
; ----------------------------------------------------------------------
receiveByteLoop:
        sec                             ; 0957  SBC below must subtract exactly the patched constant
        ldx     ldCia2Bits              ; 0958  the bank bits with CLK OUT and DATA OUT clear: writing this releases both lines
        nop                             ; 095A  one cycle of padding
L_095B:
        lda     VIC_RASTER              ; 095B  where is the raster right now?
L_095E:
        sbc     #$32                    ; 095E  operand patched at $0926 with (yscroll + $2F): subtracts the top of the display window
        bcc     L_0966                  ; 0960  above the display window there are no bad lines - go straight ahead
        and     #$07                    ; 0962  how far the raster is from the next bad line
        beq     L_095B                  ; 0964  wait while the next line would be a bad line: the VIC would steal 40 cycles in the middle of the byte
L_0966:
        stx     CIA2_PRA                ; 0966  release CLK OUT and DATA OUT: 'send me the next byte' - the drive waits for exactly this at $046C
        txa                             ; 0969  take a copy of the bank bits...
        ora     #$20                    ; 096A  ...with bit 5 set, i.e. DATA OUT asserted, ready to say 'got it' after the byte
        tax                             ; 096C  keep it in X until then
        nop                             ; 096D  padding so the first sample lands on the drive's first port write
        nop                             ; 096E  padding
        bit     loaderTimingPad         ; 096F  BIT on an unused zero-page byte: three cycles of padding, nothing is tested
        lda     CIA2_PRA                ; 0971  sample 1: CLK IN and DATA IN carry data bits 0 and 1
        lsr     a                       ; 0974  shift them down two places...
        lsr     a                       ; 0975  ...so the next sample can be merged in above them (the bank bits fall off the bottom)
        nop                             ; 0976  padding to hit the drive's second write
        eor     CIA2_PRA                ; 0977  sample 2: data bits 2 and 3
        lsr     a                       ; 097A  make room for the next pair...
        lsr     a                       ; 097B  ...again dropping the bank bits of the older sample
        nop                             ; 097C  padding
        nop                             ; 097D  padding
        nop                             ; 097E  padding to hit the drive's third write
        eor     CIA2_PRA                ; 097F  sample 3: data bits 4 and 5
        lsr     a                       ; 0982  shift the four bits gathered so far down...
        lsr     a                       ; 0983  ...into their final positions
        eor     ldCia2Bits              ; 0984  cancel the bank bits of the last sample in advance - they are not shifted out any more
        eor     CIA2_PRA                ; 0986  sample 4: data bits 6 and 7; the byte is now complete
        stx     CIA2_PRA                ; 0989  pull DATA low again: 'byte taken, do not send yet'
        nop                             ; 098C  padding so the store cannot overlap the drive's last write
        sta     (ldDestPtr),y           ; 098D  put the byte in the sector buffer
        iny                             ; 098F  next byte
        bne     receiveByteLoop         ; 0990  loop until Y wraps: 256 bytes received
        rts                             ; 0992  back to readSectorBody, which reads the status byte


; strBlockExecuteLeftover - the DOS command strings 'B-E 2 0 1 17' and '#' that the boot loader
; ($C32C) sends to start the drive code, left in this copy of the loader.  The game never opens a
; KERNAL channel (the ROMs are banked out by the time $0800 runs), and nothing references these bytes:
; dead weight carried over when the loader was copied into the main program.
strBlockExecuteLeftover:
        .byte   "B-E 2 0 1 17",$00,"#",$00; 0993  'B-E 2 0 1 17' = block-execute track 1 sector 17 in the drive, then a 0 terminator, the filename '#' and another 0

; loadDestLo - operand byte of the LDA at $0868/$0933: the low byte of the transfer address, patched
; by setLoadDest.
loadDestLo:
        .byte   $00                     ; 09A2  .  low byte of the next transfer address

; loadDestHi - operand byte of the LDA at $086D/$0938: the high byte of the transfer address.
; loadSectors and writeSectors increment it after every sector, so it walks the destination page by
; page.  $09A4 is a pad byte nothing touches.
loadDestHi:
        .byte   $00,$00                 ; 09A3  ..  high byte of the next transfer address

; savedSpriteEnable - VIC_SPR_ENA as it was when a sector transfer started; the sprites are blanked so
; that sprite DMA cannot steal cycles, and this puts them back.
savedSpriteEnable:
        .byte   $00                     ; 09A5  .  sprite enable register saved across one sector transfer

; ----------------------------------------------------------------------
; manualLookupCheck - the manual look-up copy protection, and it is disabled in this build: the JSR
; that called it at $0A9B was assembled as BIT ($2C instead of $20), so it is never entered.  It picks
; one of the 32 map seeds in the table at $0401 using the boot-time random byte at $09C3, generates
; and draws that battlefield, prints 'IDENTIFY THIS MAP (SEE MANUAL)' and lets the player type the
; five-letter map name (the printable form of the 24-bit seed) three times.  A wrong answer three
; times over wipes memory downwards from $C000 and hangs the machine.  The current map seed and map
; name are saved on the stack and put back on success, so a real game is unaffected.
; In:  the answer table mapNameSeedTable ($0401), the random byte at $09C3, the player's typing
;     through enterMapName ($7717), which packs the five characters into scenarioSeed0-2 ($5A-$5C).
; Out: mapSeed and mapNameText restored, textColour/textPadColour = 0, the info panel cleared; on
;     failure it never returns.
; Called from: nobody - the only reference is the disabled JSR at $0A9B.
; ----------------------------------------------------------------------
manualLookupCheck:
        lda     mapSeed                 ; 09A6  byte 0 of the map seed of the game in progress...
        pha                             ; 09A9  ...onto the stack
        lda     mapSeed1                ; 09AA  byte 1 of the map seed
        pha                             ; 09AD  saved
        lda     mapSeed2                ; 09AE  byte 2 of the map seed
        pha                             ; 09B1  saved
        ldy     #$00                    ; 09B2  start at the first character of the map name
L_09B4:
        lda     mapNameText,y           ; 09B4  the printable map name plus its terminator ($0BA9-$0BAE)
        pha                             ; 09B7  push all six bytes; they come back off in reverse at $0A2B
        iny                             ; 09B8  next character
        cpy     #$06                    ; 09B9  five characters and the terminator byte
        bcc     L_09B4                  ; 09BB  loop until all six are on the stack
        lda     #$03                    ; 09BD  three attempts...
        sta     manualCheckTries        ; 09BF  ...counted down in manualCheckTries
        lda     #$00                    ; 09C2  the operand byte $09C3 is the random seed the boot loader took from the SID noise generator ($C215) and verifyProgramChecksum preserves ($0461)
        and     #$1F                    ; 09C4  32 questions in the manual, so keep 5 bits
        sta     scratch18               ; 09C6  hold the question number
        asl     a                       ; 09C8  x2...
        adc     scratch18               ; 09C9  ...plus one more = x3, the stride of the 3-byte answer table
        tay                             ; 09CB  index the table with it
        lda     mapNameSeedTable,y      ; 09CC  byte 0 of that map's seed  (also trainerPlaybook0200:play3Col13)
        sta     mapSeed                 ; 09CF  make it the current map seed
        iny                             ; 09D2  next byte of the entry
        lda     mapNameSeedTable,y      ; 09D3  byte 1  (also trainerPlaybook0200:play3Col13)
        sta     mapSeed1                ; 09D6  stored
        iny                             ; 09D9  last byte of the entry
        lda     mapNameSeedTable,y      ; 09DA  byte 2  (also trainerPlaybook0200:play3Col13)
        sta     mapSeed2                ; 09DD  stored
        jsr     generateBattlefieldMap  ; 09E0  build the 40x40 battlefield from that seed (map generator overlay, $6F04)
        jsr     drawOverviewMap         ; 09E3  draw it in the overview window so the player can compare it with the manual
        lda     #$02                    ; 09E6  colour byte $02 for the prompt cells: with the inverted font the strokes come out red on black
        sta     textColour              ; 09E8  colour of the printed characters
        sta     textPadColour           ; 09EA  colour of the blanks that pad a centred line
        jsr     clearInfoPanel          ; 09EC  clear the info panel to that colour
        lda     #$47                    ; 09EF  low byte of the prompt - $0A47, one character before msgIdentifyMap, so the text starts with an extra space
        ldy     #$0A                    ; 09F1  high byte of the prompt
        jsr     printString             ; 09F3  print 'IDENTIFY THIS MAP (SEE MANUAL)' (it contains two $0D line breaks)
L_09F6:
        jsr     clearMapNameField       ; 09F6  blank the five-character name field ($7836 in the overlay)
        jsr     missileLineFromComcen   ; 09F9  let the player type a map name; it is packed 5 bits per letter into scenarioSeed0-2  (also mapGenerator6F00:enterMapName)
        lda     scenarioSeed0           ; 09FC  byte 0 of what was typed
        cmp     mapSeed                 ; 09FE  against byte 0 of the seed the map was built from
        bne     L_0A11                  ; 0A01  wrong - no need to look further
        lda     scenarioSeed1           ; 0A03  byte 1 typed
        cmp     mapSeed1                ; 0A05  against byte 1 of the seed
        bne     L_0A11                  ; 0A08  wrong
        lda     scenarioSeed2           ; 0A0A  byte 2 typed
        cmp     mapSeed2                ; 0A0C  against byte 2 of the seed
        beq     L_0A29                  ; 0A0F  all three match: the player owns the manual
L_0A11:
        lda     #$03                    ; 0A11  sound 3 = the error beep
        jsr     playSound               ; 0A13  play it
        dec     manualCheckTries        ; 0A16  one attempt gone
        bne     L_09F6                  ; 0A19  ask again while attempts are left
        ldy     #$00                    ; 0A1B  index 0...
        tya                             ; 0A1D  ...and A = 0: the byte written everywhere

; ----------------------------------------------------------------------
; Three wrong answers: erase the machine.  The loop zeroes one page at a time, walking downwards from
; $C000 by decrementing the high byte of its own STA operand, and stops when that byte reaches 0.
; Long before then it wipes page $0A - its own code - so the CPU runs into zeroed memory and the
; machine has to be switched off. The tables above $C0FF survive, which is of no use to anybody.
; ----------------------------------------------------------------------
wipeMemoryLoop:
        sta     bootEntry,y             ; 0A1E  zero one byte; the operand high byte at $0A20 is what makes this walk down memory  (also textEngineC000:bitPairHighTable)
        iny                             ; 0A21  next byte of the page
        bne     wipeMemoryLoop          ; 0A22  256 bytes per pass
        dec     wipeMemoryLoop+2        ; 0A24  step the STA operand down to the previous page
        bne     wipeMemoryLoop          ; 0A27  keep going until the operand high byte wraps to 0 - by then this code is gone too
L_0A29:
        ldy     #$05                    ; 0A29  restore the six saved map-name bytes, last pushed first
L_0A2B:
        pla                             ; 0A2B  pull one byte
        sta     mapNameText,y           ; 0A2C  back into the map name
        dey                             ; 0A2F  work backwards
        bpl     L_0A2B                  ; 0A30  until all six are back
        pla                             ; 0A32  byte 2 of the saved map seed
        sta     mapSeed2                ; 0A33  restored
        pla                             ; 0A36  byte 1
        sta     mapSeed1                ; 0A37  restored
        pla                             ; 0A3A  byte 0
        sta     mapSeed                 ; 0A3B  restored - the game in progress never notices the detour
        lda     #$00                    ; 0A3E  back to colour 0...
        sta     textColour              ; 0A40  ...for printed characters
        sta     textPadColour           ; 0A42  ...and for the padding
        jmp     clearInfoPanel          ; 0A44  clear the info panel again and return through it


; padA47: unknown, 1 bytes. single $20 byte before msgIdentifyMap
msgIdentifyMapLeadSpace:
        .byte   $20                     ; 0A47     a plain space: printString is called with $0A47, so this is really the first character of the prompt below

; msgIdentifyMap: text, 36 bytes. copy-protection prompt
msgIdentifyMap:
        .byte   "  IDENTIFY",$0D,"   THIS MAP",$0D," (SEE MANUAL",')'|$80; 0A48  'IDENTIFY THIS MAP (SEE MANUAL)' - the $0D bytes are line breaks and the ')' carries bit 7 as the terminator

; ----------------------------------------------------------------------
; mainStart - the cold start of the game, run once.  It silences the CIAs, parks both hardware vectors
; on an RTI, pulls in the runtime overlays (text engine, setup overlay, message strings and the $E000
; opponent module), loads the saved setup block and runs the little start-up routine at $0300 that
; came with it, installs the raster IRQ, paints the empty battlefield screen and drops into the main
; menu.  The manual look-up check that used to run here is disabled in this build.
; In:  nothing but the memory image the boot loader left behind.
; Out: everything initialised: overlays resident, IRQ running, currentScreen = 0, playerSide = $FE (no
;     side chosen); falls straight into returnToMainMenu.
; Called from: gameEntry $0800.
; ----------------------------------------------------------------------
mainStart:
        sei                             ; 0A6C  no interrupts until the vectors are safe
        jsr     disableCiaInterrupts    ; 0A6D  mask and acknowledge both CIAs so no timer or serial interrupt can arrive
        lda     #$98                    ; 0A70  low byte of irqRti ($1298)...
        sta     nmiVector               ; 0A72  ...into the NMI vector - the ROMs are banked out, so these RAM vectors are the real ones
        sta     irqVector               ; 0A75  and into the IRQ vector, until installRasterIrq puts the real handler there
        lda     #$12                    ; 0A78  high byte of irqRti
        sta     nmiVectorHi             ; 0A7A  NMI vector high byte
        sta     irqVectorHi             ; 0A7D  IRQ vector high byte - any interrupt now just RTIs
        cli                             ; 0A80  interrupts on again; nothing can happen except an RTI
        jsr     loadStartupOverlays     ; 0A81  pull in $C000 (text engine), the $6F00 setup overlay, the message strings and the $E000 opponent module
        jsr     loadSetupBlock          ; 0A84  load the saved setup block to $0200-$03FF (track 18 sectors 15-16)
        jsr     basicVectorHijack       ; 0A87  run initGameSystems ($0300), the start-up code that came inside that block: RNG seed, fonts, map row tables, bitmap screen  (also tileRules0200:initGameSystems, trainerPlaybook0200:play1Row06)
        jsr     initIrqAndInputState    ; 0A8A  install the raster IRQ and clear the input state
        jsr     initInterruptVectors    ; 0A8D  point the NMI vector and the comm module's copy of it at the RTI, and arm the BRK exit
        lda     #$00                    ; 0A90  screen 0...
        sta     currentScreen           ; 0A92  ...is the battlefield map screen
        jsr     clearScreenAndRedrawGameScreen; 0A95  clear the bitmap and draw the empty game screen
        jsr     clearOverviewWindow     ; 0A98  blank the 20x20 overview window in it
        bit     manualLookupCheck       ; 0A9B  assembled as BIT, not JSR: the manual look-up copy protection is switched off in this build
        lda     #$FE                    ; 0A9E  $FE = no side chosen yet...
        sta     playerSide              ; 0AA0  ...the setup screens or the link decide who plays side 0 and who side 1

; ----------------------------------------------------------------------
; returnToMainMenu - the panic exit back to the main menu, used whenever the game has to abandon
; whatever it was doing: the end of a game, a lost link, the SHIFT + C= + F7 abort in the raster IRQ,
; and the fall-through from mainStart.  It cannot return to its caller, so it throws the stack away
; and rebuilds it.
; In:  nothing.
; Out: VIC interrupts and all sprites off, stack pointer = $FD, input state reset, message queue
;     empty, diskLoadDepth = 0, currentScreen = 3 (the menu screen); continues into the menu flow at
;     $1A86.
; Called from: $126B (the abort hot key in the raster IRQ), $5A3F, game/textEngineC000 $C876,
;     game/modemDriverE000 $EE0C, and by falling out of mainStart.
; ----------------------------------------------------------------------
returnToMainMenu:
        lda     #$00                    ; 0AA3  0 into both VIC registers below
        sta     VIC_IRQ_MASK            ; 0AA5  no more raster interrupts while the screen is rebuilt
        sta     VIC_SPR_ENA             ; 0AA8  all eight sprites off - the cursor and marker sprites belong to the game screens
        cli                             ; 0AAB  allow interrupts again (the handler is still installed)
        ldx     #$FD                    ; 0AAC  throw away whatever return addresses the abandoned code left...
        txs                             ; 0AAE  ...by resetting the stack pointer to $FD, which is where the game keeps it
        jsr     initBitmapScreen        ; 0AAF  clear the bitmap, screen and colour RAM to grey and set up the display registers
        jsr     initIrqAndInputState    ; 0AB2  silence the sound, re-install the raster IRQ, lock the input out for 158 frames
        jsr     resetMessageQueue       ; 0AB5  drop every pending status message
        lda     #$00                    ; 0AB8  no disk load is in progress...
        sta     diskLoadDepth           ; 0ABA  ...so the IRQ may pass keypresses to the game again
        lda     #$03                    ; 0ABD  screen 3...
        sta     currentScreen           ; 0ABF  ...is the main menu / console screen
        jsr     showMainMenu            ; 0AC2  draw the title, credits and menu frame
        jmp     L_1A86                  ; 0AC5  into the menu's link-status flow, which ends up in mainMenuLoop


; ----------------------------------------------------------------------
; startGameFromSetup - the hand-over from the setup screens to the battle, entered by JMP from the
; setup overlay ($7E3F) once both sides have agreed on a game.  It resets the game state, makes sure
; the game disk is in the drive, swaps the $E000 opponent module if the player changed between modem
; and solo trainer, asks for ANSWER or ORIGINATE when a modem game is starting, loads the battlefield
; map and the setup block, negotiates the sides and the map over the link, brings overlay B in and
; finally jumps into the battlefield loop.
; In:  setupPlayerSide, filmPlaybackMode, linkSessionState, isSoloTrainer / prevSoloTrainerFlag, the
;     resident build id $E030, overlayVariantFlag $87FF.
; Out: the battle running - never returns; jumps to runMapMainLoop ($1FD0).
; Called from: game/mapGenerator6F00 $7E3F (JMP).
; ----------------------------------------------------------------------
startGameFromSetup:
        lda     #$00                    ; 0AC8  0 into VIC_IRQ_MASK below
        sta     VIC_IRQ_MASK            ; 0ACA  no raster interrupts while the game state is torn down and rebuilt
        lda     setupPlayerSide         ; 0ACD  the side chosen in the setup screens...
        sta     playerSide              ; 0AD0  ...becomes the side this machine plays (0 owns units 0-49, 1 owns units 50-99)
        jsr     resetGameVariables      ; 0AD3  clear the zero page, the variable block and the message queue
        jsr     initIrqAndInputState    ; 0AD6  sound off, raster IRQ back on, input locked out for a moment
        jsr     patchUnitSideBranches   ; 0AD9  write the BCC/BCS opcode that decides 'is this unit mine?' into the two map renderers
        jsr     waitForGameDiskInserted ; 0ADC  block until the game disk is in the drive (its BAM says OZ)
        lda     filmPlaybackMode        ; 0ADF  watching a game film?
        bmi     L_0AFF                  ; 0AE2  then there is no opponent module to arrange
        lda     isSoloTrainer           ; 0AE4  the opponent the player picked: bit 7 set = solo trainer, clear = modem
        sta     soloTrainerAtGameStart  ; 0AE7  remembered for the whole game (the flag itself is overwritten below)
        eor     commBuildId             ; 0AEA  compare it with the build id of the module actually resident ($FF trainer, $00 modem)  (also trainerAiE000:commBuildId)
        and     #$80                    ; 0AED  only bit 7 says which build it is
        beq     L_0AF7                  ; 0AEF  same kind of opponent as last time - the module can stay
        inc     diskLoadDepth           ; 0AF1  mark a disk load in progress so the IRQ swallows keypresses
        jsr     loadCommModule          ; 0AF4  load the other $E000 module (track 35 for the trainer, track 18 s7 + track 34 for the modem)
L_0AF7:
        lda     linkSessionState        ; 0AF7  is a link session already running?
        bmi     L_0AFF                  ; 0AFA  yes - do not re-initialise the module underneath it
        jsr     initCommModule          ; 0AFC  cold-initialise the module and point its keyboard vector at scanKeyboard
L_0AFF:
        lda     linkSessionState        ; 0AFF  link already up?
        bmi     L_0B2C                  ; 0B02  then the modem has long since been set up
        lda     filmPlaybackMode        ; 0B04  watching a film?
        bmi     L_0B2C                  ; 0B07  then no modem prompt either
        ldy     prevSoloTrainerFlag     ; 0B09  what kind of opponent the previous game had
        lda     isSoloTrainer           ; 0B0C  and what this one has
        sta     prevSoloTrainerFlag     ; 0B0F  remember it for next time
        bmi     L_0B2C                  ; 0B12  solo trainer now: nothing to dial
        tya                             ; 0B14  the kind of opponent the previous game had, back into A
        bpl     L_0B2C                  ; 0B15  the previous game was a modem game too: the modem is already set

; ----------------------------------------------------------------------
; Switching from the solo trainer to a modem game: put the two-line prompt up and wait for A or O.
; Message slots $20 and $21 are the two run-time slots whose text pointers setMessageSlot patches.
; ----------------------------------------------------------------------
        ldx     #$20                    ; 0B17  message slot $20...
        lda     #$00                    ; 0B19  ...low byte of msgGetOpponentOnPhoneAnd ($8800 = 'GET OPPONENT ON PHONE AND...')
        ldy     #$88                    ; 0B1B  ...high byte
        jsr     setMessageSlot          ; 0B1D  patch slot $20 to point at it
        ldx     #$21                    ; 0B20  message slot $21...
        lda     #$1C                    ; 0B22  ...low byte of msgPressAOrOAndSetModem ($881C = 'PRESS A OR O AND SET MODEM.')
        ldy     #$88                    ; 0B24  ...high byte
        jsr     setMessageSlot          ; 0B26  patch slot $21
        jsr     waitForAnswerOriginateKey; 0B29  show them and wait until the player presses A (answer) or O (originate)
L_0B2C:
        lda     commBuildId             ; 0B2C  the build id of the module that is really resident...  (also trainerAiE000:commBuildId)
        sta     isSoloTrainer           ; 0B2F  ...written back over the wanted flag, so $0BA5 always describes what is in memory ($FF trainer / $00 modem)
        inc     diskLoadDepth           ; 0B32  disk load starting: swallow keypresses
        jsr     loadUnitTemplatesAndSetup; 0B35  load the unit start-position templates to $F000 and the setup block to $0200
        lda     #$00                    ; 0B38  0 into diskLoadDepth
        sta     diskLoadDepth           ; 0B3A  the disk is done with for now
        jsr     resumeCommAfterDiskAccess; 0B3D  let the comm module run again and clear the loading message
        jsr     startGameSession        ; 0B40  agree the sides and the map with the opponent and set up all the battle state
        inc     diskLoadDepth           ; 0B43  one more disk load coming
        lda     isSoloTrainer           ; 0B46  solo trainer?
        bpl     L_0B5B                  ; 0B49  no - the modem build's $EC00 tail was loaded with the module itself
        ldx     #$00                    ; 0B4B  destination low byte $00...
        ldy     #$EC                    ; 0B4D  ...high byte $EC: the trainer tail loads at $EC00

; ----------------------------------------------------------------------
; loadCommTailBuild1 - not a routine of its own: the tail of startGameFromSetup, entered with X/Y
; already set at $0B4B.  It pulls the solo trainer's $EC00-$EFFF sub-overlay (the code that picks the
; opening play) off track 35 sectors 12-15.  The modem build's tail comes from track 34 sectors 12-15
; through loadCommTailBuild2 ($1070) instead.
; In:  X = $00, Y = $EC (the destination page).
; Out: $EC00-$EFFF loaded.
; Called from: falls in from $0B4D.
; ----------------------------------------------------------------------
loadCommTailBuild1:
        jsr     setLoadDest             ; 0B4F  destination $EC00
        ldy     #$23                    ; 0B52  track 35 = the solo trainer's track
        ldx     #$0C                    ; 0B54  first sector 12
        lda     #$04                    ; 0B56  four sectors = $EC00-$EFFF
        jsr     loadSectors             ; 0B58  read them
L_0B5B:
        lda     #$00                    ; 0B5B  0 into gamePhase
        sta     gamePhase               ; 0B5D  gamePhase 0 means the battle itself is running (non-zero = setup, film or menu)
        jsr     loadOverlayBIfNotResident; 0B5F  bring the comcen missile/drone screens into $6F00 if the setup overlay is still there
        lda     #$00                    ; 0B62  0 into the two bytes below
        sta     diskLoadDepth           ; 0B64  no disk load in progress: the IRQ may deliver keys again
        sta     manualCheckTries        ; 0B67  the manual-check attempt counter is not needed during a game
        jsr     resetMessageQueue       ; 0B6A  empty the message queue before the battle messages start
        lda     filmPlaybackMode        ; 0B6D  watching a film?
        bmi     L_0B77                  ; 0B70  then do not announce the battle
        lda     #$1C                    ; 0B72  message $1C = 'THE BATTLE BEGINS!'
        jsr     queueMessage            ; 0B74  queue it
L_0B77:
        jsr     buildOwnerNameString    ; 0B77  build the '<us> <them>' name line shown on the menu panel
        jmp     runMapMainLoop          ; 0B7A  into the battlefield screen's main loop - this never returns


; inputLockoutTimer: byteTable, 2 bytes. $0B7D lockout timer, $0B7E settings byte
inputLockoutTimer:
        .byte   $00                     ; 0B7D  frames during which the IRQ ignores the keyboard and joystick and keeps the previous state
programChecksum:
        .byte   $DB                     ; 0B7E  checksum of $0402-$87FF computed by verifyProgramChecksum and exchanged with the opponent so both sides can detect a different build

; ownerNameString: text, 21 bytes. owner name string built at run time
ownerNameString:
        .byte   $A0,$A0                 ; 0B7F  21-byte owner/player name built at run time from ownPlayerName and opponentPlayerName, terminated with bit 7 set
        .byte   $A0,$A0,$A0,$A0,$A0,$A0 ; 0B81  more of the 21 blanks the owner-name line starts life as
        .byte   $A0,$A0                 ; 0B87  more blanks of the owner-name line
        .byte   $A0                     ; 0B89  more blanks of the owner-name line
        .byte   $A0,$A0,$A0,$A0,$A0,$A0,$A0; 0B8A  .......
        .byte   $A0                     ; 0B91  the last blanks before the terminator position
        .byte   $A0                     ; 0B92  .
ownerNameEnd:
        .byte   $A0                     ; 0B93  last byte of ownerNameString (the bit-7 terminator position)

; $0B94-$0BB4 - the game settings block: everything that describes the game being played and has to
; match on both machines. gameVersionByte and programChecksum are exchanged in the version handshake,
; mapSeed and mapFlipFlag pick the battlefield, gameTypeOptions the rules, playerSide and
; playsFirstFlag the sides, and the two 6-byte text fields at mapNameText and mapNameBackup hold the
; printable map name. The film header carries a copy of most of it.
gameVersionByte:
        .byte   $00                     ; 0B94  game/film format id: written to film header offset $2F and verified on load, and sent in the link version handshake
mapSeed:
        .byte   $00                     ; 0B95  24-bit seed of the current battlefield ($0B95-$0B97) = the 5-letter map name packed 5 bits per letter; sent with command $8B/$A6
mapSeed1:
        .byte   $00                     ; 0B96  byte 1 of mapSeed
mapSeed2:
        .byte   $00                     ; 0B97  byte 2 of mapSeed
prevMapSeed:
        .byte   $00                     ; 0B98  3-byte copy of the previous game's mapSeed; if it is unchanged the saved map name is restored, otherwise the first-play flag is reset
prevMapSeed1:
        .byte   $00                     ; 0B99  byte 1 of prevMapSeed
prevMapSeed2:
        .byte   $00                     ; 0B9A  byte 2 of prevMapSeed
filmPlaybackMode:
        .byte   $FF                     ; 0B9B  bit 7 = a game film is being played back (commands come from the film, player input is locked out); bits 0-1 = the replay viewpoint 0 US, 1 THEM, 2 BOTH
linkSessionState:
        .byte   $00                     ; 0B9C  link session: 0 = none, 2 = game session agreed, $80 = link up at menu level, $FF = link lost
mapFlipFlag:
        .byte   $00                     ; 0B9D  0/1: the chosen map is shown rotated 180 degrees (F1 TO FLIP); XORed with playerSide to decide the orientation and sent as command $A6 instead of $8B
prevMapFlipFlag:
        .byte   $00                     ; 0B9E  mapFlipFlag of the previous game; a change forces the first-play flag back to 1
playerSide:
        .byte   $FE                     ; 0B9F  side this machine plays, 0 or 1 ($FE = not chosen yet); EOR 1 gives the opponent, and units 0-49 belong to side 0, 50-99 to side 1
setupPlayerSide:
        .byte   $FE                     ; 0BA0  side chosen in the setup screens, copied into playerSide when the game starts
playsFirstFlag:
        .byte   $00                     ; 0BA1  non-zero = this side has 1ST PLAY; it alternates every game and is forced to 1 when the map or the options change
savedPlaysFirstFlag:
        .byte   $00                     ; 0BA2  copy of playsFirstFlag taken at game start and restored after a film playback
gameTypeOptions:
        .byte   $07                     ; 0BA3  bits 0-2 game type (0 SCRIMAGE .. 6 DEFENDER, names at $CCC8), bit 3 UNIT MENUS, bit 4 DAMAGE, bit 6 CUSTOM rules, bit 7 custom map
prevGameTypeOptions:
        .byte   $FF                     ; 0BA4  gameTypeOptions of the previous game; a difference forces the first-play flag back to 1
isSoloTrainer:
        .byte   $FF                     ; 0BA5  bit 7 set = PRACTICE WITH SOLO TRAINER (the track 35 computer opponent is behind the $E000 jump table); clear = a modem game (track 34)
prevSoloTrainerFlag:
        .byte   $FF                     ; 0BA6  previous value of isSoloTrainer; a change from set to clear triggers the GET OPPONENT ON PHONE prompt
diskLoadDepth:
        .byte   $01                     ; 0BA7  nesting counter bumped around every disk load and overlay swap; while it is non-zero the IRQ throws keypresses away
gameEndReason:
        .byte   $01                     ; 0BA8  0 while a game runs; 1 = aborted / no game, $08 = time up, $A0/$C0 = the comcen of side 0/1 was knocked out
mapNameText:
        .byte   $A0                     ; 0BA9  5-character map name ($0BA9-$0BAD) from the 32-letter alphabet at $890C - the printable form of mapSeed
        .byte   $A0                     ; 0BAA  characters 2-5 of the printable map name
        .byte   $A0,$A0,$A0             ; 0BAB  ...
mapNameTerminator:
        .byte   $A0                     ; 0BAE  byte after mapNameText: $8D while it is printed, '!' | $80 when the name is hidden from the opponent
mapNameBackup:
        .byte   $A0                     ; 0BAF  6-byte copy of mapNameText and its terminator, kept with prevMapSeed and restored when the same map is used again
        .byte   $A0                     ; 0BB0  the rest of the saved copy of the map name and its terminator
        .byte   $A0,$A0,$A0,$A0         ; 0BB1  ....

; ----------------------------------------------------------------------
; initIrqAndInputState - brings the interrupt and input state back to a known point.  Called at cold
; start, on every return to the main menu and at the start of a game.  It silences the sound, installs
; the raster IRQ, re-arms the one-shot 'SPOTTED RECYCLER' check, blanks the chat line and locks the
; keyboard and joystick out for 158 frames so that the keypress or click that got here is not read
; again by the screen the player is about to land in.
; In:  nothing.
; Out: raster IRQ installed, inputLockoutTimer = $9E, chat line blank, gameEndCountdown /
;     shiftHeldFlag / comcenStunStatus = 0, requestedScreenKey = $FF, the NOP restored at $3F57.
; Called from: mainStart $0A8A, returnToMainMenu $0AB2 and startGameFromSetup $0AD6.
; ----------------------------------------------------------------------
initIrqAndInputState:
        jsr     stopAllSound            ; 0BB5  silence all four sound slots
        jsr     installRasterIrq        ; 0BB8  point $FFFE at rasterIrqHandler and enable the raster interrupt
        lda     #$EA                    ; 0BBB  $EA = NOP...
        sta     checkEnemyRecyclerSpotted; 0BBD  ...restored over the first byte of checkEnemyRecyclerSpotted, which patches itself to RTS ($60) once it has fired, so the 'SPOTTED RECYCLER!' message can happen again in the next game
        lda     #$9E                    ; 0BC0  $9E is used twice below: as a frame count and as a string terminator
        sta     inputLockoutTimer       ; 0BC2  158 frames (about 2.6 seconds) during which the IRQ ignores the keyboard and the joystick
        sta     D_0549                  ; 0BC5  and as the second byte of the chat line, where $9E is a bit-7 terminator that prints nothing - i.e. blank the incoming chat line
        lda     #$00                    ; 0BC8  0 into the three flags below
        sta     gameEndCountdown        ; 0BCA  the end-of-game countdown is not running
        sta     shiftHeldFlag           ; 0BCD  no SHIFT held as far as the game is concerned
        sta     comcenStunStatus        ; 0BD0  the comcen is not stunned
        lda     #$FF                    ; 0BD3  $FF = no key
        sta     requestedScreenKey      ; 0BD5  forget any pending F-key screen request
        rts                             ; 0BD7  back to the caller


; ----------------------------------------------------------------------
; disableCiaInterrupts - masks every interrupt source in both CIAs and acknowledges whatever was
; already pending.  Writing $7F to an interrupt control register clears all five mask bits (bit 7 = 0
; means 'clear the bits I name'), and reading the register clears the latched flags.
; In:  nothing.
; Out: CIA1 and CIA2 will not raise IRQ or NMI; their pending flags cleared.
; Called from: mainStart $0A6D and loadCommModule $0F22 (before the trainer build is read in).
; ----------------------------------------------------------------------
disableCiaInterrupts:
        lda     #$7F                    ; 0BD8  $7F = clear all five interrupt mask bits (bit 7 clear = 'clear the bits set below')
        sta     CIA1_ICR                ; 0BDA  CIA1 raises the IRQ line - no more timer or keyboard interrupts
        sta     CIA2_ICR                ; 0BDD  CIA2 raises the NMI line - no more serial or timer NMIs
        lda     CIA1_ICR                ; 0BE0  reading the register clears any interrupt already latched in CIA1
        lda     CIA2_ICR                ; 0BE3  and in CIA2
        rts                             ; 0BE6  back to the caller


; ----------------------------------------------------------------------
; initInterruptVectors - finishes the interrupt set-up: it points the BRK escape of the raster handler
; at the normal IRQ exit and parks both the hardware NMI vector and the comm module's private copy of
; it on the bare RTI at $1298.  The comm module later replaces its own copy with the modem's NMI
; routine when the UART is running.
; In:  nothing.
; Out: the JMP operand at $10FF/$1100 = irqExit ($1293); $E031/$E032 and $FFFA/$FFFB = irqRti ($1298).
; Called from: mainStart $0A8D.
; ----------------------------------------------------------------------
initInterruptVectors:
        lda     #$93                    ; 0BE7  low byte of irqExit ($1293)
        sta     L_10FE+1                ; 0BE9  operand of the JMP at $10FE, which rasterIrqHandler takes when the pushed status byte says BRK - so a stray BRK just restores the registers and RTIs
        lda     #$12                    ; 0BEC  high byte of irqExit
        sta     L_10FE+2                ; 0BEE  second operand byte of that JMP
        lda     #$98                    ; 0BF1  low byte of irqRti ($1298)
        sta     nmiChainVector          ; 0BF3  the comm module's copy of the NMI vector, which its own handler chains through  (also trainerAiE000:nmiChainVector)
        sta     nmiVector               ; 0BF6  and the hardware NMI vector itself
        lda     #$12                    ; 0BF9  high byte of irqRti
        sta     nmiChainVectorHi        ; 0BFB  comm module copy, high byte  (also trainerAiE000:nmiChainVectorHi)
        sta     nmiVectorHi             ; 0BFE  hardware NMI vector, high byte - a RESTORE keypress now does nothing
        rts                             ; 0C01  back to the caller


; ----------------------------------------------------------------------
; setNmiVectorRti - points the hardware NMI vector at the bare RTI at $1298.  The solo trainer never
; uses an NMI, so loadCommModule jumps here after reading the trainer build in, to undo whatever the
; modem build had left in the vector.
; In:  nothing.
; Out: $FFFA/$FFFB = $1298.
; Called from: loadCommModule $0F2E (JMP).
; ----------------------------------------------------------------------
setNmiVectorRti:
        lda     #$98                    ; 0C02  low byte of irqRti
        sta     nmiVector               ; 0C04  NMI vector low
        lda     #$12                    ; 0C07  high byte of irqRti
        sta     nmiVectorHi             ; 0C09  NMI vector high - an NMI now returns immediately
L_0C0C:
        rts                             ; 0C0C  shared RTS, also used by jumpToCd00IfPatched below


; ----------------------------------------------------------------------
; jumpToCd00IfPatched - dead code.  It looks at $CD00 and, if it finds a JMP opcode there, turns the
; VIC interrupt off and jumps into it.  Nothing calls it, and at run time $CD00 is unitPictureHiTable
; inside the $C000 overlay, i.e. ordinary data, so the test would practically never pass.  It reads
; like a development hook for jumping into a monitor or a test routine loaded over the overlay.
; In:  $CD00.
; Out: normally just an RTS; if the byte were $4C it would disable the raster interrupt and jump
;     there.
; Called from: nothing in any traced code.
; ----------------------------------------------------------------------
jumpToCd00IfPatched:
        lda     unitPictureHiTable      ; 0C0D  first byte of the $C000 overlay's unit-picture table
        cmp     #$4C                    ; 0C10  $4C = JMP absolute: has somebody planted a jump there?
        bne     L_0C0C                  ; 0C12  no - return through the RTS at $0C0C
        lda     #$00                    ; 0C14  0 into VIC_IRQ_MASK
        sta     VIC_IRQ_MASK            ; 0C16  no raster interrupts, so the planted code owns the machine
        jmp     unitPictureHiTable      ; 0C19  and run it


; ----------------------------------------------------------------------
; resetGameVariables - wipes the game's variables back to a cold state: zero page $15-$7F and $8C-$FF,
; the whole variable block $9008-$92FF, the eight-entry message queue and the last-key bytes.  It
; deliberately leaves $00-$14 (the 6510 port and the loader's own scratch) and $80-$8B alone, and
; starts the variable block at $9008 so the VIC shadow registers at $9000-$9007 survive.  Clearing the
; RNG state $57-$59 is intentional too: stepRandomSeed re-seeds it from the joystick as soon as it
; sees all three bytes zero.
; In:  nothing.
; Out: zero page and $9008-$92FF cleared, messageQueue and lastTypedKey = $FF, newKeyEvent = $FF,
;     gamePhase = $FF, filmPtr rewound to $D000, currentScreen = 3, the chat line blanked.
; Called from: startGameFromSetup $0AD3 and the setup block's initGameSystems ($0300).
; ----------------------------------------------------------------------
resetGameVariables:
        ldy     #$15                    ; 0C1C  start at $0015 - $00-$14 belong to the 6510 port and the loader
        lda     #$00                    ; 0C1E  the value written everywhere
L_0C20:
        sta     a:CPU_DDR,y             ; 0C20  absolute,Y so it can reach the whole of page zero
        iny                             ; 0C23  next byte
        bpl     L_0C20                  ; 0C24  stop at $80, i.e. clear $15-$7F
        ldy     #$8C                    ; 0C26  second run: $8C upwards ($80-$8B are left alone)
L_0C28:
        sta     a:CPU_DDR,y             ; 0C28  clear it
        iny                             ; 0C2B  next byte
        bne     L_0C28                  ; 0C2C  until Y wraps at the end of page zero
        lda     #$08                    ; 0C2E  $9008 is the first variable after the VIC shadow registers...
        sta     mapCellPtr              ; 0C30  ...low byte of the fill pointer
        lda     #$90                    ; 0C32  high byte...
        sta     mapCellPtrHi            ; 0C34  ...so the fill starts at $9008
        lda     #$FF                    ; 0C36  $92FF is the last variable...
        sta     fillEndPtr              ; 0C38  ...low byte of the limit
        lda     #$92                    ; 0C3A  high byte...
        sta     fillEndPtrHi            ; 0C3C  ...so the fill ends at $92FF
        ldy     #$00                    ; 0C3E  the indirect store always uses offset 0
L_0C40:
        lda     #$00                    ; 0C40  zero
        sta     (mapCellPtr),y          ; 0C42  clear one variable byte
        jsr     advanceFillPointer      ; 0C44  step the pointer and set C when the limit is reached
        bcc     L_0C40                  ; 0C47  loop until the whole block is clear
        ldy     #$07                    ; 0C49  eight message queue slots...
        lda     #$FF                    ; 0C4B  ...filled with $FF = empty
L_0C4D:
        sta     messageQueue,y          ; 0C4D  clear queue slot Y
        dey                             ; 0C50  next slot
        bpl     L_0C4D                  ; 0C51  until all eight are done
        sta     lastTypedKey            ; 0C53  $FF = nothing typed
        sta     newKeyEvent             ; 0C56  $FF = no key event pending
        sta     gamePhase               ; 0C59  $FF = not in the battle phase
        jsr     rewindFilmPtr           ; 0C5B  rewind the game-film pointer to the start of the buffer under the I/O area ($D000)
        lda     #$03                    ; 0C5E  screen 3...
        sta     currentScreen           ; 0C60  ...the menu/console screen is where the game goes next
        jmp     clearChatStatusLine     ; 0C63  blank the chat status line and return through it


; ----------------------------------------------------------------------
; installRasterIrq - installs rasterIrqHandler as the interrupt routine.  There is no KERNAL to go
; through: with $01 = $35 the hardware vector at $FFFE is the real one, and the handler rewrites it
; from the zero-page shadow on every pass, so a corrupted vector repairs itself.  The interrupt is
; armed for raster line $FA (250), in the lower border below the 200 display lines, so all the
; per-frame work happens off screen.
; In:  nothing.
; Out: $FFFE/$FFFF and zp_AB/zp_AC = $10F1, rasterIrqLine = $FA, irqInProgress = 0, VIC raster
;     interrupt acknowledged and enabled.
; Called from: initIrqAndInputState $0BB8.
; ----------------------------------------------------------------------
installRasterIrq:
        sei                             ; 0C66  no interrupts while the vector is half written
        lda     #$F1                    ; 0C67  low byte of rasterIrqHandler ($10F1)
        sta     irqVectorShadowLo       ; 0C69  the shadow the handler writes back to $FFFE on every interrupt
        sta     irqVector               ; 0C6B  and the live hardware vector
        lda     #$10                    ; 0C6E  high byte of rasterIrqHandler
        sta     irqVectorShadowHi       ; 0C70  shadow high byte
        sta     irqVectorHi             ; 0C72  hardware vector high byte
        lda     #$FA                    ; 0C75  raster line 250, in the lower border
        sta     rasterIrqLine           ; 0C77  the handler re-arms VIC_RASTER from this every time
        sta     VIC_RASTER              ; 0C79  arm the raster compare now
        lda     #$00                    ; 0C7C  0 into irqInProgress
        sta     irqInProgress           ; 0C7E  the handler's re-entrancy guard starts clear
        cli                             ; 0C80  interrupts on
        lda     #$01                    ; 0C81  bit 0 = the raster interrupt
        sta     VIC_IRQ_FLAG            ; 0C83  acknowledge anything already latched
        sta     VIC_IRQ_MASK            ; 0C86  and enable raster interrupts
        rts                             ; 0C89  back to the caller


; comcenUnitIndexBySide - the unit index of each side's COMCEN: 49 ($31) for side 0, 99 ($63) for side
; 1, the last unit of each side's 50.  Indexed by the side number, so the code can reach either
; command centre's row in the unit arrays (energy, stun, position) without a multiply.  Phase 1 called
; this perSideValueTable;
; $7BF2 in the setup overlay copies the entry straight into comcenUnit ($9236).
comcenUnitIndexBySide:
        .byte   $31,$63                 ; 0C8A  1c  side 0's comcen is unit 49, side 1's is unit 99

; ----------------------------------------------------------------------
; showModemSetupPrompt - puts a caller-supplied line above 'PRESS SPACE WHEN READY.' and waits for the
; space bar.  The setup overlay uses it for the modem dialogue ('PICK UP PHONE THEN PRESS SPACE.' and
; friends).
; In:  A/Y = the string to show in message slot $20.
; Out: message slots $20 and $21 patched, both messages shown, then it tail-jumps into waitForSpaceKey
;     ($7DB8) in the overlay, so it never returns here.
; Called from: game/mapGenerator6F00 $79E3.
; ----------------------------------------------------------------------
showModemSetupPrompt:
        ldx     #$20                    ; 0C8C  message slot $20 = the run-time slot for the caller's line
        jsr     setMessageSlot          ; 0C8E  patch its text pointer to A/Y
        ldx     #$21                    ; 0C91  message slot $21 = the second run-time slot
        lda     #$37                    ; 0C93  low byte of msgPressSpaceWhenReady ($8837)
        ldy     #$88                    ; 0C95  high byte
        jsr     setMessageSlot          ; 0C97  patch slot $21 to it
        jsr     resetMessagesAndBeep    ; 0C9A  show slot $20, beep twice and arm slot $21 as the fallback line
        jmp     waitForSpaceKey         ; 0C9D  wait for the space bar in the setup overlay; that routine returns to our caller


; ----------------------------------------------------------------------
; resetMessagesAndBeep - clears the message queue, shows message slot $20 immediately, beeps twice a
; third of a second apart and makes slot $21 the line the status bar falls back to once slot $20 has
; had its time.  The double beep is what tells the player across the room that the game wants
; something.
; In:  message slots $20 and $21 already patched by the caller.
; Out: queue empty, slot $20 displayed, persistentMessageId = $21.
; Called from: showModemSetupPrompt $0C9A and waitForAnswerOriginateKey $0CBD.
; ----------------------------------------------------------------------
resetMessagesAndBeep:
        jsr     resetMessageQueue       ; 0CA0  drop anything already queued
        lda     #$20                    ; 0CA3  message id $20 = the first run-time slot
        jsr     queueMessage            ; 0CA5  put it on the status line
        lda     #$03                    ; 0CA8  sound 3 = the attention beep
        jsr     playSound               ; 0CAA  play it
        lda     #$14                    ; 0CAD  20 frames = a third of a second
        jsr     waitFrames              ; 0CAF  wait, so the two beeps are heard as two
        lda     #$03                    ; 0CB2  sound 3 again
        jsr     playSound               ; 0CB4  play it
        ldx     #$21                    ; 0CB7  message id $21 = the second run-time slot
        stx     persistentMessageId     ; 0CB9  it becomes the line the status bar returns to when the queue drains
        rts                             ; 0CBC  back to the caller


; ----------------------------------------------------------------------
; waitForAnswerOriginateKey - the ANSWER/ORIGINATE question at the start of a modem game.  It shows
; the two prompt lines, then spins until the player types A or O, and tells the comm module which of
; the two Hayes command strings to use ($E011 indexes the modem's init-string table).  The spin also
; stirs the game RNG, so the moment the player answers seeds the map.
; In:  message slots $20/$21 already patched by the caller; lastTypedKey fed by the raster IRQ.
; Out: $E011 = 0 (answer) or 1 (originate), inputLockoutTimer = 0, message queue emptied.
; Called from: startGameFromSetup $0B29.
; ----------------------------------------------------------------------
waitForAnswerOriginateKey:
        jsr     resetMessagesAndBeep    ; 0CBD  show the prompt and beep twice
        lda     #$FF                    ; 0CC0  $FF = nothing typed
        sta     lastTypedKey            ; 0CC2  forget any key that was typed before the prompt went up
        lda     #$00                    ; 0CC5  0 into inputLockoutTimer
        sta     inputLockoutTimer       ; 0CC7  cancel the 158-frame lockout so the IRQ passes the keystroke through at once
L_0CCA:
        jsr     updateStatusMessage     ; 0CCA  keep the status line alive while waiting
        jsr     nextGameRandom          ; 0CCD  stir the 24-bit game RNG: the human reaction time is the entropy source
        ldx     #$00                    ; 0CD0  X = 0 = ANSWER
        ldy     lastTypedKey            ; 0CD2  the last key the IRQ recorded
        cpy     #$C1                    ; 0CD5  $C1 = 'A' (PETSCII with bit 7 set) = answer the call
        beq     L_0CDE                  ; 0CD7  got it
        inx                             ; 0CD9  X = 1 = ORIGINATE
        cpy     #$CF                    ; 0CDA  $CF = 'O' = place the call
        bne     L_0CCA                  ; 0CDC  neither - keep waiting
L_0CDE:
        stx     isOriginateMode         ; 0CDE  tell the modem driver which mode it is in; it indexes its init-string table with this  (also trainerAiE000:isOriginateMode)
        jmp     resetMessageQueue       ; 0CE1  clear the prompt away and return through resetMessageQueue


; strOurs - the four characters 'OURS', copied over the owner-name field when the disk carries no
; name. It has no bit-7 terminator because it is never printed directly: buildOwnerNameString copies
; it into ownPlayerName and the terminator is added to the assembled line afterwards.
strOurs:
        .byte   "OURS"                  ; 0CE4  default owner name for an unpersonalised disk

; ----------------------------------------------------------------------
; buildOwnerNameString - assembles the 21-character line that names the two players on the menu panel,
; out of the two 10-byte name fields at the top of memory: ownPlayerName ($FFDE), copied out of the
; disk's BAM by checkDiskId, and opponentPlayerName ($FFE8), which the modem driver fills in from the
; other machine.  The first name is packed to the right of the first ten columns and the second to the
; left of the last ten, so the two meet either side of the single space in the middle.  Trailing
; blanks of the first name and leading blanks of the second are dropped ($FFDE holds shifted spaces,
; $A0, which the AND #$7F turns into ordinary spaces).
; In:  ownPlayerName and opponentPlayerName, 10 bytes each.
; Out: ownerNameString ($0B7F-$0B93) filled and terminated with bit 7 set on its last byte;
;     ownPlayerName replaced by 'OURS' when it was blank.
; Called from: startGameFromSetup $0B77.
; ----------------------------------------------------------------------
buildOwnerNameString:
        ldy     #$14                    ; 0CE8  21 characters, $0B7F-$0B93
        lda     #$20                    ; 0CEA  a plain space
L_0CEC:
        sta     ownerNameString,y       ; 0CEC  blank the whole line first
        dey                             ; 0CEF  next character
        bpl     L_0CEC                  ; 0CF0  until all 21 are blank
        lda     ownPlayerName           ; 0CF2  first character of the owner name out of the disk BAM
        cmp     #$A0                    ; 0CF5  $A0 = shifted space = the field was never personalised
        bne     L_0D04                  ; 0CF7  the disk has a name - use it
        ldy     #$03                    ; 0CF9  four characters of 'OURS'
L_0CFB:
        lda     strOurs,y               ; 0CFB  copy them...
        sta     ownPlayerName,y         ; 0CFE  ...over the first four bytes of the owner-name field
        dey                             ; 0D01  next character
        bpl     L_0CFB                  ; 0D02  until all four are in

; ----------------------------------------------------------------------
; First name, right aligned: walk it backwards into columns 9..0 of the line.  scratch18 stays 0 until
; the first real character has been placed and is negative afterwards, so blanks at the end of the
; field are skipped while blanks inside the name are kept.
; ----------------------------------------------------------------------
L_0D04:
        lda     #$00                    ; 0D04  0 into the 'seen a character yet' flag
        sta     scratch18               ; 0D06  scratch18 = 0 means nothing placed so far
        ldy     #$09                    ; 0D08  last character of the 10-byte field
        ldx     #$09                    ; 0D0A  column 9 = the rightmost of the first half of the line
L_0D0C:
        lda     ownPlayerName,y         ; 0D0C  one character of the owner name
        and     #$7F                    ; 0D0F  clear bit 7: shifted characters ($A0 etc.) become plain ones
        cmp     #$20                    ; 0D11  is it a space?
        bne     L_0D19                  ; 0D13  no - place it
        bit     scratch18               ; 0D15  have any characters been placed yet?
        bpl     L_0D1F                  ; 0D17  no - this is a trailing blank, drop it
L_0D19:
        sta     ownerNameString,x       ; 0D19  place the character in the line
        dex                             ; 0D1C  one column to the left
        dec     scratch18               ; 0D1D  make scratch18 negative: from now on blanks count as part of the name
L_0D1F:
        dey                             ; 0D1F  previous character of the field
        bpl     L_0D0C                  ; 0D20  until the whole field has been scanned

; ----------------------------------------------------------------------
; Second name, left aligned: columns 11..20, forwards through the field, with the same blank-squeezing
; rule - so this drops the leading blanks.  Column 10 stays a space and separates the two names.
; ----------------------------------------------------------------------
        ldx     #$0B                    ; 0D22  column 11, just past the separating space
        ldy     #$00                    ; 0D24  first character of the opponent's name
        sty     scratch18               ; 0D26  nothing placed yet
L_0D28:
        lda     opponentPlayerName,y    ; 0D28  one character of the opponent name
        and     #$7F                    ; 0D2B  clear bit 7
        cmp     #$20                    ; 0D2D  is it a space?
        bne     L_0D35                  ; 0D2F  no - place it
        bit     scratch18               ; 0D31  have any characters been placed yet?
        bpl     L_0D3B                  ; 0D33  no - this is a leading blank, drop it
L_0D35:
        sta     ownerNameString,x       ; 0D35  place the character
        inx                             ; 0D38  one column to the right
        dec     scratch18               ; 0D39  make scratch18 negative so inner blanks survive
L_0D3B:
        iny                             ; 0D3B  next character of the field
        cpy     #$0A                    ; 0D3C  ten characters in the field
        bcc     L_0D28                  ; 0D3E  loop until they are all scanned
        lda     ownerNameEnd            ; 0D40  the last byte of the line ($0B93)
        ora     #$80                    ; 0D43  set bit 7 on it...
        sta     ownerNameEnd            ; 0D45  ...which is how every string in this game ends
        rts                             ; 0D48  back to the caller


; ----------------------------------------------------------------------
; setJoystickRepeatDelay - the joystick auto-repeat used by every menu and by the map scrolling.  The
; caller sets a direction going and calls this to arm the delay before it happens again: 16 frames for
; the first repeat, 6 frames afterwards, so a nudge moves one step and a held stick runs.  The raster
; IRQ clears joyRepeatMask when the stick returns to centre, which forces joyHoldCounter back to 0 and
; so restores the long first delay.  A is preserved so callers can keep a value in it across the call.
; In:  joyRepeatMask ($91C9), joyHoldCounter ($91CA).
; Out: joyRepeatTimer (zp_6C) = 16 or 6, joyHoldCounter stepped, joyRepeatMask = $7F, A unchanged.
; Called from: $20E7, $2230 and $60AF in this file and $8090/$849E in the setup overlay.
; ----------------------------------------------------------------------
setJoystickRepeatDelay:
        ldy     #$10                    ; 0D49  16 frames: the pause before a held direction starts repeating
        pha                             ; 0D4B  the caller's A must survive
        inc     joyHoldCounter          ; 0D4C  count this step
        lda     joyHoldCounter          ; 0D4F  read the counter back
        and     joyRepeatMask           ; 0D52  the IRQ clears this mask to 0 while the stick is centred, which zeroes the counter
        sta     joyHoldCounter          ; 0D55  so the counter only grows while the stick is held
        cmp     #$01                    ; 0D58  was this the first step after centring?
        bcc     L_0D5E                  ; 0D5A  yes - keep the long 16-frame delay
        ldy     #$06                    ; 0D5C  no - 6 frames between repeats
L_0D5E:
        sty     joyRepeatTimer          ; 0D5E  arm the countdown; the raster IRQ decrements it every frame
        lda     #$7F                    ; 0D60  $7F lets the counter run again...
        sta     joyRepeatMask           ; 0D62  ...until the IRQ sees the stick centred and clears the mask
        pla                             ; 0D65  give the caller its A back
        rts                             ; 0D66  back to the caller


; keyMatrixCodeTable - the whole C64 keyboard as 64 bytes, indexed by (port A row) * 8 + (port B
; column bit), which is exactly the order scanKeyboard finds a key in.  Ordinary keys are their ASCII
; code with bit 7 set (so 'A' is $C1 and '3' is $B3), the four function keys are 0-3 because the game
; uses them as screen numbers (F1 = 0 map, F3 = 1 missile, F5 = 2 drone, F7 = 3 menu), and the control
; keys are ASCII control codes with bit 7 set: DEL $88, RETURN $8D, CRSR DOWN $8B, HOME $8C, CRSR
; RIGHT $8E, RUN/STOP $93, left arrow $9B, pound $83.  Two magic entries: $FE marks the three modifier
; cells (both SHIFTs and the Commodore key), which the scanner tests separately and skips here, and
; $FF marks CTRL, which makes the scan report 'no key' - the game has no use for CTRL and treats
; holding it as a way to block the keyboard.
keyMatrixCodeTable:
        .byte   $88,$8D,$8E,$03,$00,$01,$02,$8B; 0D67  ........  row 0 (PA0): DEL, RETURN, CRSR RIGHT, F7=3, F1=0, F3=1, F5=2, CRSR DOWN
        .byte   $B3                     ; 0D6F  row 1 (PA1): 3 W A 4 Z S E, then LEFT SHIFT ($FE = handled separately)
        .byte   $D7,$C1,$B4,$DA,$D3,$C5,$FE; 0D70  .......
        .byte   $B5                     ; 0D77  row 2 (PA2): 5 R D 6 C F T X
        .byte   $D2,$C4,$B6,$C3,$C6,$D4,$D8; 0D78  .......
        .byte   $B7                     ; 0D7F  row 3 (PA3): 7 Y G 8 B H U V
        .byte   $D9,$C7,$B8,$C2,$C8,$D5,$D6; 0D80  .......
        .byte   $B9                     ; 0D87  row 4 (PA4): 9 I J 0 M K O N
        .byte   $C9,$CA,$B0,$CD,$CB,$CF,$CE; 0D88  .......
        .byte   $AB                     ; 0D8F  row 5 (PA5): + P L - . : @ ,
        .byte   $D0,$CC,$AD,$AE,$BA,$C0,$AC; 0D90  .......
        .byte   $83                     ; 0D97  row 6 (PA6): pound, *, ;, HOME, RIGHT SHIFT ($FE), =, up arrow, /
        .byte   $AA,$BB,$8C,$FE,$BD,$DE,$AF; 0D98  .......
        .byte   $B1                     ; 0D9F  row 7 (PA7): 1, left arrow, CTRL ($FF = report no key), 2, SPACE, C= ($FE), Q, RUN/STOP
        .byte   $9B,$FF,$B2,$A0,$FE,$D1,$93; 0DA0  .......

; shiftedKeyTable - what a key produces when SHIFT is held, for the sixteen codes $AC-$BB (',' '-' '.'
; '/', the digits '0'-'9', ':' and ';'); pollKeyboardEvent indexes it with keycode - $AC.  The digits
; give the usual C64 symbols !"#$%&'() and shifted '0' stays '0', ',' '.' '/' become '<' '>' '?' and
; '-' stays '-'. The last two entries repeat those of '8' and '9', so shifted ':' and ';' come out as
; '(' and ')' instead of the brackets a C64 would give - the game's font has no square brackets.
; Letters are not in the table: SHIFT does not change them, the font has only one case.
shiftedKeyTable:
        .byte   $BC,$AD,$BE,$BF,$B0,$A1,$A2,$A3; 0DA7  ........  shifted , - . / 0 1 2 3  ->  < - > ? 0 ! " #
        .byte   $A4                     ; 0DAF  shifted 4 5 6 7 8 9 : ;  ->  $ % & ' ( ) ( )
        .byte   $A5,$A6,$A7,$A8,$A9,$A8,$A9; 0DB0  .......

; ----------------------------------------------------------------------
; scanKeyboard - the game's own keyboard matrix scanner; there is no KERNAL to ask, since the ROMs are
; banked out.  It is not called directly: initCommModule patches its address into the comm module's
; key vector ($E013/$E014), and pollKeyboardEvent reaches it once a frame through the module's $E015
; entry, which lets the modem driver steal keys for its Commodore-key hot keys first. The scan first
; makes sure no joystick is confusing the matrix (a stick in port 1 pulls column lines low, one in
; port 2 pulls row lines low, and either looks exactly like a key), then tests the two SHIFT keys and
; the Commodore key as modifiers, and finally walks rows 7..0 and columns 7..0 and translates the
; first key it finds through keyMatrixCodeTable.  Only one key is ever reported - the game needs no
; rollover.
; In:  CIA1 ports; the row masks at D_2F8D ($2F8D) and the column bit masks at spriteBitMasks ($2F85).
; Out: A = key code or $FF for none, Y = $80 when a SHIFT is held (else 0), X = $80 when the Commodore
;     key is held (else 0).  It self-modifies the operands at $0E2B, $0E3A, $0E41 and $0E43, and
;     leaves CIA1_DDRA = $FF, CIA1_DDRB = $00 and the last row mask in CIA1_PRA - the raster IRQ
;     relies on that to read joystick 2 immediately afterwards.
; Called from: game/modemDriverE000 and game/trainerAiE000 through the patched vector at $E013
;     ($E012/$E015).
; ----------------------------------------------------------------------
scanKeyboard:
        ldx     #$00                    ; 0DB7  0 into both result operands below
        stx     L_0E40+1                ; 0DB9  the LDY operand at $0E41: assume no SHIFT is held
        stx     L_0E42+1                ; 0DBC  the LDX operand at $0E43: assume the Commodore key is not held
        stx     CIA1_DDRB               ; 0DBF  port B all inputs - it reads the eight key columns
        lda     #$FF                    ; 0DC2  $FF into port A and its direction register
        sta     CIA1_DDRA               ; 0DC4  port A all outputs - it selects the key rows (a 0 bit selects a row)
        sta     CIA1_PRA                ; 0DC7  no row selected at all
        cmp     CIA1_PRB                ; 0DCA  with no row selected every column should read high
        beq     L_0DD2                  ; 0DCD  it does - the matrix is undisturbed
L_0DCF:
        jmp     L_0E3E                  ; 0DCF  a column is being pulled low with no row selected: that is a joystick in port 1, so report 'no key'

L_0DD2:
        cmp     CIA1_PRA                ; 0DD2  port A should also read back the $FF it is driving
        bne     L_0DCF                  ; 0DD5  it does not: a joystick in port 2 is pulling a row line low, so the scan would lie - report 'no key'
        stx     CIA1_PRA                ; 0DD7  X is still 0: select all eight rows at once
        cmp     CIA1_PRB                ; 0DDA  if any key at all is down, some column now reads low
        beq     L_0DCF                  ; 0DDD  nothing is down - report 'no key' without scanning

; ----------------------------------------------------------------------
; The three modifier keys are wired into the matrix like any other key, so they have to be tested by
; name before the general scan, which skips their cells ($FE in keyMatrixCodeTable).
; ----------------------------------------------------------------------
        ldy     #$BF                    ; 0DDF  row mask $BF = PA6 low (the row with RIGHT SHIFT)
        lda     #$10                    ; 0DE1  column bit 4 = RIGHT SHIFT
        jsr     testKeyMask             ; 0DE3  is it down?
        beq     L_0DF1                  ; 0DE6  yes - record a shift
        ldy     #$FD                    ; 0DE8  row mask $FD = PA1 low (the row with LEFT SHIFT)
        lda     #$80                    ; 0DEA  column bit 7 = LEFT SHIFT
        jsr     testKeyMask             ; 0DEC  is that one down?
        bne     L_0DF6                  ; 0DEF  neither shift is down
L_0DF1:
        lda     #$80                    ; 0DF1  $80 = shift held
        sta     L_0E40+1                ; 0DF3  patch it into the LDY at $0E40, which returns the shift flag in Y
L_0DF6:
        ldy     #$7F                    ; 0DF6  row mask $7F = PA7 low (the row with the Commodore key)
        lda     #$20                    ; 0DF8  column bit 5 = the Commodore key
        jsr     testKeyMask             ; 0DFA  is it down?
        bne     L_0E04                  ; 0DFD  no
        lda     #$80                    ; 0DFF  $80 = Commodore key held
        sta     L_0E42+1                ; 0E01  patch it into the LDX at $0E42, which returns that flag in X

; ----------------------------------------------------------------------
; The scan proper: eight rows, eight columns each, from the top down, stopping at the first key that
; is down. So when two keys are held the one with the higher row (and within a row the higher column)
; wins.
; ----------------------------------------------------------------------
L_0E04:
        ldx     #$07                    ; 0E04  start at row 7
L_0E06:
        ldy     #$07                    ; 0E06  start at column 7
        lda     invertedBitMasks,x      ; 0E08  the row select pattern for this row (one bit low)
        sta     CIA1_PRA                ; 0E0B  select it on port A
L_0E0E:
        lda     CIA1_PRB                ; 0E0E  read the columns...
        cmp     CIA1_PRB                ; 0E11  ...and read them again
        bne     L_0E0E                  ; 0E14  until two reads agree - cheap debounce against key bounce and the raster's noise
        and     spriteBitMasks,y        ; 0E16  test the single column bit for this column
        beq     L_0E23                  ; 0E19  zero means that key is down
L_0E1B:
        dey                             ; 0E1B  next column
        bpl     L_0E0E                  ; 0E1C  until all eight of this row are done
        dex                             ; 0E1E  next row
        bpl     L_0E06                  ; 0E1F  until all eight rows are done
        bmi     L_0E3E                  ; 0E21  nothing found anywhere - report 'no key'
L_0E23:
        txa                             ; 0E23  the row number...
        asl     a                       ; 0E24  ...times two...
        asl     a                       ; 0E25  ...times four...
        asl     a                       ; 0E26  ...times eight = the start of that row in the code table
        sty     L_0E2A+1                ; 0E27  patch the column number into the ORA operand at $0E2B
L_0E2A:
        ora     #$FF                    ; 0E2A  operand patched just above: row * 8 + column = the index into keyMatrixCodeTable
        stx     L_0E39+1                ; 0E2C  save the row number in the LDX operand at $0E3A, because X is about to be reused as the table index
        tax                             ; 0E2F  index the table
        lda     keyMatrixCodeTable,x    ; 0E30  the key code for the key that is down
        cmp     #$FE                    ; 0E33  $FE and $FF are the two magic entries
        bcc     L_0E40                  ; 0E35  a real key code - return it
        bne     L_0E3E                  ; 0E37  $FF = CTRL: pretend nothing is pressed
L_0E39:
        ldx     #$FF                    ; 0E39  $FE = one of the modifier cells: restore the row number (operand patched at $0E2C)...
        jmp     L_0E1B                  ; 0E3B  ...and carry on scanning from the next column

L_0E3E:
        lda     #$FF                    ; 0E3E  $FF = no key
L_0E40:
        ldy     #$FF                    ; 0E40  operand patched at $0DB9/$0DF3: Y = $80 when a shift is held, 0 otherwise
L_0E42:
        ldx     #$FF                    ; 0E42  operand patched at $0DBC/$0E01: X = $80 when the Commodore key is held, 0 otherwise
        rts                             ; 0E44  back to pollKeyboardEvent (through the comm module)


; ----------------------------------------------------------------------
; testKeyMask - tests one named key for scanKeyboard: it selects the row given in Y, reads the columns
; twice until two reads agree, and masks out the single column bit the caller passed in A.  The mask
; is not kept in a register but patched into the AND operand at $0E54, so both registers stay free.
; In:  Y = the port A row select pattern (one bit low), A = the column bit mask.
; Out: Z = 1 when that key is down; A = the masked port B reading; the operand at $0E54 patched.
; Called from: scanKeyboard $0DE3 (right shift), $0DEC (left shift) and $0DFA (Commodore key).
; ----------------------------------------------------------------------
testKeyMask:
        sta     L_0E53+1                ; 0E45  patch the caller's column mask into the AND at $0E53
        sty     CIA1_PRA                ; 0E48  select the row
L_0E4B:
        lda     CIA1_PRB                ; 0E4B  read the columns...
        cmp     CIA1_PRB                ; 0E4E  ...and read them again
        bne     L_0E4B                  ; 0E51  until two reads agree
L_0E53:
        and     #$FF                    ; 0E53  keep only the column of the key we are asking about (operand patched at $0E45)
        rts                             ; 0E55  Z = 1 means the key is down


; ----------------------------------------------------------------------
; pollKeyboardEvent - the keyboard front end, run once per frame from the raster IRQ (and only from
; there).  It asks the comm module for a key - which reaches scanKeyboard, after the modem driver has
; had its chance to swallow Commodore-key hot keys - and turns the raw 'this key is down' answer into
; events: a key that is the same as last frame is ignored until the 20-frame repeat timer runs out, so
; a held key repeats about three times a second. The four function keys (codes 0-3) are screen
; requests: they go only into requestedScreenKey (zp_66) and explicitly clear newKeyEvent, because
; nothing may type them into a text field.  Every other key (all codes have bit 7 set) becomes a
; newKeyEvent for the line editors, a lastTypedKey for the menus, and is also written into
; requestedScreenKey, which cancels any stale F-key request.  When a shift is held the sixteen
; punctuation and digit codes $AC-$BB are replaced from shiftedKeyTable.
; In:  the comm module key entry $E015 -> scanKeyboard (A = code, Y = shift, X = Commodore);
;     prevKeyCode, keyRepeatTimer.
; Out: rawKeyCode/shiftHeldFlag/commodoreHeldFlag ($92A0/$92E4/$92E5) = the raw scan, newKeyEvent
;     ($90EA), prevKeyCode ($90E9), lastTypedKey ($9248), requestedScreenKey (zp_66) and
;     keyRepeatTimer (zp_70).
; Called from: rasterIrqHandler $11E7 (only when the input lockout timer has expired).
; ----------------------------------------------------------------------
pollKeyboardEvent:
        jsr     commKeyEntry            ; 0E56  read a key through the comm module, which vectors on to scanKeyboard  (also trainerAiE000:commReadKeyEntry)
        sta     rawKeyCode              ; 0E59  keep the raw code the scanner returned
        sty     shiftHeldFlag           ; 0E5C  Y = $80 when a shift was held during that scan
        stx     commodoreHeldFlag       ; 0E5F  X = $80 when the Commodore key was held
        cmp     #$FF                    ; 0E62  $FF = no key is down
        beq     L_0EA4                  ; 0E64  nothing pressed - clear the pending state
        cmp     prevKeyCode             ; 0E66  the same key as the previous frame?
        bne     L_0E6F                  ; 0E69  no - a genuinely new keypress
        ldy     keyRepeatTimer          ; 0E6B  the repeat countdown the IRQ decrements every frame
        bne     L_0EAB                  ; 0E6D  still running: the key is being held, do not report it again yet
L_0E6F:
        ldy     #$14                    ; 0E6F  20 frames until a held key repeats
        sty     keyRepeatTimer          ; 0E71  arm the repeat timer
        sta     newKeyEvent             ; 0E73  publish the key as this frame's event
        sta     prevKeyCode             ; 0E76  remember it so the next frame can tell 'held' from 'pressed again'
        cmp     #$80                    ; 0E79  codes below $80 are only the four function keys
        bcc     L_0E9B                  ; 0E7B  F1/F3/F5/F7 are handled separately below
        sta     lastTypedKey            ; 0E7D  everything else is a typed character: the menus read it here
        sta     requestedScreenKey      ; 0E80  and into the screen-request byte, where a printable code reads as 'no screen change wanted' and so cancels a stale F-key request
        bit     shiftHeldFlag           ; 0E82  was a shift held?
        bpl     L_0E9A                  ; 0E85  no - the code stands as it is
        sec                             ; 0E87  prepare an exact subtraction
        sbc     #$AC                    ; 0E88  $AC = ',' is the first code that has a shifted form
        bcc     L_0E9A                  ; 0E8A  below the table - letters and control codes are unaffected
        cmp     #$10                    ; 0E8C  sixteen entries, $AC-$BB
        bcs     L_0E9A                  ; 0E8E  above the table - unaffected
        tax                             ; 0E90  the difference is the table index
        lda     shiftedKeyTable,x       ; 0E91  the shifted form of that key
        sta     newKeyEvent             ; 0E94  replace this frame's event...
        sta     lastTypedKey            ; 0E97  ...and the typed character with it
L_0E9A:
        rts                             ; 0E9A  back to the IRQ

L_0E9B:
        tay                             ; 0E9B  hold the function key number (0-3) in Y
        lda     #$FF                    ; 0E9C  $FF = no character event...
        sta     newKeyEvent             ; 0E9E  ...so a screen key can never be typed into a text field
        sty     requestedScreenKey      ; 0EA1  the screen the player asked for: 0 map, 1 missile, 2 drone, 3 menu
        rts                             ; 0EA3  back to the IRQ

L_0EA4:
        lda     #$FF                    ; 0EA4  $FF = none
        sta     requestedScreenKey      ; 0EA6  no screen change pending
        sta     prevKeyCode             ; 0EA8  and no previous key, so the very next press counts as new
L_0EAB:
        lda     #$FF                    ; 0EAB  $FF = nothing to report
        sta     newKeyEvent             ; 0EAD  clear this frame's key event
        rts                             ; 0EB0  back to the IRQ


; ----------------------------------------------------------------------
; checkFirePressed - the game's 'has the player clicked?' primitive; almost every interactive loop
; spins on it with BNE.  It reports a click only on the edge: the button must have been seen released
; since the last accepted click (fireButtonArmed) and must be down now.  An accepted click arms a
; 10-frame debounce and plays the click sound.  It also refuses clicks when the outgoing command ring
; has less than five bytes free, which throttles the player instead of overflowing the link.
; In:  joyFireUp (zp_5F, 0 = button down), fireButtonArmed (zp_79), fireDebounceTimer (zp_6D), the
;     command ring indices through isCmdRingNearlyFull.
; Out: A = 0 (Z=1) for a fresh click, A = 1 otherwise; fireDebounceTimer = 10 and sound 1 queued on a
;     click; fireButtonArmed maintained.
; Called from: about a dozen loops - $0ED5, $1470, $21E7, $5E68, $608E, $6226, $63FB here,
;     $7751/$806F/$8127/$84A4/$878D in the overlays and $C865 in the text engine.
; ----------------------------------------------------------------------
checkFirePressed:
        jsr     isCmdRingNearlyFull     ; 0EB1  C=1 when fewer than five bytes are free in the outgoing command ring
        bcs     L_0EC0                  ; 0EB4  the link cannot take another order - swallow the click
        lda     fireDebounceTimer       ; 0EB6  frames left of the debounce after the last accepted click
        bne     L_0EC0                  ; 0EB8  still counting down - ignore the button
        lda     joyFireUp               ; 0EBA  0 = the button is down right now
        ora     fireButtonArmed         ; 0EBC  the latch that says the button has been released since the last click
        bne     L_0EC3                  ; 0EBE  either of them non-zero means there is something to look at
L_0EC0:
        lda     #$01                    ; 0EC0  A = 1: no click this time
        rts                             ; 0EC2  callers loop on BNE, so this keeps them waiting

L_0EC3:
        lda     joyFireUp               ; 0EC3  is the button actually down?
        bne     L_0ED2                  ; 0EC5  no - it has just been released, so re-arm the latch
        lda     #$0A                    ; 0EC7  10 frames...
        sta     fireDebounceTimer       ; 0EC9  ...during which further clicks are ignored (contact bounce and human double taps)
        lda     #$01                    ; 0ECB  sound 1 = the UI click
        jsr     playSound               ; 0ECD  play it so the player hears the press
        lda     #$00                    ; 0ED0  A = 0: a fresh click, and 0 also disarms the latch below
L_0ED2:
        sta     fireButtonArmed         ; 0ED2  store 0 (click taken) or the button-up state (latch armed)
        rts                             ; 0ED4  A tells the caller which of the two happened


; ----------------------------------------------------------------------
; checkFirePressedInteractive - checkFirePressed plus a 'may the player act at all?' gate, used by the
; loops that turn a click into an order.  It reports a click only when a game film is not being
; watched (filmPlaybackMode bit 7) and the game has not already ended (gameOverFlags bit 6, which is
; set when a game is over or aborted).  Phase 1 called this checkFirePressedNotSolo, which is wrong:
; the solo trainer leaves filmPlaybackMode clear and its player clicks normally.
; In:  the same joystick state as checkFirePressed, plus filmPlaybackMode ($0B9B) and gameOverFlags
;     ($923F).
; Out: A = 0 only for an accepted click during live play; non-zero otherwise.
; Called from: $2076 here and $75FA/$8043 in the $6F00 overlays.
; ----------------------------------------------------------------------
checkFirePressedInteractive:
        jsr     checkFirePressed        ; 0ED5  the ordinary edge-detected click
        bne     L_0EE4                  ; 0ED8  no click - return non-zero without looking further

; ----------------------------------------------------------------------
; Shared entry: isPlayerInputAllowed ($0EE5) jumps in here with A = 0 to ask the same question without
; testing the button.
; ----------------------------------------------------------------------
L_0EDA:
        lda     filmPlaybackMode        ; 0EDA  watching a game film?
        bmi     L_0EE4                  ; 0EDD  then the player is a spectator: the click does not count (A is negative, i.e. non-zero)
        lda     gameOverFlags           ; 0EDF  the flags set when the game has ended or been aborted
        and     #$40                    ; 0EE2  bit 6 blocks all further player commands; clear = the click is accepted (A = 0)
L_0EE4:
        rts                             ; 0EE4  Z=1 only for an accepted click in a live game


; ----------------------------------------------------------------------
; isPlayerInputAllowed - the gate half of checkFirePressedInteractive without the button test: it
; jumps into that routine at $0EDA with A = 0, so it answers 'may the local player still act?' - Z=1
; when no film is being watched and the game has not ended.  Phase 1 named it getSoloOrFlag923F and
; thought it was unreferenced; the REPAIR tab uses it to decide whether the selected system may be
; repaired.
; In:  filmPlaybackMode ($0B9B), gameOverFlags ($923F).
; Out: Z=1 (A = 0) when the player may act.
; Called from: $658B, the REPAIR tab menu loop.
; ----------------------------------------------------------------------
isPlayerInputAllowed:
        lda     #$00                    ; 0EE5  A = 0 stands in for 'the button was pressed'
        jmp     L_0EDA                  ; 0EE7  run only the film/game-over test of checkFirePressedInteractive


; ----------------------------------------------------------------------
; isFireHeld - the level-triggered version of checkFirePressed: no ring-buffer check, no debounce
; reload and no click sound, so it keeps answering 0 for as long as the button is held (once it has
; been seen released).  Used by the two loops where an action has to run while the button is down -
; the map screen's group box at $2376 and the console at $407A.
; In:  joyFireUp (zp_5F), fireButtonArmed (zp_79), fireDebounceTimer (zp_6D).
; Out: A = 0 while the button is held, A = 1 otherwise; fireButtonArmed re-armed when the button is
;     up.
; Called from: $2376 and $407A.
; ----------------------------------------------------------------------
isFireHeld:
        lda     fireDebounceTimer       ; 0EEA  still inside the debounce of an earlier click?
        bne     L_0EF4                  ; 0EEC  then say 'not held'
        lda     joyFireUp               ; 0EEE  0 = the button is down
        ora     fireButtonArmed         ; 0EF0  the released-since-last-time latch
        bne     fireArmOrHeld           ; 0EF2  something to look at
L_0EF4:
        lda     #$01                    ; 0EF4  A = 1: the button is not held (or has never been released)
        rts                             ; 0EF6  back to the caller


; ----------------------------------------------------------------------
; Tail of isFireHeld ($0EEA): reached when the button is up, or is down and has already been seen
; released once (fireButtonArmed set).  A=0 on return means 'the player is holding fire'.
; ----------------------------------------------------------------------
fireArmOrHeld:
        lda     joyFireUp               ; 0EF7  re-read the latched fire state: 0 = button down, 1 = button up
        beq     L_0EFD                  ; 0EF9  button still down (and armed) -> return A=0 = held
        sta     fireButtonArmed         ; 0EFB  button is up: arm the latch so the next press counts, and return A=1 = not held
L_0EFD:
        rts                             ; 0EFD  back to the caller; Z=1 means fire is being held


; ----------------------------------------------------------------------
; stopAllSound - Silences every SID voice by requesting sound id 0, which requestSound turns into
; $FF/$FF/$FF/$00 in the four sound-request slots $92E7-$92EA so the IRQ dequeues 'sound 0' and the
; player releases all three voices.
; In:  none
; Out: $92E7-$92EA reset, all voices silenced
; Called from: initIrqAndInputState ($0BB5) and $1AF7 (end of game)
; ----------------------------------------------------------------------
stopAllSound:
        lda     #$00                    ; 0EFE  sound id 0 = the 'silence everything' request
        jmp     playSound               ; 0F00  tail-call the $C000 sound entry (playSound preserves X/Y and returns to our caller)


; ----------------------------------------------------------------------
; loadStartupOverlays - One-shot start-up loader: fast-loads track 30 sectors 0-15 into $C000-$CFFF
; (the text/glyph engine, which replaces the boot loader that lived there), then loadOverlayA - which
; chains the message strings and the comm tail - and falls straight into loadCommModule.
; In:  isSoloTrainer ($0BA5), read further down the chain
; Out: $C000-$CFFF, $6F00-$87FF variant A, $8800-$89FF, $EC00-$EFFF, $E000-$EBFF, comm module
;     initialised
; Called from: mainStart ($0A81), once
; ----------------------------------------------------------------------
loadStartupOverlays:
        ldx     #$00                    ; 0F03  destination low byte $00 ...
        ldy     #$C0                    ; 0F05  ... and high byte $C0: the transfer lands at $C000
        jsr     setLoadDest             ; 0F07  store both into the loader's self-modifying destination operands
        ldy     #$1E                    ; 0F0A  track $1E = 30, the text/glyph engine overlay
        lda     #$10                    ; 0F0C  $10 = 16 sectors = 4 KB -> $C000-$CFFF
        ldx     #$00                    ; 0F0E  starting at sector 0
        jsr     loadSectors             ; 0F10  pull it in over the boot loader that is still sitting at $C000
        jsr     loadOverlayA            ; 0F13  then the $6F00 overlay variant A, the $8800 strings and the $EC00 comm tail

; ----------------------------------------------------------------------
; loadCommModule - Loads the opponent module behind the $E000 jump table and initialises it.  With
; isSoloTrainer ($0BA5) bit 7 set it disables the CIA interrupts and reads track 35 sectors 0-11 (the
; solo trainer), then points the NMI vector at the bare RTI because the trainer never uses NMIs;
; otherwise it falls into loadCommBuild2 for the real modem driver.
; In:  isSoloTrainer ($0BA5) bit 7
; Out: $E000-$EBFF filled, comm module initialised, (build 1) NMI vector = irqRti
; Called from: $0AF4 in startGameFromSetup, and by fall-through from loadStartupOverlays
; ----------------------------------------------------------------------
loadCommModule:
        ldx     #$00                    ; 0F16  destination low byte $00 ...
        ldy     #$E0                    ; 0F18  ... high byte $E0: the module always loads at $E000
        jsr     setLoadDest             ; 0F1A  arm the loader's destination
        lda     isSoloTrainer           ; 0F1D  which opponent did the player pick?
        bpl     loadCommBuild2          ; 0F20  bit 7 clear = modem game -> load build 2 (the software modem)
        jsr     disableCiaInterrupts    ; 0F22  solo trainer: no CIA interrupts at all while the fast loader runs
        ldy     #$23                    ; 0F25  track $23 = 35, the solo trainer
        lda     #$0C                    ; 0F27  $0C = 12 sectors = 3 KB -> $E000-$EBFF
        ldx     #$00                    ; 0F29  from sector 0
        jsr     loadCommFinish          ; 0F2B  load it and run the module's init entry
        jmp     setNmiVectorRti         ; 0F2E  the trainer has no UART, so send NMIs straight to an RTI and return


; ----------------------------------------------------------------------
; loadCommBuild2 - Loads the modem-driver build of the comm module with interrupts off.  Its first
; page comes from track 18 sector 7 (on the directory track, so it is not encrypted and holds the
; module's jump table and variable block); track 34 sectors 1-11 supply the rest.  Falls into
; loadCommFinish.
; In:  load destination already set to $E000
; Out: $E000-$EBFF = the modem driver
; Called from: loadCommModule ($0F20) when isSoloTrainer bit 7 is clear
; ----------------------------------------------------------------------
loadCommBuild2:
        sei                             ; 0F31  no interrupts during the fast-loader handshake
        ldy     #$12                    ; 0F32  track $12 = 18, the directory track
        ldx     #$07                    ; 0F34  sector 7 holds the driver's first page
        lda     #$01                    ; 0F36  just that one sector -> $E000-$E0FF
        jsr     loadSectors             ; 0F38  read it (loadSectors leaves the destination advanced to $E100)
        ldy     #$22                    ; 0F3B  track $22 = 34, the rest of the modem driver
        ldx     #$01                    ; 0F3D  sectors 1..11 (sector 0 is filler)
        lda     #$0B                    ; 0F3F  $0B = 11 sectors -> $E100-$EBFF

; ----------------------------------------------------------------------
; loadCommFinish - Shared tail of the two comm-module load paths: does the final loadSectors with the
; registers the caller set up and falls into initCommModule.
; In:  A = sector count, Y = track, X = first sector, destination from setLoadDest
; Out: comm module loaded and initialised
; Called from: loadCommModule ($0F2B); reached by fall-through from loadCommBuild2
; ----------------------------------------------------------------------
loadCommFinish:
        jsr     loadSectors             ; 0F41  final transfer of the module

; ----------------------------------------------------------------------
; initCommModule - Cold-initialises the freshly loaded comm module and grafts the game's own keyboard
; scanner into it: the module's keyboard vector at $E013/$E014 is patched to scanKeyboard ($0DB7), so
; the module's $E012/$E015 entries scan the matrix with the game's routine (the KERNAL is banked out).
; In:  comm module resident at $E000
; Out: $E013/$E014 = $0DB7, interrupts enabled
; Called from: loadCommFinish (fall-through) and directly from $0AFC
; ----------------------------------------------------------------------
initCommModule:
        ldx     #$01                    ; 0F44  function code 1 = cold initialise the module
        jsr     commLinkControl         ; 0F46  call the module's life-cycle entry $E003  (also trainerAiE000:commSetLinkStateEntry)
        lda     #$B7                    ; 0F49  low byte of scanKeyboard ($0DB7) ...
        sta     D_E013                  ; 0F4B  ... into the operand of the module's keyboard JMP
        lda     #$0D                    ; 0F4E  high byte $0D ...
        sta     D_E014                  ; 0F50  ... completing the vector: $E012/$E015 now scan the game's own key matrix
        cli                             ; 0F53  the module is live: let the raster IRQ run again
        rts                             ; 0F54  done


; ----------------------------------------------------------------------
; loadSectorsDelayed - Wrapper around loadSectors that first burns 8 raster frames through the $C000
; frame delay, giving the drive time to settle (and the previous fast-load handshake time to finish)
; before the next request.  Every overlay, map and setup load in this file goes through it.
; In:  A = sector count, Y = track, X = first sector, destination from setLoadDest
; Out: as loadSectors (memory filled, C=1 on error)
; Called from: $0FD1, $1054, $105D, $106D, $1096, $109F, $10B8, $107D, $10C8 and overlay A ($7E97,
;     $7F84)
; ----------------------------------------------------------------------
loadSectorsDelayed:
        pha                             ; 0F55  the sector count must survive the delay
        lda     #$08                    ; 0F56  8 frames ...
        jsr     waitFrames              ; 0F58  ... of raster-timed idling (Y and X are preserved by waitFrames)
        pla                             ; 0F5B  get the sector count back
        jmp     loadSectors             ; 0F5C  now do the transfer and return to our caller


; ----------------------------------------------------------------------
; suspendCommModule - Asks the comm module to pause before the game touches the disk: the fast loader
; needs interrupts off and exact cycle timing, which a UART NMI would ruin.  When the link is up it
; writes request code $C0 into the module's suspend-request byte $E039 and spins until the module
; acknowledges by setting bit 7 of $E03A.  A is preserved across the call.
; In:  $E03B (link active flag)
; Out: $E039 = $C0; blocks until $E03A bit 7 is set
; Called from: suspendCommForDiskAccess ($10CB)
; ----------------------------------------------------------------------
suspendCommModule:
        pha                             ; 0F5F  the caller's A must survive
        lda     isLinkActive            ; 0F60  is the link actually up?  (also trainerAiE000:isLinkActive)
        beq     L_0F6F                  ; 0F63  no link (or the solo trainer) - nothing to suspend
        lda     #$C0                    ; 0F65  $C0 = 'disable every CIA2 NMI source' request code
        sta     nmiSuspendRequest       ; 0F67  hand it to the module's request byte $E039  (also trainerAiE000:nmiSuspendRequest)
L_0F6A:
        lda     nmiSuspendAck           ; 0F6A  poll the acknowledge byte $E03A ...  (also trainerAiE000:nmiSuspendAck)
        bpl     L_0F6A                  ; 0F6D  ... until bit 7 says the module has parked itself
L_0F6F:
        pla                             ; 0F6F  restore the caller's A
        rts                             ; 0F70  the disk may now be touched


; ----------------------------------------------------------------------
; resumeCommModule - Counterpart of suspendCommModule: when the link is up it clears the request byte
; $E039 and spins until the module clears bit 7 of the acknowledge byte $E03A, i.e. until the UART
; NMIs are running again.
; In:  $E03B (link active flag)
; Out: $E039 = 0; blocks until $E03A bit 7 is clear
; Called from: resumeCommAfterDiskAccess ($10D1)
; ----------------------------------------------------------------------
resumeCommModule:
        lda     isLinkActive            ; 0F71  is the link up?  (also trainerAiE000:isLinkActive)
        beq     L_0F80                  ; 0F74  no link - nothing to resume
        lda     #$00                    ; 0F76  0 = 'no suspend request'
        sta     nmiSuspendRequest       ; 0F78  release the module  (also trainerAiE000:nmiSuspendRequest)
L_0F7B:
        lda     nmiSuspendAck           ; 0F7B  poll the acknowledge byte ...  (also trainerAiE000:nmiSuspendAck)
        bmi     L_0F7B                  ; 0F7E  ... until the module reports it is running again
L_0F80:
        rts                             ; 0F80  back to normal service


; Three status-line strings used by the disk routines below, each terminated by a character with bit 7
; set.  showDiskMessageAndFail is called with A = low byte, Y = high byte ($0F).
msgNotADataDisk:
        .byte   "NOT A DATA DISK",'!'|$80; 0F81  shown when the disk in the drive is not the film/data disk ('EA')

; msgInsertGameDisk: text, 17 bytes. 'INSERT GAME DISK.' - shown when the 'OZ' game disk is missing.
msgInsertGameDisk:
        .byte   "INSERT GAME DISK",'.'|$80; 0F91  shown when the disk in the drive is not the game disk ('OZ')

; msgDiskError: text, 11 bytes. 'DISK ERROR.' - shown by diskErrorExit when the BAM read fails.
msgDiskError:
        .byte   "DISK ERROR",'.'|$80    ; 0FA2  shown when the BAM read itself failed (drive error or no disk)

; diskIdTable: two 2-character disk ids indexed by X (X=0 -> 'OZ' = the game disk, X=2 -> 'EA' = the
; film/data disk).  checkDiskId reads the first character with diskIdTable,X and the second with
; $0FAE,X and compares them against the id field of the BAM at $02A2/$02A3.
diskIdTable:
        .byte   "O"                     ; 0FAD  first character of the game-disk id 'OZ' = Ozark Softscape
D_0FAE:
        .byte   $5A                     ; 0FAE  'Z' completes 'OZ'; 'E','A' is the data-disk id used for game films
        .byte   $45,$41                 ; 0FAF  EA

; ----------------------------------------------------------------------
; checkDiskId - Verifies which disk is in the drive.  Reads the BAM (track 18 sector 0, which is not
; encrypted) to $0200 and compares the disk id at $02A2/$02A3 with the two characters of
; diskIdTable[X]; the two expected characters and the 'silent' flag are stuffed into instruction
; operands rather than variables.  For the game disk (X=0), outside film playback, it also copies the
; ten bytes at BAM offset $AB - the owner name the game writes onto each player's disk - into
; ownPlayerName ($FFDE), which buildOwnerNameString turns into the printed owner line and which is
; exchanged with the opponent.  NOTE: the read clobbers $0200-$02FF, so callers reload the setup
; block.
; In:  A = silent flag (bit 7 set = fail without a message), X = 0 ('OZ') or 2 ('EA')
; Out: C=0 ok / C=1 wrong disk or drive error, diskIdIndex ($92F0), $FFDE-$FFE7, $0200-$02FF clobbered
; Called from: waitForGameDisk ($10E3) and the film save/load code in overlay A ($7E53, $7E5C)
; ----------------------------------------------------------------------
checkDiskId:
        sta     L_0FFE+1                ; 0FB1  self-modifying: the caller's silent flag becomes the operand of the LDA #$FF at $0FFE
        stx     diskIdIndex             ; 0FB4  remember which id we are looking for (X is about to be destroyed)
        ldy     diskIdTable,x           ; 0FB7  Y = expected first id character ('O' or 'E')
        lda     D_0FAE,x                ; 0FBA  A = expected second id character ('Z' or 'A'), from diskIdTable+1
        tax                             ; 0FBD  park it in X so both can be written into the compare operands
        sty     L_0FDC+1                ; 0FBE  self-modifying: patch the CMP #$FF at $0FDC with the expected first character
        stx     L_0FE3+1                ; 0FC1  self-modifying: patch the CMP #$FF at $0FE3 with the expected second character
        ldx     #$00                    ; 0FC4  BAM destination low byte $00 ...
        ldy     #$02                    ; 0FC6  ... high byte $02: the sector lands at $0200 (over the terrain rule table)
        jsr     setLoadDest             ; 0FC8  arm the loader
        lda     #$01                    ; 0FCB  one sector
        ldy     #$12                    ; 0FCD  track $12 = 18, the directory track
        ldx     #$00                    ; 0FCF  sector 0 = the BAM
        jsr     loadSectorsDelayed      ; 0FD1  read it with the usual 8-frame settling delay
        bcc     L_0FD9                  ; 0FD4  C=0: the BAM is in memory
        jmp     diskErrorExit           ; 0FD6  drive error / no disk -> 'DISK ERROR.' and C=1

L_0FD9:
        lda     bamDiskIdChar0          ; 0FD9  first character of the disk id in the BAM  (also trainerPlaybook0200:play1Grp12)
L_0FDC:
        cmp     #$FF                    ; 0FDC  compare against the expected first character (operand patched at $0FBE)
        bne     L_0FFE                  ; 0FDE  wrong disk
        lda     bamDiskIdChar1          ; 0FE0  second character of the disk id  (also trainerPlaybook0200:play1Grp13)
L_0FE3:
        cmp     #$FF                    ; 0FE3  compare against the expected second character (operand patched at $0FC1)
        bne     L_0FFE                  ; 0FE5  wrong disk
        lda     diskIdIndex             ; 0FE7  which id were we checking?
        bne     L_1014                  ; 0FEA  non-zero = the 'EA' data disk: nothing else to do, report success
        lda     filmPlaybackMode        ; 0FEC  game disk: is a film being replayed?
        bmi     L_1014                  ; 0FEF  during playback the film supplies the names, so leave $FFDE alone
        ldx     #$09                    ; 0FF1  copy 10 bytes, index 9 down to 0
L_0FF3:
        lda     bamDiskSerial,x         ; 0FF3  BAM offset $AB = the owner name EA writes into the unused tail of the BAM  (also trainerPlaybook0200:play1Grp21)
        sta     ownPlayerName,x         ; 0FF6  -> ownPlayerName ($FFDE-$FFE7), used by buildOwnerNameString and the link handshake
        dex                             ; 0FF9  next byte
        bpl     L_0FF3                  ; 0FFA  loop until index 0 has been copied
        bmi     L_1014                  ; 0FFC  always taken (X went to $FF): report success
L_0FFE:
        lda     #$FF                    ; 0FFE  operand patched at $0FB1 with the caller's silent flag
        bmi     L_1016                  ; 1000  bit 7 set = caller wants a silent failure -> just return C=1
        lda     #$91                    ; 1002  low byte of msgInsertGameDisk ($0F91) ...
        ldy     #$0F                    ; 1004  ... and its high byte
        ldx     L_0FDC+1                ; 1006  which id did we expect (the patched compare operand)?
        cpx     #$45                    ; 1009  'E' means we wanted the data disk 'EA'
        bne     L_1011                  ; 100B  wanted the game disk -> keep 'INSERT GAME DISK.'
L_100D:
        lda     #$81                    ; 100D  low byte of msgNotADataDisk ($0F81) - overlay A jumps straight in here at $7E71
        ldy     #$0F                    ; 100F  ... and its high byte
L_1011:
        jmp     showDiskMessageAndFail  ; 1011  show it, beep, pause and return C=1

L_1014:
        clc                             ; 1014  C=0 = the expected disk is in the drive
        rts                             ; 1015  success return

L_1016:
        sec                             ; 1016  C=1 = wrong disk
        rts                             ; 1017  silent failure return


; ----------------------------------------------------------------------
; diskErrorExit - Loads the address of msgDiskError ('DISK ERROR.') and falls into
; showDiskMessageAndFail.
; In:  none
; Out: message shown, C=1
; Called from: checkDiskId ($0FD6) and overlay A ($7E9D)
; ----------------------------------------------------------------------
diskErrorExit:
        lda     #$A2                    ; 1018  low byte of msgDiskError ($0FA2) ...
        ldy     #$0F                    ; 101A  ... and its high byte

; ----------------------------------------------------------------------
; showDiskMessageAndFail - Common failure exit of the disk routines: error beep, put the message on
; the centred status line for about two seconds, blank the line again and return C=1.
; In:  A = message address low byte, Y = high byte (bit-7 terminated string)
; Out: C=1, status line restored
; Called from: checkDiskId ($1011) and diskErrorExit by fall-through
; ----------------------------------------------------------------------
showDiskMessageAndFail:
        pha                             ; 101C  the message pointer's low byte has to survive the beep
        lda     #$03                    ; 101D  sound 3 = the error buzz
        jsr     playSound               ; 101F  queue it (playSound preserves Y, which still holds the message page)
        pla                             ; 1022  get the low byte back
        jsr     showMessageString       ; 1023  print the string centred on the 36-column status line
        lda     #$64                    ; 1026  $64 = 100 frames, about 2 seconds ...
        jsr     waitFrames              ; 1028  ... so the player can read it
        jsr     clearMessageLine        ; 102B  blank the status line again
        sec                             ; 102E  C=1 = the operation failed
        rts                             ; 102F  back to the caller


; ----------------------------------------------------------------------
; ensureOverlayAResident - Makes sure the $6F00-$87FF overlay variant A (map generator, setup and menu
; code) is in memory before control leaves for handleMainMenuChoice ($7DE3).  overlayVariantFlag
; ($87FF) is the identity byte carried by whichever overlay is resident: $FF = variant B, so variant A
; must be read in full; $00 = variant A is already there and only the $8800 message strings and the
; $EC00 comm tail (which sub-overlays borrow) need refreshing.
; In:  overlayVariantFlag ($87FF)
; Out: variant A resident plus fresh strings/comm tail; comm module suspended and resumed around the
;     disk access
; Called from: $1C72, with diskLoadDepth bumped around the call
; ----------------------------------------------------------------------
ensureOverlayAResident:
        jsr     suspendCommForDiskAccess; 1030  park the comm module and put 'WORKING...' on the status line
        jsr     waitForGameDiskInserted ; 1033  make sure the OZ disk is really in the drive first
        lda     overlayVariantFlag      ; 1036  identity byte of the resident $6F00 overlay  (also mapGenerator6F00:overlayVariantFlag)
        bmi     L_1041                  ; 1039  $FF = variant B is resident -> the whole of variant A has to be read
        jsr     loadStringsAndCommTail  ; 103B  variant A is already there: just refresh $8800-$89FF and $EC00-$EFFF
        jmp     resumeCommAfterDiskAccess; 103E  restart the comm module, clear the message and return

L_1041:
        jsr     loadOverlayA            ; 1041  read variant A (which chains the strings and the comm tail)
        jmp     resumeCommAfterDiskAccess; 1044  restart the comm module, clear the message and return


; ----------------------------------------------------------------------
; loadOverlayA - Reads the $6F00-$87FF overlay variant A: track 31 sectors 0-16 to $6F00-$7FFF and
; track 32 sectors 0-7 to $8000-$87FF.  Variant A is the random map generator, the setup phase and the
; film menus.  Falls into loadStringsAndCommTail.
; In:  none
; Out: $6F00-$87FF = variant A (its last byte $87FF is the identity byte $00), plus $8800-$89FF and
;     $EC00-$EFFF
; Called from: loadStartupOverlays ($0F13) and ensureOverlayAResident ($1041)
; ----------------------------------------------------------------------
loadOverlayA:
        ldx     #$00                    ; 1047  destination low byte $00 ...
        ldy     #$6F                    ; 1049  ... high byte $6F: the overlay starts at $6F00
        jsr     setLoadDest             ; 104B  arm the loader
        ldy     #$1F                    ; 104E  track $1F = 31
        ldx     #$00                    ; 1050  from sector 0
        lda     #$11                    ; 1052  $11 = 17 sectors -> $6F00-$7FFF
        jsr     loadSectorsDelayed      ; 1054  read them
        ldy     #$20                    ; 1057  track $20 = 32
        ldx     #$00                    ; 1059  from sector 0
        lda     #$08                    ; 105B  8 sectors -> $8000-$87FF, ending with the identity byte at $87FF
        jsr     loadSectorsDelayed      ; 105D  read them

; ----------------------------------------------------------------------
; loadStringsAndCommTail - Reads the modem status-message string block (track 29 sectors 9-10) into
; $8800-$89FF, then falls into loadCommTailBuild2.  In a solo game the trainer overwrites this block
; with its own per-unit tables, so it has to be reloaded whenever the strings are wanted again.
; In:  none
; Out: $8800-$89FF strings, $EC00-$EFFF comm tail
; Called from: ensureOverlayAResident ($103B); reached by fall-through from loadOverlayA
; ----------------------------------------------------------------------
loadStringsAndCommTail:
        ldx     #$00                    ; 1060  destination low byte $00 ...
        ldy     #$88                    ; 1062  ... high byte $88
        jsr     setLoadDest             ; 1064  arm the loader
        ldy     #$1D                    ; 1067  track $1D = 29
        ldx     #$09                    ; 1069  from sector 9
        lda     #$02                    ; 106B  2 sectors -> $8800-$89FF
        jsr     loadSectorsDelayed      ; 106D  read them

; ----------------------------------------------------------------------
; loadCommTailBuild2 - Reads the last page group of the modem driver (track 34 sectors 12-15) into
; $EC00-$EFFF.  Overlay A also calls it to put the comm tail back after the $EC00 sub-overlay from
; track 29 has temporarily occupied that space.
; In:  none
; Out: $EC00-$EFFF = the modem driver tail
; Called from: overlay A ($7B33); reached by fall-through from loadStringsAndCommTail
; ----------------------------------------------------------------------
loadCommTailBuild2:
        ldx     #$00                    ; 1070  destination low byte $00 ...
        ldy     #$EC                    ; 1072  ... high byte $EC
        jsr     setLoadDest             ; 1074  arm the loader
        ldy     #$22                    ; 1077  track $22 = 34
        ldx     #$0C                    ; 1079  from sector $0C = 12
        lda     #$04                    ; 107B  4 sectors -> $EC00-$EFFF
        jmp     loadSectorsDelayed      ; 107D  read them and return straight to our caller


; ----------------------------------------------------------------------
; loadOverlayBIfNotResident - Loads the $6F00 overlay variant B only when it is not already in memory.
; overlayVariantFlag ($87FF) is $00 while variant A is resident and $FF while B is, so a positive
; value means the swap has to happen.
; In:  overlayVariantFlag ($87FF)
; Out: see loadOverlayB, or nothing at all
; Called from: startGameFromSetup ($0B5F)
; ----------------------------------------------------------------------
loadOverlayBIfNotResident:
        lda     overlayVariantFlag      ; 1080  identity byte of the resident $6F00 overlay  (also mapGenerator6F00:overlayVariantFlag)
        bpl     loadOverlayB            ; 1083  $00 = variant A is resident -> swap variant B in
        rts                             ; 1085  variant B is already there, nothing to do


; ----------------------------------------------------------------------
; loadOverlayB - Reads the $6F00-$87FF overlay variant B (the comcen missile and drone screens): track
; 33 sectors 0-16 to $6F00-$7FFF and track 32 sectors 8-15 to $8000-$87FF.  Afterwards it copies the
; byte at $206B into $750A: both are the opcode byte of a BCC/BCS that follows a 'is this unit index
; below 50' test, i.e. the side-dependent branch the main program has already patched for this
; player's side, propagated into the freshly loaded overlay.
; In:  $206B (the patched branch opcode), overlayVariantFlag
; Out: $6F00-$87FF = variant B, $750A patched, comm module suspended and resumed
; Called from: loadOverlayBIfNotResident ($1083)
; ----------------------------------------------------------------------
loadOverlayB:
        jsr     suspendCommForDiskAccess; 1086  park the comm module and show 'WORKING...' - the fast loader needs the interrupts
        ldx     #$00                    ; 1089  destination low byte $00 ...
        ldy     #$6F                    ; 108B  ... high byte $6F
        jsr     setLoadDest             ; 108D  arm the loader
        ldy     #$21                    ; 1090  track $21 = 33
        lda     #$11                    ; 1092  $11 = 17 sectors -> $6F00-$7FFF
        ldx     #$00                    ; 1094  from sector 0
        jsr     loadSectorsDelayed      ; 1096  read them
        ldy     #$20                    ; 1099  track $20 = 32
        ldx     #$08                    ; 109B  from sector 8 (sectors 0-7 of that track belong to variant A)
        lda     #$08                    ; 109D  8 sectors -> $8000-$87FF
        jsr     loadSectorsDelayed      ; 109F  read them
        lda     D_206B                  ; 10A2  the branch opcode ($90 BCC / $B0 BCS) that patchOwnSideBranches wrote at $206B for this player's side
        sta     radarSideBranchOpcode   ; 10A5  patch the matching 'cpy #$32 / branch' in the overlay at $7508-$750A
        jmp     resumeCommAfterDiskAccess; 10A8  restart the comm module, clear the message and return


; ----------------------------------------------------------------------
; loadUnitTemplatesAndSetup - Reads track 18 sectors 8-14 into $F000-$F6FF, then falls into
; loadSetupBlock.  What is on the disk there is not a battlefield: it is four 400-byte unit
; start-position templates (columns, rows, flags and types for 100 units each) which the setup code
; picks from and copies into the live unit arrays; the 40x40 map is generated, not loaded.
; In:  none
; Out: $F000-$F6FF = the start-position templates, $0200-$03FF = the setup block
; Called from: startGameFromSetup ($0B35), with diskLoadDepth bumped so the IRQ drops keypresses
; ----------------------------------------------------------------------
loadUnitTemplatesAndSetup:
        ldx     #$00                    ; 10AB  destination low byte $00 ...
        ldy     #$F0                    ; 10AD  ... high byte $F0
        jsr     setLoadDest             ; 10AF  arm the loader
        ldy     #$12                    ; 10B2  track $12 = 18, the directory track
        ldx     #$08                    ; 10B4  from sector 8
        lda     #$07                    ; 10B6  7 sectors -> $F000-$F6FF (four 400-byte templates plus 192 unused bytes)
        jsr     loadSectorsDelayed      ; 10B8  read them

; ----------------------------------------------------------------------
; loadSetupBlock - Reads the saved setup block (track 18 sectors 15-16) into $0200-$03FF: the terrain
; tile rule table at $0200 and the persisted game settings plus the little start-up routine at $0300.
; In:  none
; Out: $0200-$03FF
; Called from: mainStart ($0A84); reached by fall-through from loadUnitTemplatesAndSetup
; ----------------------------------------------------------------------
loadSetupBlock:
        ldx     #$00                    ; 10BB  destination low byte $00 ...
        ldy     #$02                    ; 10BD  ... high byte $02
        jsr     setLoadDest             ; 10BF  arm the loader
        ldy     #$12                    ; 10C2  track $12 = 18
        ldx     #$0F                    ; 10C4  from sector $0F = 15
        lda     #$02                    ; 10C6  2 sectors -> $0200-$03FF
        jmp     loadSectorsDelayed      ; 10C8  read them and return straight to our caller


; ----------------------------------------------------------------------
; suspendCommForDiskAccess - Standard prologue for disk access: pause the comm module, then set
; persistentMessageId to 1 and paint message 1 ('WORKING...') on the status line so the player knows
; the drive is busy.
; In:  none
; Out: comm module paused, persistentMessageId ($92A1) = 1
; Called from: ensureOverlayAResident ($1030), loadOverlayB ($1086), waitForGameDiskInserted ($10DC)
;     and overlay A ($7B7D, $7DF7, $7F57, $815E)
; ----------------------------------------------------------------------
suspendCommForDiskAccess:
        jsr     suspendCommModule       ; 10CB  stop the UART NMIs so the fast loader can keep its timing
        jmp     showWorkingMessage      ; 10CE  show the 'WORKING...' notice and return to our caller


; ----------------------------------------------------------------------
; resumeCommAfterDiskAccess - Epilogue for disk access: restart the comm module, forget the persistent
; 'WORKING...' message and blank the status line.
; In:  none
; Out: comm module running, persistentMessageId ($92A1) = 0, status line cleared
; Called from: $0B3D, $103E, $1044, $10A8 and overlay A ($7DFE, $7F95, $81A2, $81AF)
; ----------------------------------------------------------------------
resumeCommAfterDiskAccess:
        jsr     resumeCommModule        ; 10D1  let the module's NMIs run again
        lda     #$00                    ; 10D4  no message is being held on the line any more
        sta     persistentMessageId     ; 10D6  clear the persistent message id
        jmp     clearMessageLine        ; 10D9  blank the status line and return to our caller


; ----------------------------------------------------------------------
; waitForGameDiskInserted - Blocks until the OZ game disk is in the drive.  Suspends the comm module
; (and leaves it suspended for the caller), then loops: check the disk id verbosely, and on failure
; print 'PRESS BUTTON WHEN READY.' and wait for a fire click before trying again.  Because that prompt
; stirs the RNG while it waits, the delay also randomises the map seed.
; In:  none
; Out: returns only with C=0 from checkDiskId; comm module left suspended
; Called from: mainStart ($0ADC), ensureOverlayAResident ($1033) and overlay A ($81AC)
; ----------------------------------------------------------------------
waitForGameDiskInserted:
        jsr     suspendCommForDiskAccess; 10DC  pause the comm module and show 'WORKING...'

; ----------------------------------------------------------------------
; Retry loop: ask for the game disk until it is there.
; ----------------------------------------------------------------------
waitForGameDisk:
        ldx     #$00                    ; 10DF  id index 0 = the game disk 'OZ'
        lda     #$00                    ; 10E1  A bit 7 clear = report a mismatch on the status line
        jsr     checkDiskId             ; 10E3  read the BAM and compare the id
        bcc     L_10EE                  ; 10E6  C=0: the right disk is in the drive
        jsr     promptPressButtonWhenReady; 10E8  wrong disk: prompt and wait for the fire button
        jmp     waitForGameDisk         ; 10EB  then try again

L_10EE:
        rts                             ; 10EE  the game disk is present


; spriteEnableScratch: written by the raster IRQ at $11CB with the sprite enable mask it is about to
; put into VIC_SPR_ENA.  Nothing ever reads it back (a dead store); $10F0 is unused padding before the
; interrupt handler.
spriteEnableScratch:
        .byte   $00,$00                 ; 10EF  ..  copy of the last sprite enable mask written by the IRQ; never read

; ----------------------------------------------------------------------
; rasterIrqHandler - The game's only interrupt handler, entered through the RAM vector $FFFE at raster
; line $FA (250), installed by installRasterIrq ($0C66).  A BRK is recognised from the B flag in the
; pushed status byte and thrown away through the patchable JMP operand at $10FF/$1100 (a debug hook
; that initInterruptVectors points at irqExit).  Phase 1 acknowledges the VIC interrupt, re-arms the
; raster line and the $FFFE/$FFFF vector from their zero-page shadows, guards against re-entry with
; irqInProgress, copies the two VIC control registers from their shadows and services the comm module
; ($E000 with X=0 and $E00C); this half also runs while a disk load is in progress.  Phase 2 copies
; the remaining VIC shadows and the eight sprite colour/X/Y shadows (colours >= $10 hold two nibbles
; that alternate every other frame to make a sprite flash; positions can be biased by the fine-scroll
; offsets), bumps the 24-bit frame counter, re-enables interrupts, polls the keyboard and joystick 2,
; maintains the idle counter, the RNG and the six frame countdowns, watches for the SHIFT+C=+F7 abort,
; blinks the map cursor, ticks the frame timers, runs both halves of the chat and drives the sound.
; In:  VIC and CIA1 registers, the shadow block $9000-$9024, inputLockoutTimer ($0B7D), diskLoadDepth
;      ($0BA7), screenShakeTimer ($92C8), spriteScrollFineX/Y ($92F4/$92F5), comm entries $E000/$E00C
; Out: every VIC register, frameCounter (zp_45-zp_47), joyDirection/joyFireUp and their copies,
;      requestedScreenKey, irqInProgress, the timers zp_6C-zp_71, the chat buffers and the sound state
; Called from: the hardware IRQ vector $FFFE (and BRK)
; ----------------------------------------------------------------------
rasterIrqHandler:
        pha                             ; 10F1  save the registers the handler is about to use
        txa                             ; 10F2  X ...
        pha                             ; 10F3  ... onto the stack
        tya                             ; 10F4  Y ...
        pha                             ; 10F5  ... onto the stack
        tsx                             ; 10F6  S now points 6 bytes below the pushed processor status
        lda     D_0104,x                ; 10F7  $0104+S = the status byte the CPU pushed when it took the interrupt
        and     #$10                    ; 10FA  bit 4 = the B flag: set means this was a BRK, not a raster IRQ
        beq     irqNotBrk               ; 10FC  a real interrupt - carry on
L_10FE:
        jmp     irqExit                 ; 10FE  BRK debug hook: the operand at $10FF/$1100 is patched by initInterruptVectors to irqExit


; ----------------------------------------------------------------------
; Acknowledge the VIC interrupt and re-arm both the raster line and the RAM interrupt vector; the
; vector is rewritten every frame because the film code temporarily flips $01 to see the RAM under the
; I/O area.
; ----------------------------------------------------------------------
irqNotBrk:
        lda     #$01                    ; 1101  bit 0 = the raster interrupt latch ...
        sta     VIC_IRQ_FLAG            ; 1103  ... written back to clear it
        lda     rasterIrqLine           ; 1106  the raster line the game wants ($FA = 250, below the visible playfield)
        sta     VIC_RASTER              ; 1108  re-arm VIC_RASTER for the next frame
        lda     irqVectorShadowLo       ; 110B  shadow of the IRQ vector low byte ...
        sta     irqVector               ; 110D  ... written back into $FFFE
        lda     irqVectorShadowHi       ; 1110  shadow of the high byte ...
        sta     irqVectorHi             ; 1112  ... written back into $FFFF
        lda     irqInProgress           ; 1115  is a previous invocation still running? (the handler re-enables interrupts halfway through)
        bne     irqBailOut              ; 1117  yes - do nothing this frame
        inc     irqInProgress           ; 1119  mark the handler as busy
        lda     vicCtrl1Shadow          ; 111B  shadow of VIC_CTRL1 (screen mode, raster bit 8, Y scroll)
        sta     VIC_CTRL1               ; 111E  -> $D011
        lda     vicCtrl2Shadow          ; 1121  shadow of VIC_CTRL2 (multicolour, X scroll)
        sta     VIC_CTRL2               ; 1124  -> $D016
        ldx     #$00                    ; 1127  function 0 = the comm module's per-frame service tick
        jsr     commJumpTable           ; 1129  run the UART / trainer bit-and-byte timing  (also trainerAiE000:commRequestEntry)
        jsr     pollLinkStatus          ; 112C  and the suspend handshake plus the carrier-detect sampling  (also trainerAiE000:commFrameTickEntry)
        lda     diskLoadDepth           ; 112F  how deeply nested is the current disk load?
        bpl     irqPhase2               ; 1132  always taken in practice - nothing ever makes diskLoadDepth negative, so the bail-out below is effectively dead
irqBailOut:
        jmp     irqExit                 ; 1134  bail out with only the comm module serviced


; ----------------------------------------------------------------------
; Phase 2: push the rest of the VIC shadow block ($9002-$9006, $9020, $9023, $9024) into the chip.
; Keeping shadows and copying them once per frame means the game never has to worry about a partly
; updated register being displayed.
; ----------------------------------------------------------------------
irqPhase2:
        lda     vicMemPtrShadow         ; 1137  shadow of VIC_MEM_PTR (bitmap at $A000, video matrix at $8C00 within VIC bank 2)
        sta     VIC_MEM_PTR             ; 113A  -> $D018
        lda     vicBorderShadow         ; 113D  border colour shadow
        sta     VIC_BORDER              ; 1140  -> $D020
        lda     vicBg0Shadow            ; 1143  background colour 0 shadow
        sta     VIC_BG0                 ; 1146  -> $D021
        lda     vicBg1Shadow            ; 1149  multicolour background 1 shadow
        sta     VIC_BG1                 ; 114C  -> $D022
        lda     vicBg2Shadow            ; 114F  multicolour background 2 shadow
        sta     VIC_BG2                 ; 1152  -> $D023
        lda     spriteXMsbShadow        ; 1155  sprite X bit-8 mask shadow
        sta     VIC_SPR_HI_X            ; 1158  -> $D010
        lda     spriteXExpandShadow     ; 115B  sprite X expand mask shadow
        sta     VIC_SPR_EXP_X           ; 115E  -> $D01D
        lda     spriteYExpandShadow     ; 1161  sprite Y expand mask shadow
        sta     VIC_SPR_EXP_Y           ; 1164  -> $D017

; ----------------------------------------------------------------------
; Sprite position bias: while screenShakeTimer is running the sprites are shifted by the current fine
; scroll offsets so they stay glued to the smooth-scrolled bitmap; otherwise both biases are zero. The
; two offsets are written into the ADC operands at $11B6 and $11BF.
; ----------------------------------------------------------------------
        lda     screenShakeTimer        ; 1167  is the screen being shaken / fine-scrolled?
        bne     irqUseScrollOffsets     ; 116A  yes - derive the offsets from the scroll position
        sta     L_11B5+1                ; 116C  no bias: patch the X adder at $11B5 with 0
        sta     L_11BE+1                ; 116F  and the Y adder at $11BE with 0
        beq     irqSpriteLoopSetup      ; 1172  always taken (A is still 0)
irqUseScrollOffsets:
        lda     spriteScrollFineY       ; 1174  vertical fine-scroll position
        and     #$07                    ; 1177  only the 0-7 pixel offset
        sec                             ; 1179  prepare a borrow-free subtraction
        sbc     #$03                    ; 117A  centre it: -3..+4 pixels
        sta     L_11BE+1                ; 117C  patch the Y adder operand at $11BE
        lda     spriteScrollFineX       ; 117F  horizontal fine-scroll position
        and     #$07                    ; 1182  only the 0-7 pixel offset
        sta     L_11B5+1                ; 1184  patch the X adder operand at $11B5

; ----------------------------------------------------------------------
; Copy the eight sprite shadows into the VIC.  Y counts the sprite index 7..0 and X the register
; offset 2*index, so $D000+X / $D001+X are that sprite's X and Y registers.  A colour shadow below
; $10 is a plain colour; $10 and above packs two nibbles which are swapped every other frame to make
; the sprite flash between two colours.
; ----------------------------------------------------------------------
irqSpriteLoopSetup:
        ldy     #$07                    ; 1187  start with sprite 7
        ldx     #$0E                    ; 1189  $0E = 7*2, the X/Y register offset of sprite 7
        lda     frameCounter            ; 118B  free-running frame counter ...
        ror     a                       ; 118D  ... rotated right twice ...
        ror     a                       ; 118E  ... so that bit 7 of the result is bit 0 of the frame counter
        sta     spriteFlashPhase        ; 118F  the flash phase: negative on every other frame
irqSpriteLoop:
        lda     spriteColourShadow,y    ; 1191  colour shadow of sprite Y ($9008+Y)
        cmp     #$10                    ; 1194  values 0-$0F are ordinary colours
        bcc     irqWriteSpriteColour    ; 1196  plain colour - write it straight out
        bit     spriteFlashPhase        ; 1198  two packed nibbles: only swap them on the flashing frames
        bpl     irqSpritePosition       ; 119A  off-phase frame - leave the colour register untouched
        eor     VIC_SPR0_COLOR,y        ; 119C  compare the shadow with what the register currently holds
        and     #$0F                    ; 119F  look at the low nibble of the difference
        cmp     #$01                    ; 11A1  C=1 when the register does not already show the shadow's low nibble
        lda     spriteColourShadow,y    ; 11A3  reload the packed shadow byte
        bcs     irqWriteSpriteColour    ; 11A6  register differs -> show the low nibble this time
        lsr     a                       ; 11A8  otherwise shift the high nibble down ...
        lsr     a                       ; 11A9  ... (4 shifts) ...
        lsr     a                       ; 11AA  ... so the sprite alternates ...
        lsr     a                       ; 11AB  ... between the two packed colours
irqWriteSpriteColour:
        and     #$0F                    ; 11AC  only the colour bits
        sta     VIC_SPR0_COLOR,y        ; 11AE  -> $D027+Y, the sprite's colour register
irqSpritePosition:
        lda     spriteXShadow,y         ; 11B1  X position shadow of sprite Y ($9010+Y)
        clc                             ; 11B4  clear carry for the bias add
L_11B5:
        adc     #$FF                    ; 11B5  add the horizontal bias (operand patched at $116C or $1184)
        sta     VIC_SPR0_X,x            ; 11B7  -> $D000+X, the sprite's X register
        lda     spriteYShadow,y         ; 11BA  Y position shadow of sprite Y ($9018+Y)
        clc                             ; 11BD  clear carry for the bias add
L_11BE:
        adc     #$FF                    ; 11BE  add the vertical bias (operand patched at $116F or $117C)
        sta     VIC_SPR0_Y,x            ; 11C0  -> $D001+X, the sprite's Y register
        dex                             ; 11C3  step the register offset ...
        dex                             ; 11C4  ... down by two
        dey                             ; 11C5  next sprite
        bpl     irqSpriteLoop           ; 11C6  loop until sprite 0 has been done
        lda     spriteEnableShadow      ; 11C8  sprite enable mask shadow
        sta     spriteEnableScratch     ; 11CB  keep a copy at $10EF (nothing ever reads it back)
        sta     VIC_SPR_ENA             ; 11CE  -> $D015, so sprites appear and disappear in step with the rest of the frame

; ----------------------------------------------------------------------
; Bump the free-running 24-bit frame counter zp_45/zp_46/zp_47.
; ----------------------------------------------------------------------
        inc     frameCounter            ; 11D1  low byte of the frame counter
        bne     irqAfterSprites         ; 11D3  no carry into the middle byte
        inc     frameCounterMid         ; 11D5  middle byte
        bne     irqAfterSprites         ; 11D7  no carry into the high byte
        inc     frameCounterHi          ; 11D9  high byte: wraps after about 77 hours

; ----------------------------------------------------------------------
; From here on the handler runs with interrupts enabled - irqInProgress keeps a second raster IRQ from
; re-entering it.  inputLockoutTimer counts frames during which the keyboard and joystick are not
; sampled at all, so joyDirection and joyFireUp keep whatever they had.
; ----------------------------------------------------------------------
irqAfterSprites:
        cli                             ; 11DB  everything time-critical is done; allow interrupts again
        lda     inputLockoutTimer       ; 11DC  is input still locked out (after a screen change or an abort)?
        beq     irqPollInput            ; 11DF  no - sample the keyboard and the stick
        dec     inputLockoutTimer       ; 11E1  count the lockout down one frame ...
        jmp     irqIdleCounters         ; 11E4  ... and skip the whole input poll

irqPollInput:
        jsr     pollKeyboardEvent       ; 11E7  scan the key matrix through the comm module's keyboard entry
        lda     #$FF                    ; 11EA  default 'joystick idle' pattern in case a key is pending
        ldy     requestedScreenKey      ; 11EC  did the poll leave a screen-select key pending (0-3 = F1/F3/F5/F7, $FF = none)?
        bpl     irqStoreJoystick        ; 11EE  yes - do not read the stick this frame
        ldy     newKeyEvent             ; 11F0  is a typed key waiting to be consumed?
        cpy     #$FF                    ; 11F3  $FF = no key
        bne     irqStoreJoystick        ; 11F5  a key is pending - do not read the stick this frame
        lda     #$FF                    ; 11F7  deselect every keyboard row ...
        sta     CIA1_PRA                ; 11F9  ... so CIA1 port A shows joystick 2 instead of the matrix
L_11FC:
        lda     CIA1_PRA                ; 11FC  read the port ...
        nop                             ; 11FF  ... let it settle ...
        cmp     CIA1_PRA                ; 1200  ... and read it again
        bne     L_11FC                  ; 1203  the two reads disagree (contact bounce) - try once more
irqStoreJoystick:
        and     #$1F                    ; 1205  keep bits 0-4: up, down, left, right, fire (all active low)
        ldy     #$01                    ; 1207  assume the button is up
        cmp     #$10                    ; 1209  bit 4 = the fire button
        bcs     L_120E                  ; 120B  bit set = button not pressed - keep Y=1
        dey                             ; 120D  button down -> Y=0
L_120E:
        sty     joyFireUp               ; 120E  latch the fire state for the whole game
        sty     joyFireUpCopy           ; 1210  second copy - nothing ever reads it (dead store)
        and     #$0F                    ; 1212  keep the four direction bits ($0F = centred)
        sta     joyDirection            ; 1214  latch the direction for the whole game
        sta     joyDirectionCopy        ; 1216  second copy - nothing ever reads it (dead store)

; ----------------------------------------------------------------------
; inputIdleFrames counts consecutive frames with the stick centred and the button up, saturating at
; $FF; anything else resets it to 0.  The pause/attract logic elsewhere uses it.
; ----------------------------------------------------------------------
irqIdleCounters:
        lda     joyDirection            ; 1218  the latched direction nibble
        cmp     #$0F                    ; 121A  $0F = all four switches open = centred
        bne     L_1227                  ; 121C  the stick is being pushed
        lda     #$00                    ; 121E  stick centred: forget any held direction ...
        sta     joyRepeatMask           ; 1220  ... by clearing the direction repeat mask
        lda     joyFireUp               ; 1223  is the fire button also up?
        bne     L_122E                  ; 1225  yes - nothing at all is being touched
L_1227:
        lda     #$00                    ; 1227  some input is active ...
        sta     inputIdleFrames         ; 1229  ... so restart the idle counter
        beq     L_1238                  ; 122C  always taken
L_122E:
        lda     inputIdleFrames         ; 122E  frames of no input so far
        cmp     #$FF                    ; 1231  already saturated?
        beq     L_1238                  ; 1233  yes - leave it at $FF
        inc     inputIdleFrames         ; 1235  another idle frame
L_1238:
        jsr     stepRandomSeed          ; 1238  stir the 24-bit game RNG once per frame
        ldx     #$05                    ; 123B  index 5 down to 0 covers zp_71..zp_6C

; ----------------------------------------------------------------------
; Decrement the six frame countdowns zp_6C-zp_71 (joystick repeat, fire debounce, menu blink, marker
; blink, key repeat and one spare), each only while it is non-zero.
; ----------------------------------------------------------------------
irqTimerLoop:
        lda     joyRepeatTimer,x        ; 123D  timer zp_6C+X
        beq     L_1243                  ; 123F  already expired - leave it alone
        dec     joyRepeatTimer,x        ; 1241  one frame closer to expiry
L_1243:
        dex                             ; 1243  next timer
        bpl     irqTimerLoop            ; 1244  loop over all six
        lda     diskLoadDepth           ; 1246  is a disk load or overlay swap in progress?
        beq     irqCheckAbortHotkey     ; 1249  no - function keys may switch screens
        lda     #$FF                    ; 124B  $FF = no screen requested ...
        sta     requestedScreenKey      ; 124D  ... so an F-key pressed during a load is thrown away

; ----------------------------------------------------------------------
; The panic hotkey: SHIFT + C= + F7 abandons whatever is happening and returns to the main menu.
; returnToMainMenu resets the stack pointer, so this JMP never comes back - the interrupt frame is
; simply abandoned (interrupts were already re-enabled at $11DB).
; ----------------------------------------------------------------------
irqCheckAbortHotkey:
        lda     shiftHeldFlag           ; 124F  was SHIFT held during the key scan?
        bpl     irqPerFrameWork         ; 1252  no - not the abort combination
        lda     commodoreHeldFlag       ; 1254  was the Commodore key held too?
        bpl     irqPerFrameWork         ; 1257  no - not the abort combination
        lda     requestedScreenKey      ; 1259  which function key is pending?
        cmp     #$03                    ; 125B  3 = F7
        bne     irqPerFrameWork         ; 125D  not F7 - carry on normally
        lda     #$01                    ; 125F  lock the input out ...
        sta     diskLoadDepth           ; 1261  ... by pretending a disk load is running
        lda     #$FF                    ; 1264  $FF ...
        sta     inputLockoutTimer       ; 1266  ... = the longest possible input lockout
        sta     requestedScreenKey      ; 1269  ... and forget the pending F7
        jmp     returnToMainMenu        ; 126B  never returns: returnToMainMenu rebuilds the stack from scratch


; ----------------------------------------------------------------------
; Per-frame housekeeping that is allowed to run with interrupts on.
; ----------------------------------------------------------------------
irqPerFrameWork:
        lda     currentScreen           ; 126E  which screen is showing?
        bne     L_127A                  ; 1271  not the battlefield screen - no map cursor to blink
        lda     gamePhase               ; 1273  gamePhase: bit 7 marks the phases in which there is no map cursor to blink
        bmi     L_127A                  ; 1275  gamePhase negative -> skip the blink
        jsr     blinkCursorColour       ; 1277  toggle the map cursor sprite colour
L_127A:
        jsr     updateFrameTimers       ; 127A  count the game's own frame timers down
        jsr     handleChatSend          ; 127D  outgoing chat: pick up typed characters and queue them to the opponent
        jsr     receiveChatCharacter    ; 1280  incoming chat: take one received byte and add it to the message line
        jsr     dequeueSoundRequest     ; 1283  take the highest-priority queued sound request into X
        txa                             ; 1286  X = $FF when nothing is queued
        bmi     L_128C                  ; 1287  nothing to start
        jsr     startSound              ; 1289  start sound A on its voices
L_128C:
        jsr     soundPlayerTick         ; 128C  advance the SID sound scripts by one frame
        lda     #$00                    ; 128F  the handler is finished ...
        sta     irqInProgress           ; 1291  ... so a later raster IRQ may run it again

; ----------------------------------------------------------------------
; irqExit - Pulls Y, X and A back off the stack and falls into irqRti.  It is both the normal end of
; rasterIrqHandler and the target that initInterruptVectors patches into the BRK-exit JMP at
; $10FF/$1100.
; In:  the register frame pushed by rasterIrqHandler
; Out: registers restored, RTI
; Called from: $10FE (BRK) and $1134 (early bail-out); reached by fall-through at the end of the
;     handler
; ----------------------------------------------------------------------
irqExit:
        pla                             ; 1293  Y ...
        tay                             ; 1294  ... restored
        pla                             ; 1295  X ...
        tax                             ; 1296  ... restored
        pla                             ; 1297  and A

; ----------------------------------------------------------------------
; irqRti - A bare RTI.  It is the NMI vector target for the whole game: initInterruptVectors and
; setNmiVectorRti put $1298 into $FFFA/$FFFB and into the comm module's own NMI chain vector
; ($E031/$E032), so a stray RS-232 NMI cannot disturb the game when no modem session is running.
; In:  none
; Out: none
; Called from: the NMI vector; reached by fall-through from irqExit
; ----------------------------------------------------------------------
irqRti:
        rti                             ; 1298  return from interrupt


; Three leftover bytes between irqRti and updateFrameTimers.  Nothing references them and they do not
; disassemble into anything sensible - alignment padding or the tail of an edited-out routine.
padIrq1299:
        .byte   $00,$09,$13             ; 1299  ...  unreferenced padding

; ----------------------------------------------------------------------
; updateFrameTimers - Per-frame housekeeping called from the raster IRQ.  It bumps the two
; free-running animation ticks, then runs three groups of counters: an 8-frame group (the
; packet-exchange watchdog, the end-of-game countdown and linkWaitTimer), a 4-frame group (the
; status-message hold timer and the link poll delay) and a per-frame group (step delay, shot line,
; view redraw and marker sprite).  Every countdown is guarded so it stops at 0.
; In:  frameCounter (zp_45), gamePhase (zp_B1)
; Out: $92F8, $92FB, $9223, $92C9, zp_7F, zp_72, $9163, $90FC, $91C8, $9234, $923E
; Called from: rasterIrqHandler ($127A)
; ----------------------------------------------------------------------
updateFrameTimers:
        inc     missileFlightTick       ; 129C  animation tick used by the missile screen
        inc     radarSweepTick          ; 129F  animation tick used by the radar sweep
        lda     frameCounter            ; 12A2  the frame counter drives all the slower groups
        and     #$07                    ; 12A4  every 8th frame ...
        bne     L_12D1                  ; 12A6  ... otherwise skip straight to the 4-frame group
        lda     frameCounter            ; 12A8  which of the two 8-frame slots is this?
        and     #$0F                    ; 12AA  low nibble 0 = one of the two, i.e. every 16 frames
        beq     bumpLinkWatchdog        ; 12AC  16-frame slot: always bump the watchdog
        lda     gamePhase               ; 12AE  the odd 8-frame slot only counts when gamePhase is not $FF ...
        cmp     #$FF                    ; 12B0  ... so at gamePhase $FF the watchdog runs at half speed (every 16 frames)
        beq     L_12C1                  ; 12B2  skip the watchdog this time

; ----------------------------------------------------------------------
; linkTimeoutCounter is the packet-exchange watchdog: checkLinkTimeout ($5A14) arms it with bit 7 set,
; a completed exchange clears it at $4E69, and here it counts up while negative and sticks at
; $FF, which is how the game notices that the opponent has stopped answering.
; ----------------------------------------------------------------------
bumpLinkWatchdog:
        lda     linkTimeoutCounter      ; 12B4  the watchdog value
        bpl     L_12C1                  ; 12B7  positive = not armed, nothing to do
        clc                             ; 12B9  clear carry for the increment
        adc     #$01                    ; 12BA  one more tick towards the timeout
        bcs     L_12C1                  ; 12BC  already at $FF - saturate instead of wrapping to 0
        sta     linkTimeoutCounter      ; 12BE  store the advanced watchdog
L_12C1:
        lda     gameEndCountdown        ; 12C1  gameEndCountdown: the delay from 'game over' to the summary screen
        beq     L_12CB                  ; 12C4  0 = not running
        bmi     L_12CB                  ; 12C6  negative = it has already reached its $80 end stop
        inc     gameEndCountdown        ; 12C8  count it up towards $80
L_12CB:
        lda     linkWaitTimer           ; 12CB  linkWaitTimer: the timeout used while waiting for the comm module
        beq     L_12D1                  ; 12CD  already expired
        dec     linkWaitTimer           ; 12CF  one 8-frame tick closer to zero
L_12D1:
        lda     frameCounter            ; 12D1  the frame counter again ...
        and     #$03                    ; 12D3  ... for the every-4-frames group
        bne     L_12E5                  ; 12D5  not a 4-frame slot
        lda     messageTimer            ; 12D7  messageTimer: how long the status-line message stays up
        beq     L_12DD                  ; 12D9  already expired
        dec     messageTimer            ; 12DB  count it down
L_12DD:
        lda     linkPollDelay           ; 12DD  linkPollDelay: pacing of the link status polls
        beq     L_12E5                  ; 12E0  already expired
        dec     linkPollDelay           ; 12E2  count it down

; ----------------------------------------------------------------------
; The per-frame group: four independent countdowns, each frozen once it reaches 0.
; ----------------------------------------------------------------------
L_12E5:
        lda     stepDelayTimer          ; 12E5  stepDelayTimer: frames until the next unit step of the round
        beq     L_12ED                  ; 12E8  already expired
        dec     stepDelayTimer          ; 12EA  count it down
L_12ED:
        lda     shotLineTimer           ; 12ED  shotLineTimer: how long a shot line stays drawn in the viewport
        beq     L_12F5                  ; 12F0  already expired
        dec     shotLineTimer           ; 12F2  count it down
L_12F5:
        lda     viewRedrawTimer         ; 12F5  viewRedrawTimer: frames until the 7x5 view is redrawn
        beq     L_1301                  ; 12F8  already expired
        cmp     #$80                    ; 12FA  $80 is the 'frozen / redraw pending' sentinel ...
        beq     L_1301                  ; 12FC  ... which must not be counted down
        dec     viewRedrawTimer         ; 12FE  count it down
L_1301:
        lda     markerSpriteTimer       ; 1301  markerSpriteTimer: how long the repair/target marker sprites stay visible
        beq     L_1309                  ; 1304  already expired
        dec     markerSpriteTimer       ; 1306  count it down
L_1309:
        rts                             ; 1309  back to the interrupt handler


; ----------------------------------------------------------------------
; stepRandomSeed - Called from the IRQ every frame.  If the 24-bit game RNG state has collapsed to all
; zeroes (which would lock the LFSR up) it re-seeds the low byte from the current joystick direction,
; so the generator both recovers and is fed a little entropy from the player, then steps the LFSR
; once.
; In:  gameRngState0-2 (zp_57-zp_59), joyDirection (zp_5D)
; Out: the RNG state advanced, A = the new random byte
; Called from: rasterIrqHandler ($1238)
; ----------------------------------------------------------------------
stepRandomSeed:
        lda     gameRngState0           ; 130A  byte 0 of the RNG state ...
        ora     gameRngState1           ; 130C  ... ORed with byte 1 ...
        ora     gameRngState2           ; 130E  ... and byte 2
        bne     L_1316                  ; 1310  any bit set - the state is fine
        lda     joyDirection            ; 1312  all zero: take the joystick nibble as fresh entropy ...
        sta     gameRngState0           ; 1314  ... and drop it into byte 0 so the LFSR can run again
L_1316:
        jsr     nextGameRandom          ; 1316  advance the 24-bit LFSR in the high-memory block
        rts                             ; 1319  A holds the new random byte


; cursorBlinkTimer: the 30-frame countdown of blinkCursorColour, stored in the code stream just in
; front of the routine that owns it.
cursorBlinkTimer:
        .byte   $00                     ; 131A  .  counts 30..0 and then wraps; bit 7 signals 'time to toggle'

; ----------------------------------------------------------------------
; blinkCursorColour - Blinks the map cursor.  A 30-frame countdown toggles bit 0 of the sprite 0
; colour shadow and copies the result to the sprite 2 shadow, so the two cursor sprites alternate
; between two adjacent colours.  The IRQ only calls it while currentScreen is 0 (the battlefield
; screen) and gamePhase is not negative.
; In:  cursorBlinkTimer ($131A), spriteColourShadow ($9008)
; Out: $131A, $9008, $900A
; Called from: rasterIrqHandler ($1277)
; ----------------------------------------------------------------------
blinkCursorColour:
        dec     cursorBlinkTimer        ; 131B  one frame off the blink countdown
        bpl     L_1330                  ; 131E  still positive - nothing to do this frame
        lda     #$1E                    ; 1320  $1E = 30 frames until the next toggle ...
        sta     cursorBlinkTimer        ; 1322  ... reload the countdown
        lda     spriteColourShadow      ; 1325  sprite 0 colour shadow
        eor     #$01                    ; 1328  flip the lowest colour bit (e.g. between colours 6 and 7)
        sta     spriteColourShadow      ; 132A  back into the sprite 0 shadow
        sta     sprite2ColourShadow     ; 132D  and mirror it into the sprite 2 shadow so both halves of the cursor match
L_1330:
        rts                             ; 1330  the IRQ picks the shadows up on the next frame


; ----------------------------------------------------------------------
; handleChatSend - The outgoing half of the modem chat ('message to opponent'), run every frame from
; the IRQ.  chatState ($929C) is the state byte: 0 = off, $7F = 'open the typing line', and bit 7 set
; = typing, with bits 0-5 holding the cursor position in the outgoing line buffer $9278.  Typing is
; entered by RETURN (or by another routine setting $7F) provided the module's transmit ring is empty;
; after that each frame hands one key to the $C000 line editor and queues the accepted character to
; the opponent.  If the transmit ring fills up the line is terminated with RETURN instead.
; In:  $E03B link flag, $E02E transmit ring count, filmNameLength ($92E6), chatState ($929C),
;     newKeyEvent ($90EA)
; Out: chatState, the line buffer $9278.., newKeyEvent/lastTypedKey cleared, one byte queued through
;     $E009, sounds
; Called from: rasterIrqHandler ($127D)
; ----------------------------------------------------------------------
handleChatSend:
        lda     isLinkActive            ; 1331  is the link up?  (also trainerAiE000:isLinkActive)
        beq     L_133B                  ; 1334  no link - chat is impossible
        lda     filmNameLength          ; 1336  is the SAVE GAME FILM name field using the same line buffer?
        beq     L_1341                  ; 1339  no - the chat may use it
L_133B:
        lda     #$00                    ; 133B  shut the chat down ...
        sta     chatState               ; 133D  ... by clearing the state byte
        rts                             ; 1340  nothing more to do this frame

L_1341:
        lda     chatState               ; 1341  the chat state byte
        bmi     L_1373                  ; 1344  bit 7 set = already typing -> go and send the next character
        cmp     #$7F                    ; 1346  $7F = another routine asked for the typing line to be opened
        beq     L_1352                  ; 1348  open it
        lda     newKeyEvent             ; 134A  otherwise look at the last key
        cmp     #$8D                    ; 134D  $8D = RETURN opens the chat line
        beq     L_1352                  ; 134F  open it
L_1351:
        rts                             ; 1351  nothing to do this frame


; ----------------------------------------------------------------------
; Enter typing mode - but only if the module has nothing left to transmit.
; ----------------------------------------------------------------------
L_1352:
        lda     isLinkActive            ; 1352  double-check the link is still up  (also trainerAiE000:isLinkActive)
        beq     L_1351                  ; 1355  it dropped - abandon
        lda     hostOutCount            ; 1357  how many bytes are still queued for transmission?  (also trainerAiE000:hostOutCount)
        bne     L_1351                  ; 135A  the ring is not empty - wait until it drains
        lda     #$C0                    ; 135C  $C0 = bit 7 (typing) + bit 6, cursor position 0 ...
        sta     chatState               ; 135E  ... becomes the new chat state
        lda     #$9E                    ; 1361  $9E = the cursor glyph ...
        sta     typedLineBuffer         ; 1363  ... put at the head of the outgoing line so the player sees a caret
        lda     #$FF                    ; 1366  swallow the key ...
        sta     newKeyEvent             ; 1368  ... that opened the line ...
        sta     lastTypedKey            ; 136B  ... and the typed-key copy as well
        lda     #$02                    ; 136E  sound 2 = the typing click
        jmp     requestSound            ; 1370  requestSound is called directly (playSound is not re-entrant and this is inside the IRQ)


; ----------------------------------------------------------------------
; Typing mode: bits 0-5 of chatState are the cursor position.  One key per frame is fed to the shared
; line editor and the accepted character is queued to the opponent, so the remote sees the message
; being typed letter by letter.
; ----------------------------------------------------------------------
L_1373:
        and     #$3F                    ; 1373  cursor position within the 36-byte line buffer
        tay                             ; 1375  the editor wants it in Y
        lda     hostOutCount            ; 1376  how full is the transmit ring?  (also trainerAiE000:hostOutCount)
        cmp     #$10                    ; 1379  $10 = 16 bytes = full
        bcc     L_1385                  ; 137B  room left - edit one more character
        tya                             ; 137D  ring full: force the line to end ...
        ora     #$80                    ; 137E  ... by setting bit 7 of the cursor position (the editor's 'RETURN' marker) ...
        tay                             ; 1380  ... back into Y
        lda     #$8D                    ; 1381  and send RETURN instead of a character
        bne     L_138C                  ; 1383  always taken
L_1385:
        lda     #$A3                    ; 1385  $A3 = max 35 characters with bit 7 = 'scroll the line left when it is full'
        jsr     handleTextEntryKey      ; 1387  let the $C000 line editor consume newKeyEvent into the buffer
        beq     L_1351                  ; 138A  nothing was accepted this frame
L_138C:
        pha                             ; 138C  keep the accepted character
        tya                             ; 138D  the editor's updated cursor position
        eor     #$80                    ; 138E  toggle bit 7: a normal character leaves it set (still typing), RETURN clears it (line finished)
        ora     #$40                    ; 1390  bit 6 is always set so the state byte is never mistaken for 0
        sta     chatState               ; 1392  store the new chat state
        pla                             ; 1395  get the character back
        jmp     putLinkByte             ; 1396  queue it to the opponent and return to the IRQ  (also trainerAiE000:commSendByteEntry)


; ----------------------------------------------------------------------
; receiveChatCharacter - The incoming half of the chat, called every frame from the IRQ.  It pulls one
; byte out of the comm module's receive ring and ignores anything below $80.  RETURN ($8D) marks the
; message complete, DEL ($88) backspaces by overwriting the previous character with a space, and
; everything else is appended to the incoming message line.
; In:  the comm receive ring through $E006 (C=1 = empty), incomingChatLength ($9277)
; Out: the $9253 buffer and the $0548 status line, $9277, messageTimer cleared, sound 5
; Called from: rasterIrqHandler ($1280)
; ----------------------------------------------------------------------
receiveChatCharacter:
        jsr     getLinkByte             ; 1399  fetch one received byte (C=1 = nothing waiting)  (also trainerAiE000:commGetReceivedByteEntry)
        bcs     L_1411                  ; 139C  nothing received this frame
        cmp     #$80                    ; 139E  chat characters always have bit 7 set ...
        bcc     L_1411                  ; 13A0  ... so anything below $80 is not chat text
        cmp     #$8D                    ; 13A2  $8D = RETURN, the end of the opponent's message
        bne     L_13AD                  ; 13A4  not RETURN - try DEL
        lda     #$80                    ; 13A6  $80 marks the message complete: bit 7 set, length 0 ...
        sta     incomingChatLength      ; 13A8  ... so the next character starts a fresh message
        bmi     L_1408                  ; 13AB  always taken - go and beep
L_13AD:
        cmp     #$88                    ; 13AD  $88 = DEL
        bne     appendChatCharacter     ; 13AF  an ordinary character - append it
        lda     incomingChatLength      ; 13B1  how long is the message so far?
        beq     L_1400                  ; 13B4  empty - nothing to erase
        bmi     L_1400                  ; 13B6  the previous message was already complete - nothing to erase
        dec     incomingChatLength      ; 13B8  step back over the last character
        lda     #$20                    ; 13BB  a space ...
        jsr     appendChatCharacter     ; 13BD  ... written over it (this re-increments the length)
        dec     incomingChatLength      ; 13C0  step back again so the next character lands on that space
        rts                             ; 13C3  back to the IRQ


; ----------------------------------------------------------------------
; appendChatCharacter - Appends character A to the 36-byte incoming message buffer at $9253 and
; rewrites the visible status line from it.  A negative incomingChatLength means the previous message
; was finished, so the buffer restarts at 0; when the buffer is full at 35 characters it is scrolled
; one byte left.  The line is rewritten backwards from the newest character so that only that
; character carries bit 7 - the game's string terminator convention - and the printed line therefore
; always ends exactly where the message does.
; In:  A = character, incomingChatLength ($9277), the $9253 buffer
; Out: $9253-$9276, $9277, the status line $0548-$056C, messageTimer = 0, sound 5
; Called from: receiveChatCharacter ($13AF as a branch, $13BD as a call)
; ----------------------------------------------------------------------
appendChatCharacter:
        ldy     incomingChatLength      ; 13C4  current message length = the index of the next free byte
        bpl     L_13CE                  ; 13C7  a message is in progress
        ldy     #$00                    ; 13C9  previous message finished: start a new one ...
        sty     incomingChatLength      ; 13CB  ... at offset 0
L_13CE:
        cpy     #$23                    ; 13CE  $23 = 35 characters, the width of the line
        bcc     L_13E5                  ; 13D0  there is still room
        pha                             ; 13D2  the buffer is full - save the new character
        ldx     #$00                    ; 13D3  scroll the whole buffer one byte to the left ...
L_13D5:
        lda     incomingChatScrollSrc,x ; 13D5  ... reading from $9254 (buffer+1) ...
        sta     incomingChatBuffer,x    ; 13D8  ... and writing to $9253
        inx                             ; 13DB  next byte
        cpx     #$24                    ; 13DC  $24 = 36 bytes
        bcc     L_13D5                  ; 13DE  keep scrolling
        dey                             ; 13E0  the write position moves back one
        dec     incomingChatLength      ; 13E1  and so does the recorded length
        pla                             ; 13E4  get the new character back
L_13E5:
        and     #$7F                    ; 13E5  store it without the terminator bit
        sta     incomingChatBuffer,y    ; 13E7  into the buffer at the write position
        inc     incomingChatLength      ; 13EA  the message is one character longer
        ora     #$80                    ; 13ED  the newest character carries the bit-7 terminator

; ----------------------------------------------------------------------
; Rewrite the whole visible line backwards, newest character first, so that exactly one byte (the last
; one) has bit 7 set and the string printer stops there.
; ----------------------------------------------------------------------
chatLineWriteLoop:
        sta     D_0549,y                ; 13EF  write into the status line text at $0549+index
        dey                             ; 13F2  walk backwards through the message
        bmi     L_13FB                  ; 13F3  the whole message has been written
        lda     incomingChatBuffer,y    ; 13F5  earlier characters are stored plain (bit 7 clear)
        jmp     chatLineWriteLoop       ; 13F8  keep going

L_13FB:
        lda     #$1E                    ; 13FB  $1E = the leading marker glyph ...
        sta     chatMessageLine         ; 13FD  ... at $0548, in front of the message text
L_1400:
        ldy     messageQueueHead        ; 1400  leftover test: the message queue head ...
        cpy     messageQueueTail        ; 1403  ... compared with the tail ...
        bne     L_1408                  ; 1406  ... but both outcomes continue at $1408, so this compare has no effect (dead code)
L_1408:
        lda     #$00                    ; 1408  0 ...
        sta     messageTimer            ; 140A  ... clears the message hold timer so the chat line is shown at once
        lda     #$05                    ; 140C  sound 5 = the incoming-character click
        jsr     requestSound            ; 140E  requestSound directly - this runs inside the IRQ, where playSound is not re-entrant
L_1411:
        rts                             ; 1411  back to the IRQ


; $1412 is a scratch byte the REPAIR tab uses as a 3..0 loop counter while it erases and redraws the
; four comcen system markers ($184B-$187C).  $1413 holds the comcenStunStatus value that was current
; when drawRepairTab last ran ($175F), so the console loop can spot a change of stun state.  The
; $7F written into $1412 at $141F is never read: the repair code always reloads it first.
repairLoopCounter:
        .byte   $00                     ; 1412  .  REPAIR tab marker loop counter; the $7F stored at $141F is a leftover dead store
repairTabStunCopy:
        .byte   $00                     ; 1413  copy of comcenStunStatus taken when the REPAIR tab was last drawn

; ----------------------------------------------------------------------
; resumeMainMenuScreen - Re-entry into the console screen (screen 3, the F7 screen with the STATS /
; REPAIR / MISC tabs) that does not rebuild the frame: it just re-picks the default tab, clears the
; text window and joins the console loop at its 'tab has changed' handler so the panel is redrawn.
; Used when the comcen-stunned blackout ($0709) hands control back to whichever screen was showing.
; In:  none
; Out: menuTabIndex ($90F9) set, the tab panel redrawn; never returns (the console loop takes over)
; Called from: $076D, the screen dispatch at the end of runComcenStunnedBlackout
; ----------------------------------------------------------------------
resumeMainMenuScreen:
        jsr     selectDefaultTab        ; 1414  pick the tab the current game state suggests
        jsr     clearMenuTextWindow     ; 1417  blank the panel area under the tab bar
        jmp     drawSelectedTab         ; 141A  draw the newly selected tab and fall into the console loop


; ----------------------------------------------------------------------
; enterMainMenu - Full entry into the console screen (screen 3): picks the default tab, draws the
; whole screen (tab bar and frames) and enters the console loop with that tab's panel drawn.
; In:  comcenStunStatus ($922A) and everything selectDefaultTab reads
; Out: the console screen drawn, menuTabIndex set; never returns
; Called from: $0706, the F7 branch of switchToRequestedScreen
; ----------------------------------------------------------------------
enterMainMenu:
        lda     #$7F                    ; 141D  $7F into the REPAIR tab's scratch byte $1412 ...
        sta     repairLoopCounter       ; 141F  ... which nothing ever reads: the repair marker loops always reload it first, so this is a leftover dead store
        ldy     #$00                    ; 1422  leftover tab pre-selection: default to tab 0 (STATS) ...
        lda     comcenStunStatus        ; 1424  ... unless the comcen is stunned ...
        bpl     L_142A                  ; 1427  ... (bit 7 of comcenStunStatus) ...
        iny                             ; 1429  ... in which case tab 1 (REPAIR)
L_142A:
        sty     menuTabIndex            ; 142A  store it - but selectDefaultTab three bytes later overwrites it unconditionally, so all of $1422-$142A is dead
        jsr     selectDefaultTab        ; 142D  really choose the tab
        jsr     showMainMenu            ; 1430  draw the tab bar and both frames (it reads menuTabIndex for the text colour)
        jsr     selectDefaultTab        ; 1433  choose the tab again - redundant, nothing showMainMenu does changes the inputs
        jmp     drawSelectedTab         ; 1436  draw that tab's panel and fall into the console loop


; ----------------------------------------------------------------------
; mainMenuLoop - The console screen's main loop (screen 3, tabs STATS / REPAIR / MISC).  Every pass it
; runs one frame of the game engine (the battle keeps going while the console is open), blinks the
; selected tab, refreshes the REPAIR tab when the comcen stun state changes and the STATS tab when a
; command exchange has been applied, and honours the function keys.  The joystick moves the selection
; left and right between the three tabs with a 20-frame repeat, and the fire button dispatches through
; tabActionTable.  The loop never returns; every path jumps back to $1439.
; In:  joyDirection (zp_5D), joyRepeatTimer (zp_6C), menuTabIndex ($90F9), comcenStunStatus ($922A),
;     exchangeAppliedFlag ($920E)
; Out: menuTabIndex, the console screen; runs forever
; Called from: entered by fall-through/JMP from resumeMainMenuScreen and enterMainMenu
; ----------------------------------------------------------------------
mainMenuLoop:
        jsr     updateGameFrame         ; 1439  keep the simulation running while the console is open
        jsr     blinkSelectedTab        ; 143C  toggle the selected tab between its bright and normal colour
        lda     menuTabIndex            ; 143F  which tab is showing?
        cmp     #$01                    ; 1442  1 = REPAIR
        bne     L_1456                  ; 1444  any other tab - skip the repair refresh
        lda     comcenStunStatus        ; 1446  current comcen stun state ...
        eor     repairTabStunCopy       ; 1449  ... against the copy taken when the tab was drawn ...
        and     #$80                    ; 144C  ... looking only at bit 7 (stunned or not)
        beq     L_1453                  ; 144E  unchanged - no full redraw needed
        jsr     drawRepairTab           ; 1450  the stun state flipped: redraw the whole REPAIR panel in its new colour
L_1453:
        jsr     printEnergyValue        ; 1453  refresh the comcen energy percentage (it only prints when the value changed)
L_1456:
        lda     exchangeAppliedFlag     ; 1456  has a command exchange been applied since the STATS tab was drawn?
        beq     L_1463                  ; 1459  no - nothing to refresh
        lda     menuTabIndex            ; 145B  which tab is showing?
        bne     L_1463                  ; 145E  only tab 0 (STATS) shows the numbers that just changed
        jsr     drawStatsTab            ; 1460  recount the units and reprint the STATS panel (it clears the flag again)  (also mapGenerator6F00:editDronesAndMissilesBody)
L_1463:
        jsr     switchToRequestedScreen ; 1463  let F1/F3/F5/F7 switch to another screen (never returns if it does)

; ----------------------------------------------------------------------
; Joystick handling: left and right move between the three tabs, everything else falls through to the
; fire-button test.  joyRepeatTimer holds the auto-repeat delay so a held stick steps once every 20
; frames.
; ----------------------------------------------------------------------
        lda     joyRepeatTimer          ; 1466  is the joystick auto-repeat still counting down?
        bne     L_1470                  ; 1468  yes - ignore the stick, only look at the button
        lda     joyDirection            ; 146A  the latched joystick direction
        cmp     #$0F                    ; 146C  $0F = centred
        bne     menuJoyLeftRight        ; 146E  the stick is being pushed - see which way
L_1470:
        jsr     checkFirePressed        ; 1470  edge-detect a fresh fire-button press
        bne     mainMenuLoop            ; 1473  no press - go round again

; ----------------------------------------------------------------------
; Fire on a tab: light that tab and call its handler through tabActionTable.  The handler address is
; copied into the operand of the JSR at $148D, which is why the disassembly shows 'jsr $FFFF' there.
; ----------------------------------------------------------------------
        ldx     menuTabIndex            ; 1475  the selected tab
        jsr     setTabHighlightColour   ; 1478  show it in its bright colour while its action runs
        lda     menuTabIndex            ; 147B  tab index ...
        asl     a                       ; 147E  ... times two ...
        tay                             ; 147F  ... is the offset into the table of 16-bit handler addresses
        lda     tabActionTable,y        ; 1480  handler low byte ...
        sta     L_148D+1                ; 1483  ... self-modifying: into the operand of the JSR at $148D
        iny                             ; 1486  next table byte
        lda     tabActionTable,y        ; 1487  handler high byte ...
        sta     L_148D+2                ; 148A  ... into the high half of that operand
L_148D:
        jsr     irqVectorHi             ; 148D  call the tab's fire handler (operand patched just above; tab 0 points at the RTS at $158E)
L_1490:
        jmp     mainMenuLoop            ; 1490  back to the top of the loop


; ----------------------------------------------------------------------
; Left/right tab movement.  The direction bits are active low, so after masking bits 2 and 3, $04
; means right is pressed and $08 means left; $0C means neither.
; ----------------------------------------------------------------------
menuJoyLeftRight:
        and     #$0C                    ; 1493  keep the left (bit 2) and right (bit 3) switches
        cmp     #$0C                    ; 1495  $0C = neither of them pressed
        beq     mainMenuLoop            ; 1497  up or down only - ignore it
        ldx     #$14                    ; 1499  $14 = 20 frames ...
        stx     joyRepeatTimer          ; 149B  ... of auto-repeat delay before the selection may move again
        pha                             ; 149D  remember the direction
        ldx     menuTabIndex            ; 149E  the tab we are leaving ...
        jsr     setTabNormalColour      ; 14A1  ... goes back to its normal colour
        pla                             ; 14A4  get the direction back
        cmp     #$04                    ; 14A5  $04 = right pressed (bit 3 low)
        bne     L_14B5                  ; 14A7  not right, so it must be left
        lda     menuTabIndex            ; 14A9  the selected tab
        cmp     #$02                    ; 14AC  2 = MISC, the rightmost tab
        beq     L_1490                  ; 14AE  already at the right edge - ignore
        inc     menuTabIndex            ; 14B0  move one tab to the right
        bne     L_14BD                  ; 14B3  always taken
L_14B5:
        lda     menuTabIndex            ; 14B5  the selected tab
        beq     L_1490                  ; 14B8  already at STATS, the left edge - ignore
        dec     menuTabIndex            ; 14BA  move one tab to the left
L_14BD:
        lda     #$02                    ; 14BD  sound 2 = the selection click
        jsr     playSound               ; 14BF  play it
        ldx     menuTabIndex            ; 14C2  the newly selected tab ...
        jsr     setTabHighlightColour   ; 14C5  ... gets its bright colour
        jsr     clearMenuTextWindow     ; 14C8  blank the panel under the tab bar ready for the new contents

; ----------------------------------------------------------------------
; Draw the panel of the currently selected tab.  Entered directly by both console entry points as well
; as after a tab change.
; ----------------------------------------------------------------------
drawSelectedTab:
        lda     menuTabIndex            ; 14CB  which tab?
        bne     L_14D9                  ; 14CE  not tab 0
        jsr     drawGameSetupSummary    ; 14D0  STATS: the game type, rules, play order, owner names and map id
        jsr     drawStatsTab            ; 14D3  plus the unit counts, drones, missiles, comspeed and terrain points (in overlay B)  (also mapGenerator6F00:editDronesAndMissilesBody)
        jmp     mainMenuLoop            ; 14D6  back to the loop

L_14D9:
        cmp     #$01                    ; 14D9  tab 1?
        bne     L_14E6                  ; 14DB  no - it must be MISC
        jsr     drawRepairTab           ; 14DD  REPAIR: the comcen schematic and its four system markers
        jsr     snapshotComcenStatus    ; 14E0  remember the comcen status that is now on screen
        jmp     mainMenuLoop            ; 14E3  back to the loop

L_14E6:
        cmp     #$02                    ; 14E6  tab 2?
        bne     L_14ED                  ; 14E8  menuTabIndex is only ever 0, 1 or 2 (every writer is in this loop), so this branch is never taken
        jsr     printOptionsList        ; 14EA  MISC: print the option list (ABORT GAME, VOICE PAUSE, EXIT ...)
L_14ED:
        jmp     mainMenuLoop            ; 14ED  back to the loop


; ----------------------------------------------------------------------
; snapshotComcenStatus - Remembers what the REPAIR tab is currently showing: copies the three comcen
; system status bytes ($9242-$9244) into the snapshot at $92D7-$92D9 and the comcen's energy (the low
; 6 bits of its entry in unitEnergyTable) into $92D6.  selectDefaultTab compares the live values
; against this snapshot to decide whether the player still has repairs to look at.
; In:  comcenSystemStatus ($9241..), comcenUnit ($9236), unitEnergyTable ($F9C4)
; Out: comcenStatusSnapshot ($92D6-$92D9)
; Called from: mainMenuLoop ($14E0) and the end of repairTabAction ($187E)
; ----------------------------------------------------------------------
snapshotComcenStatus:
        ldx     #$03                    ; 14F0  index 3 down to 1: the battle, radar and drone system bytes
L_14F2:
        lda     comcenSystemStatus,x    ; 14F2  system status byte $9241+X
        sta     comcenStatusSnapshot,x  ; 14F5  into the snapshot at $92D6+X
        dex                             ; 14F8  next system
        bne     L_14F2                  ; 14F9  stop at index 1 - slot 0 is filled from the energy below
        ldx     comcenUnit              ; 14FB  this side's comcen (unit 49 or 99)
        lda     unitEnergyTable,x       ; 14FE  its energy byte
        and     #$3F                    ; 1501  the low 6 bits are the energy 0-50 (the top bits are flags)
        sta     comcenStatusSnapshot    ; 1503  snapshot slot 0 = the comcen energy
        rts                             ; 1506  done


; ----------------------------------------------------------------------
; selectDefaultTab - Chooses which console tab should be showing.  If the game is over or has not
; started (gameEndReason non-zero) it opens MISC (2), where ABORT/EXIT live.  Otherwise it opens
; REPAIR (1) when the comcen is stunned or when anything on the comcen has changed since the last
; snapshotComcenStatus, and MISC (2) when nothing needs attention.  STATS (0) is never chosen here.
; In:  gameEndReason ($0BA8), comcenStunStatus ($922A), comcenSystemStatus, comcenStatusSnapshot,
;     comcenUnit, unitEnergyTable
; Out: menuTabIndex ($90F9)
; Called from: resumeMainMenuScreen ($1414) and enterMainMenu ($142D, $1433)
; ----------------------------------------------------------------------
selectDefaultTab:
        ldy     #$02                    ; 1507  assume tab 2 = MISC
        lda     gameEndReason           ; 1509  0 while a game is actually running
        bne     L_1531                  ; 150C  no game in progress - MISC it is
        ldy     #$00                    ; 150E  a game is running: assume tab 0 = STATS for now
        lda     comcenStunStatus        ; 1510  is the comcen stunned?
        bmi     L_152F                  ; 1513  yes - the player needs the REPAIR tab
        ldx     #$03                    ; 1515  compare the three system bytes with the snapshot, index 3 down to 1
L_1517:
        lda     comcenSystemStatus,x    ; 1517  live system status $9241+X ...
        cmp     comcenStatusSnapshot,x  ; 151A  ... against the snapshot $92D6+X
        bne     L_152F                  ; 151D  something changed - show REPAIR
        dex                             ; 151F  next system
        bne     L_1517                  ; 1520  stop at index 1
        ldx     comcenUnit              ; 1522  this side's comcen
        lda     unitEnergyTable,x       ; 1525  its energy byte
        and     #$3F                    ; 1528  keep the 0-50 energy value
        cmp     comcenStatusSnapshot    ; 152A  has the comcen lost energy since the snapshot?
        beq     L_1531                  ; 152D  unchanged - nothing to repair, leave Y at 2 = MISC
L_152F:
        ldy     #$01                    ; 152F  something needs attention: tab 1 = REPAIR
L_1531:
        sty     menuTabIndex            ; 1531  store the chosen tab
        rts                             ; 1534  done


; tabActionTable: the three fire-button handlers of the console tabs, one 16-bit little-endian address
; each, indexed by menuTabIndex*2 and copied into the JSR operand at $148D.
;   +0 tab 0 STATS   -> $158E, the RTS at the end of setMenuWindowBounds, i.e. no action at all
;   +2 tab 1 REPAIR  -> repairTabAction ($17DA), the 'repair which system' menu
;   +4 tab 2 MISC    -> miscTabAction ($1A49), the ABORT GAME / VOICE PAUSE / EXIT option list
tabActionTable:
        .addr   $158E                   ; 1535  three handler addresses, one per console tab
        .addr   repairTabAction         ; 1537
        .addr   miscTabAction           ; 1539

; ----------------------------------------------------------------------
; showMainMenu - Builds the console screen from scratch: resets the glyph plotter's parameter block,
; sets the background colour, draws the STATS/REPAIR/MISC tab bar and the two nested frames, and ends
; by repainting the status message line.
; In:  none
; Out: the whole screen 3 drawn; textDrawParams, vicBg0Shadow ($9004), cellBackgroundColour ($9226),
;     screenBuiltFlag (zp_41)
; Called from: returnToMainMenu ($0AC2) and enterMainMenu ($1430)
; ----------------------------------------------------------------------
showMainMenu:
        lda     #$00                    ; 153B  0 into the four glyph-plotter parameters below
        sta     useDrawColourFlag       ; 153D  useFixedColourFlag = 0: take the colour from the text state, not a fixed byte
        sta     drawColourByte          ; 1540  drawColourByte = 0
        sta     blockFillFlag           ; 1543  blockFillFlag = 0
        sta     blockFillChar           ; 1546  blockFillChar = 0
        lda     #$0C                    ; 1549  colour $0C = medium grey ...
        sta     vicBg0Shadow            ; 154B  ... as the screen background (the IRQ copies the shadow into $D021)
        lda     #$00                    ; 154E  0 ...
        sta     cellBackgroundColour    ; 1550  ... = the cell background colour used by the tile drawing
        lda     #$01                    ; 1553  1 ...
        sta     screenBuiltFlag         ; 1555  ... marks 'a screen has been built' (written by every screen builder, read nowhere)
        lda     #$00                    ; 1557  0 ...
        sta     blockFillFlag           ; 1559  ... into blockFillFlag again - redundant, it was already cleared at $1543
        jsr     drawMenuTabBar          ; 155C  print STATS, REPAIR and MISC and colour their three tab boxes
        jsr     drawMenuFrame           ; 155F  draw the outer and inner frames
        jmp     redrawStatusMessage     ; 1562  repaint whatever message belongs on the status line and return to our caller


; ----------------------------------------------------------------------
; drawMenuFrame - Draws the console's two nested frames from the predefined window table: window $18
; is the outer 40x20 box starting at row 5, window $19 the inner 38x18 box at row 6.  Falls into
; clearMenuTextWindow.
; In:  none
; Out: the frames drawn, then the text window set and cleared
; Called from: showMainMenu ($155F)
; ----------------------------------------------------------------------
drawMenuFrame:
        ldx     #$18                    ; 1565  window $18 = column 0, row 5, 40x20 cells - the outer frame
        jsr     drawWindowFrame         ; 1567  draw it from its 9-tile frame set
        ldx     #$19                    ; 156A  window $19 = column 1, row 6, 38x18 cells - the inner frame
        jsr     drawWindowFrame         ; 156C  draw it too

; ----------------------------------------------------------------------
; clearMenuTextWindow - Sets the console panel's text colour and window bounds and blanks the window.
; In:  comcenStunStatus ($922A), menuTabIndex ($90F9)
; Out: textColour (zp_3D), the window bounds zp_39-zp_40, the panel area cleared
; Called from: resumeMainMenuScreen ($1417), mainMenuLoop ($14C8), $1ACA, $1AD6 and overlay A ($7EA0)
; ----------------------------------------------------------------------
clearMenuTextWindow:
        jsr     setMenuTextWindow       ; 156F  colour and window bounds for the panel
        jmp     bootDriveDataBit        ; 1572  blank it and return to our caller  (also textEngineC000:clearTextWindow)


; ----------------------------------------------------------------------
; setMenuTextWindow - Picks the console text colour from the comcen stun state (red on the REPAIR tab
; while the comcen is stunned) and falls into setMenuWindowBounds.
; In:  comcenStunStatus ($922A), menuTabIndex ($90F9)
; Out: textColour (zp_3D) plus the window bounds
; Called from: clearMenuTextWindow ($156F), $1866 and $1A80
; ----------------------------------------------------------------------
setMenuTextWindow:
        jsr     setMenuTextColour       ; 1575  $61 normally, $21 on a stunned REPAIR tab

; ----------------------------------------------------------------------
; setMenuWindowBounds - Sets the text window that the console panels print into: rows 7 to 21, left
; column 2, 36 characters wide, cursor homed at the top left of the window.  Its final RTS at $158E
; doubles as the do-nothing handler of the STATS tab in tabActionTable.
; In:  none
; Out: textCursorCol=0, textWindowTop=7, textCursorRow=7, textWindowLeft=2, textWindowBottom=$16,
;     textWindowWidth=$24
; Called from: setMenuTextWindow by fall-through, clearMenuWindowPlain ($1593) and $19E7
; ----------------------------------------------------------------------
setMenuWindowBounds:
        lda     #$00                    ; 1578  cursor column 0 ...
        sta     textCursorCol           ; 157A  ... relative to the window's left edge
        lda     #$07                    ; 157C  row 7 = the first line under the tab bar and frame ...
        sta     textWindowTop           ; 157E  ... as the top of the window ...
        sta     textCursorRow           ; 1580  ... and as the starting cursor row
        lda     #$02                    ; 1582  screen column 2 ...
        sta     textWindowLeft          ; 1584  ... is the window's left edge
        lda     #$16                    ; 1586  $16 = row 22 is the exclusive bottom limit ...
        sta     textWindowBottom        ; 1588  ... so the window covers rows 7-21
        lda     #$24                    ; 158A  $24 = 36 characters ...
        sta     textWindowWidth         ; 158C  ... is the window width and the wrap point
        rts                             ; 158E  also the STATS tab's 'do nothing' fire handler (tabActionTable entry 0)


; ----------------------------------------------------------------------
; clearMenuWindowPlain - Blanks the whole console-menu panel with text colour 0 (black text, no
; highlight EOR mask). This is the screen-id-3 arm of clearCurrentScreenWindows ($2DDE), which
; re-clears the content area of whichever screen is active; it is reached only by the JMP at $2E0A, so
; it returns straight to that routine's caller (typically the $C000 overlay's resume-after-pause path
; at $C75E).
; In:  nothing in registers; textFillGlyph zp_3E supplies the blank glyph
; Out: zp_3D = 0, the menu window geometry of setMenuWindowBounds (columns 2..37, rows 7..21) and the
;     whole panel filled with blanks
; Called from: JMP at $2E0A (clearCurrentScreenWindows, screen id 3)
; ----------------------------------------------------------------------
clearMenuWindowPlain:
        lda     #$00                    ; 158F  colour byte 0: black text and an all-zero highlight EOR mask
        sta     textColour              ; 1591  the text engine writes zp_3D into colour RAM for every cell it touches
        jsr     setMenuWindowBounds     ; 1593  menu panel window: left column 2, width $24, rows 7..$16
        jmp     bootDriveDataBit        ; 1596  fill the panel with the blank glyph zp_3E and return to clearCurrentScreenWindows' caller  (also textEngineC000:clearTextWindow)


; ----------------------------------------------------------------------
; drawGameSetupSummary - Draws the fixed header of the console STATS tab (menu tab 0): the rules word
; STANDARD/CUSTOM, the game-type name, an optional '. 1ST PLAY'/'. 2ND PLAY' suffix, then the run-time
; owner-name line and the column of row labels (GRUNTS/RIDERS/.../MAP ID) that drawStatsTab ($8518,
; overlay B) later fills numbers in beside. For a CUSTOM game it adds the ' MENUS xxxx DAMAGE xxx.'
; summary line.
; In:  gameTypeOptions $0BA3 (bits 0-2 game type, bit 6 CUSTOM rules), playsFirstFlag $0BA1,
;     ownerNameString $0B7F, statsTabLabels $86AC and msgOptionSummary $0636 (both only valid while
;     overlay B / the string block is resident)
; Out: text drawn into the bitmap from row 7 down; zp_3B window left ends at 10 (or 8 for CUSTOM),
;     zp_3D = $6E, zp_3F/zp_40 left after the last character; self-modifies the operand at $15DD
; Called from: mainMenuLoop at $14D0 when tab 0 (STATS) becomes the selected tab
; Line 1 of the STATS tab: '<STANDARD|CUSTOM> <GAME TYPE>[. 1ST PLAY]'.
; ----------------------------------------------------------------------
drawGameSetupSummary:
        lda     #$6F                    ; 1599  colour byte $6F: light-grey text (colour RAM low nibble), highlight EOR mask 6
        sta     textColour              ; 159B  set the text colour for the heading line
        lda     #$00                    ; 159D  cursor to the left edge of the window
        sta     textCursorCol           ; 159F  textCursorCol is relative to textWindowLeft
        lda     #$07                    ; 15A1  heading starts at screen column 7
        sta     textWindowLeft          ; 15A3  window left edge = 7
        lda     #$07                    ; 15A5  first line of the panel is screen row 7
        sta     textCursorRow           ; 15A7  absolute cursor row
        jsr     updateCursorPointers    ; 15A9  recompute the bitmap and colour-RAM pointers for that cell
        ldx     #$01                    ; 15AC  assume rulesNameTable entry 1 = CUSTOM
        lda     gameTypeOptions         ; 15AE  game settings byte: bits 0-2 type, bit 3 UNIT MENUS, bit 4 DAMAGE, bit 6 CUSTOM rules
        pha                             ; 15B1  keep the settings byte on the stack for the game-type lookup below
        and     #$40                    ; 15B2  bit 6 = CUSTOM rules selected
        bne     L_15C2                  ; 15B4  branch if CUSTOM - keep table entry 1 and always print the play order
        jsr     isAsymmetricGameType    ; 15B6  Z=1 for game type 1 (QB SNEAK) or 6 (DEFENDER), the asymmetric ones
        beq     L_15C0                  ; 15B9  branch if this type shows 1ST/2ND PLAY - no padding needed
        lda     #$04                    ; 15BB  four blanks...
        jsr     bootReceiveSectorData   ; 15BD  ...printFillChars ($C0EF): centres 'STANDARD <type>' when no ' 1ST PLAY' follows  (also textEngineC000:printFillChars)
L_15C0:
        ldx     #$00                    ; 15C0  rulesNameTable entry 0 = STANDARD
L_15C2:
        stx     L_15DC+1                ; 15C2  self-modifying code: patches the LDA #imm operand at $15DD, so $15DC loads 1 for CUSTOM and 0 for STANDARD
        lda     #$11                    ; 15C5  low byte of rulesNameTable
        ldy     #$CC                    ; 15C7  $CC11 = rulesNameTable, two 8-byte bit-7-terminated entries
        jsr     printTableEntry         ; 15C9  print entry X ($CC11 + X*8): 'STANDARD' or 'CUSTOM'
        lda     #$01                    ; 15CC  one blank...
        jsr     bootReceiveSectorData   ; 15CE  ...between the rules word and the game type name  (also textEngineC000:printFillChars)
        pla                             ; 15D1  recover the settings byte
        and     #$07                    ; 15D2  bits 0-2 = game type 0..6
        tax                             ; 15D4  use it as the table index
        lda     #$C8                    ; 15D5  low byte of scenarioNameTable
        ldy     #$CC                    ; 15D7  $CCC8 = scenarioNameTable, seven 8-byte names
        jsr     printTableEntry         ; 15D9  print SCRIMAGE / QB SNEAK / THE BOMB / FACE-OFF / SLUGGERS / FULL WAR / DEFENDER
L_15DC:
        lda     #$FF                    ; 15DC  operand patched at $15C2: 1 = CUSTOM rules, 0 = STANDARD
        bne     L_15E5                  ; 15DE  CUSTOM rules always show whose play it is
        jsr     isAsymmetricGameType    ; 15E0  standard rules: only QB SNEAK and DEFENDER have a play order
        bne     L_1606                  ; 15E3  symmetric game type under standard rules - skip the play-order suffix

; ----------------------------------------------------------------------
; Suffix '. 1ST PLAY' / '. 2ND PLAY'.
; ----------------------------------------------------------------------
L_15E5:
        lda     #$AE                    ; 15E5  $AE = '.' with bit 7 set; printChar masks bit 7 off
        jsr     printChar               ; 15E7  print the full stop after the game type
        lda     #$02                    ; 15EA  two blanks...
        jsr     bootReceiveSectorData   ; 15EC  ...before the play-order words  (also textEngineC000:printFillChars)
        lda     #$FE                    ; 15EF  low byte of '1ST'
        ldy     #$CB                    ; 15F1  $CBFE = '1ST'
        ldx     playsFirstFlag          ; 15F3  $0BA1 non-zero = this machine has first play this game
        bne     L_15FC                  ; 15F6  branch if we play first - keep the '1ST' pointer
        lda     #$01                    ; 15F8  low byte of '2ND'
        ldy     #$CC                    ; 15FA  $CC01 = '2ND'
L_15FC:
        jsr     printString             ; 15FC  print '1ST' or '2ND'
        lda     #$F9                    ; 15FF  low byte of ' PLAY'
        ldy     #$CB                    ; 1601  $CBF9 = ' PLAY'
        jsr     printString             ; 1603  print ' PLAY'

; ----------------------------------------------------------------------
; Owner-name line plus the column of STATS row labels, then the CUSTOM rules summary.
; ----------------------------------------------------------------------
L_1606:
        jsr     printNewline            ; 1606  carriage return: column 0 of the next panel row
        lda     #$0A                    ; 1609  label block starts at screen column 10
        sta     textWindowLeft          ; 160B  move the window left edge
        lda     #$03                    ; 160D  indent three more columns (absolute column 13)
        sta     textCursorCol           ; 160F  cursor column relative to the window
        jsr     updateCursorPointers    ; 1611  recompute the cell pointers for the new window/cursor
        dec     textColour              ; 1614  $6F -> $6E: light-blue body text instead of light grey
        lda     #$7F                    ; 1616  low byte of ownerNameString
        ldy     #$0B                    ; 1618  $0B7F = the 21-character owner/player line built at run time by buildOwnerNameString
        jsr     printString             ; 161A  print it; printString leaves zp_A2/zp_A3 just past the terminator
        lda     #$AC                    ; 161D  low byte of statsTabLabels
        ldy     #$86                    ; 161F  $86AC lives in the resident $6F00 overlay B (comcenScreens6F00)
        jsr     printString             ; 1621  one string with embedded CRs draws GRUNTS/RIDERS/BOOMERS/SPIES/DRONES/MISSILES/COMSPEED/TER PTS/RECYCLER/MAP ID
        lda     gameTypeOptions         ; 1624  settings byte again
        and     #$40                    ; 1627  bit 6 = CUSTOM rules
        beq     L_1639                  ; 1629  standard rules - nothing more to print
        lda     #$08                    ; 162B  custom summary starts at screen column 8
        sta     textWindowLeft          ; 162D  move the window left edge once more
        jsr     printNewline            ; 162F  carriage return onto the summary row
        lda     #$36                    ; 1632  low byte of optionSummaryTemplate
        ldy     #$06                    ; 1634  $0636 = ' MENUS xxxx DAMAGE xxx.' - overlay B has already patched the xxxx/xxx fields in place
        jsr     printString             ; 1636  print the rules summary
L_1639:
        rts                             ; 1639  done - back to mainMenuLoop


; tabColumnTable - screen column of the top-left cell of each console tab box, indexed by tab number
; (0 STATS, 1 REPAIR, 2 MISC). Each box is 2x2 character cells at row 1. Read by
; setTabNormalColour/setTabHighlightColour.
tabColumnTable:
        .byte   $0B,$13,$1B             ; 163A  ...  STATS at column $0B, REPAIR at $13, MISC at $1B

; tabColourTable - normal screen-RAM colour byte of each tab box (upper nibble = multicolour pair 01,
; lower nibble = pair 10). One entry per tab 0..2.
tabColourTable:
        .byte   $05,$06,$02             ; 163D  ...  STATS black/green $05, REPAIR black/blue $06, MISC black/red $02

; tabHighlightColourTable - bright variant of tabColourTable used while a tab is selected and
; blinking; same three-entry layout. A zero entry would make setTabHighlightColour fall through to the
; normal colour, but none is zero.
tabHighlightColourTable:
        .byte   $0D,$0E,$0A             ; 1640  ...  STATS black/light green $0D, REPAIR black/light blue $0E, MISC black/light red $0A

; ----------------------------------------------------------------------
; drawMenuTabBar - Draws the three console tabs of screen 3: prints the labels STATS, REPAIR and MISC
; on row 3 starting at column 10, each in its own colour, then paints the 2x2 tab box behind each of
; them in its normal colour, and finally resets the bitmap/colour draw modes to 'replace'.
; In:  nothing; uses the character-block at $9804 and the three colour/column tables at $163A-$1642
; Out: labels and tab boxes drawn; zp_21 used as the 2..0 tab counter (setTabNormalColour clobbers X);
;     text window left as the full screen, zp_3D = $C2, zp_3F/zp_40 after 'MISC'
; Called from: showMainMenu at $155C, when the STATS/REPAIR/MISC screen is built
; Print the three tab labels on row 3, two/three blanks apart.
; ----------------------------------------------------------------------
drawMenuTabBar:
        lda     #$CD                    ; 1643  colour byte $CD: light-green text, highlight EOR mask $C - matches the green STATS tab
        sta     textColour              ; 1645  set the label colour
        jsr     setFullScreenWindow     ; 1647  print over the whole screen: left 0, top 0, bottom $19, width $28
        lda     #$0A                    ; 164A  labels start at column 10
        sta     textCursorCol           ; 164C  cursor column (window left is 0 here, so this is absolute)
        lda     #$03                    ; 164E  tab labels sit on screen row 3
        sta     textCursorRow           ; 1650  absolute cursor row
        jsr     updateCursorPointers    ; 1652  recompute the cell pointers
        lda     #$70                    ; 1655  low byte of 'STATS'
        ldy     #$CB                    ; 1657  $CB70 = 'STATS'
        jsr     printString             ; 1659  print it
        lda     #$02                    ; 165C  two blanks...
        jsr     bootReceiveSectorData   ; 165E  ...between STATS and REPAIR  (also textEngineC000:printFillChars)
        lda     #$C6                    ; 1661  colour byte $C6: blue text, matching the blue REPAIR tab
        sta     textColour              ; 1663  switch colour before the next label
        lda     #$75                    ; 1665  low byte of 'REPAIR'
        ldy     #$CB                    ; 1667  $CB75 = 'REPAIR'
        jsr     printString             ; 1669  print it
        lda     #$03                    ; 166C  three blanks...
        jsr     bootReceiveSectorData   ; 166E  ...between REPAIR and MISC  (also textEngineC000:printFillChars)
        lda     #$C2                    ; 1671  colour byte $C2: red text, matching the red MISC tab
        sta     textColour              ; 1673  switch colour again
        lda     #$6C                    ; 1675  low byte of 'MISC'
        ldy     #$CB                    ; 1677  $CB6C = 'MISC'
        jsr     printString             ; 1679  print it

; ----------------------------------------------------------------------
; Paint the three tab boxes, from MISC (2) down to STATS (0). The loop counter has to live in zero
; page because setTabNormalColour reloads X for fillScreenColourRect.
; ----------------------------------------------------------------------
        ldx     #$02                    ; 167C  start with tab 2 = MISC
        stx     scratch21               ; 167E  keep the tab index in zp_21 across the call
L_1680:
        jsr     setTabNormalColour      ; 1680  draw tab X's box shape and fill it with its normal colour
        dec     scratch21               ; 1683  next tab down
        ldx     scratch21               ; 1685  reload the index that setTabNormalColour destroyed
        bpl     L_1680                  ; 1687  loop while the index is still 0..2
        jmp     resetDrawModes          ; 1689  restore bitmap and colour draw mode 0 (replace) and return


; ----------------------------------------------------------------------
; setTabHighlightColour - Redraws console tab X in its bright colour: looks the colour up in
; tabHighlightColourTable and joins setTabNormalColour's common tail. A zero table entry would fall
; through to the normal colour, but all three entries are non-zero, so the BNE is always taken.
; In:  X = tab index 0..2
; Out: the tab's 2x2 box redrawn and recoloured; see setTabNormalColour for the details
; Called from: mainMenuLoop at $1478 (fire pressed on the tab) and $14C5 (tab changed), and
;     blinkSelectedTab at $16D0
; ----------------------------------------------------------------------
setTabHighlightColour:
        lda     tabHighlightColourTable,x; 168C  bright colour byte of tab X ($0D/$0E/$0A)
        bne     L_1694                  ; 168F  always taken; a zero entry would mean 'no bright colour, use the normal one'

; ----------------------------------------------------------------------
; setTabNormalColour - Redraws console tab X: stores the chosen colour and the tab index into the two
; self-modified operands, sets the character-block origin to row 1 / tabColumnTable[X], draws the 2x2
; tab-box shape from $9804 and then fills those 2x2 screen-RAM colour cells with the tab colour.
; In:  X = tab index 0..2; tabColumnTable, tabColourTable/tabHighlightColourTable
; Out: D_2A2D = 1 (row), D_2A2E = tab column, D_9221/D_9222 = 2x2 (set by drawCharBlock), four bitmap
;     cells and four screen-RAM colour bytes updated; A/X/Y clobbered; patches the operands at $16AD
;     and $16AF
; Called from: mainMenuLoop at $14A1, drawMenuTabBar's loop at $1680, blinkSelectedTab at $16D3, and
;     by falling through from setTabHighlightColour
; ----------------------------------------------------------------------
setTabNormalColour:
        lda     tabColourTable,x        ; 1691  normal colour byte of tab X ($05/$06/$02)
L_1694:
        sta     L_16AE+1                ; 1694  self-modifying code: the colour becomes the LDA #imm operand at $16AF
        stx     L_16AC+1                ; 1697  self-modifying code: the tab index becomes the LDX #imm operand at $16AD - dead, see $16AC
        lda     #$01                    ; 169A  tab boxes sit on character row 1
        sta     windowOriginRow         ; 169C  block/fill origin row for drawCharBlock and fillScreenColourRect
        lda     tabColumnTable,x        ; 169F  screen column of this tab ($0B, $13 or $1B)
        sta     windowOriginCol         ; 16A2  block/fill origin column
        lda     #$04                    ; 16A5  low byte of the tab-box shape
        ldy     #$98                    ; 16A7  $9804 = character block: width 2, height 2, glyphs $45,$46,$47,$48
        jsr     drawCharBlock           ; 16A9  draw the 2x2 box into the bitmap at (row 1, tab column)
L_16AC:
        ldx     #$FF                    ; 16AC  operand written at $1697 = the tab index; dead code - X is reloaded two instructions later
L_16AE:
        lda     #$FF                    ; 16AE  operand written at $1694 = the tab colour byte
        ldx     #$02                    ; 16B0  fill width: 2 character cells
        ldy     #$02                    ; 16B2  fill height: 2 character cells
        jmp     fillScreenColourRect    ; 16B4  paint the 2x2 colour cells and return to the caller


; ----------------------------------------------------------------------
; blinkSelectedTab - Called once per pass of mainMenuLoop. It does nothing until the IRQ frame
; countdown zp_6E reaches 0; then it reloads it with 10 frames, flips blinkPhase and repaints the
; currently selected tab in either its bright or its normal colour, so the open tab blinks about three
; times a second.
; In:  zp_6E menuBlinkTimer, menuTabIndex $90F9 (selected tab), blinkPhase $1889
; Out: zp_6E = 10, blinkPhase toggled, the selected tab recoloured
; Called from: mainMenuLoop at $143C
; ----------------------------------------------------------------------
blinkSelectedTab:
        lda     menuBlinkTimer          ; 16B7  IRQ frame countdown shared by the tab blink and the menu-row blink
        beq     L_16BC                  ; 16B9  branch when the countdown has expired
        rts                             ; 16BB  still counting down - leave the tab as it is

L_16BC:
        lda     #$0A                    ; 16BC  10 frames per blink phase
        sta     menuBlinkTimer          ; 16BE  restart the countdown (the raster IRQ decrements it)
        ldx     menuTabIndex            ; 16C0  $90F9 = tab currently open: 0 STATS, 1 REPAIR, 2 MISC
        bpl     L_16C6                  ; 16C3  branch if a tab is selected
        rts                             ; 16C5  negative index = no tab open, nothing to blink

L_16C6:
        lda     blinkPhase              ; 16C6  current blink phase, 0 or 1
        eor     #$01                    ; 16C9  flip it
        sta     blinkPhase              ; 16CB  store the new phase (also read by the marker blink)
        beq     L_16D3                  ; 16CE  phase 0 - draw the tab in its normal colour
        jmp     setTabHighlightColour   ; 16D0  phase 1 - draw it bright

L_16D3:
        jmp     setTabNormalColour      ; 16D3  tail call: normal colour


; ----------------------------------------------------------------------
; printEnergyLabel - Prints the fixed word 'ENERGY' of the REPAIR tab at window column $1C, row 8
; (absolute column 30 inside the menu panel whose left edge is 2), in the current menu text colour.
; In:  none; setMenuTextColour picks $61 or $21 depending on the comcen stun flag
; Out: zp_3D, zp_3F, zp_40 set; the label drawn
; Called from: drawRepairTab at $17BE
; ----------------------------------------------------------------------
printEnergyLabel:
        jsr     setMenuTextColour       ; 16D6  white text, or white on the red panel while the comcen is stunned
        lda     #$08                    ; 16D9  row 8 of the screen
        sta     textCursorRow           ; 16DB  absolute cursor row
        lda     #$1C                    ; 16DD  column $1C relative to the panel's left edge (2) = screen column 30
        sta     textCursorCol           ; 16DF  cursor column
        jsr     updateCursorPointers    ; 16E1  recompute the cell pointers
        lda     #$B8                    ; 16E4  low byte of 'ENERGY'
        ldy     #$CB                    ; 16E6  $CBB8 = 'ENERGY'
        jmp     printString             ; 16E8  print it and return to drawRepairTab


; ----------------------------------------------------------------------
; setMenuTextColour - Chooses the text colour byte for the console menu screen: normally $61 (white
; text, highlight EOR mask 6 matching the blue panel), but $21 while the REPAIR tab is open and our
; own comcen is stunned, because drawRepairTab then paints the panel red instead of blue.
; In:  comcenStunStatus $922A (bit 7 = stunned), menuTabIndex $90F9
; Out: zp_3D textColour
; Called from: setMenuTextWindow $1575, printEnergyLabel $16D6, printEnergyValue $1702,
;     setRepairMessageWindow $19E4
; ----------------------------------------------------------------------
setMenuTextColour:
        lda     #$61                    ; 16EB  default: white text on the blue panel, EOR mask 6 for the row highlight
        ldy     comcenStunStatus        ; 16ED  $922A bit 7 = our comcen is stunned (bits 0-3 hold the stun counter)
        bpl     L_16FB                  ; 16F0  not stunned - keep $61
        ldy     menuTabIndex            ; 16F2  which tab is open?
        cpy     #$01                    ; 16F5  tab 1 = REPAIR
        bne     L_16FB                  ; 16F7  the other tabs keep the blue-panel colour
        lda     #$21                    ; 16F9  REPAIR tab while stunned: white text, EOR mask 2 for the red panel
L_16FB:
        sta     textColour              ; 16FB  store the chosen colour
        rts                             ; 16FD  return


; lastEnergyShown - the raw comcen energy value (0..50) that printEnergyValue last drew, so the
; reading is only redrawn when it actually changes. drawRepairTab presets it to $FF to force the first
; print.
lastEnergyShown:
        .byte   $00                     ; 16FE  .  on disk 0; drawRepairTab writes $FF to force the first print

; ----------------------------------------------------------------------
; printEnergyValue - Prints the comcen's energy as a percentage at column $22, row 9 of the REPAIR
; tab, right-aligned in a three-character field, but only when the value has changed since the last
; call. It then classifies the same value into comcenSystemStatus[0], the ENERGY entry of the four
; comcen systems: 0 = OK at 100%, $40 = degraded from 21% up, $80 = critical below that.
; In:  comcenUnit $9236 (49 or 99), unitEnergyTable $F9C4 (bits 0-5 = 0..50 energy), lastEnergyShown
;     $16FE
; Out: lastEnergyShown updated, comcenSystemStatus $9241 updated, up to three characters drawn; the
;     caller's text window is saved to $92B6 and restored
; Called from: mainMenuLoop at $1453 (every pass while the REPAIR tab is open), drawRepairTab at
;     $17C1, drawSystemMarker at $18A0
; Print the percentage, padded with leading blanks so it always occupies three cells.
; ----------------------------------------------------------------------
printEnergyValue:
        jsr     saveTextWindow          ; 16FF  save the caller's 12-byte text-window state to $92B6
        jsr     setMenuTextColour       ; 1702  menu text colour ($61, or $21 while stunned)
        lda     #$09                    ; 1705  the reading sits on row 9
        sta     textCursorRow           ; 1707  absolute cursor row
        lda     #$22                    ; 1709  column $22 relative to the panel left edge (2) = screen column 36
        sta     textCursorCol           ; 170B  cursor column
        jsr     updateCursorPointers    ; 170D  recompute the cell pointers
        ldy     comcenUnit              ; 1710  our own comcen: unit 49 (side 0) or 99 (side 1)
        lda     unitEnergyTable,y       ; 1713  energy array of the unit records, $64 apart from $F640
        and     #$3F                    ; 1716  bits 0-5 hold 0..50; the upper bits are other unit flags
        cmp     lastEnergyShown         ; 1718  compare with the value already on screen
        beq     L_173C                  ; 171B  unchanged - skip the (slow) redraw
        sta     lastEnergyShown         ; 171D  remember the new raw value
        asl     a                       ; 1720  x2 turns 0..50 into a 0..100 percentage
        cmp     #$64                    ; 1721  100?
        bcs     L_1737                  ; 1723  three digits - no padding
        pha                             ; 1725  keep the percentage
        lda     #$01                    ; 1726  one blank...
        jsr     bootReceiveSectorData   ; 1728  ...so a two-digit value is right-aligned  (also textEngineC000:printFillChars)
        pla                             ; 172B  get the percentage back
        cmp     #$0A                    ; 172C  10?
        bcs     L_1737                  ; 172E  two digits - one blank was enough
        pha                             ; 1730  keep the percentage
        lda     #$01                    ; 1731  a second blank...
        jsr     bootReceiveSectorData   ; 1733  ...so a one-digit value is right-aligned too  (also textEngineC000:printFillChars)
        pla                             ; 1736  get the percentage back
L_1737:
        ldy     #$00                    ; 1737  printDecimal wants a 24-bit value: middle byte 0 (zp_7E is already 0)
        jsr     printDecimal            ; 1739  print it without leading zeros

; ----------------------------------------------------------------------
; Recompute the ENERGY entry of comcenSystemStatus from the same reading. This runs even when the
; printing was skipped, so drawSystemMarker always sees a fresh status.
; ----------------------------------------------------------------------
L_173C:
        jsr     restoreTextWindow       ; 173C  put the caller's text window back
        ldy     comcenUnit              ; 173F  comcen unit index again
        lda     unitEnergyTable,y       ; 1742  its energy byte
        and     #$3F                    ; 1745  0..50
        asl     a                       ; 1747  0..100 percent
        ldy     #$00                    ; 1748  assume status 0 = system OK
        cmp     #$64                    ; 174A  exactly 100%?
        beq     L_1756                  ; 174C  yes - the energy system counts as undamaged
        ldy     #$40                    ; 174E  $40 = degraded (yellow marker)
        cmp     #$15                    ; 1750  21%?
        bcs     L_1756                  ; 1752  21% or more - degraded
        ldy     #$80                    ; 1754  $80 = critical/damaged (red marker)
L_1756:
        sty     comcenSystemStatus      ; 1756  store into comcenSystemStatus[0], the ENERGY system
        rts                             ; 1759  return


; ----------------------------------------------------------------------
; drawRepairTab - Builds the whole REPAIR tab: clears the panel in blue ($66) or, if our comcen is
; stunned, red ($22); unpacks the comcen schematic picture into the bitmap; paints a 20x10 colour
; block behind it; resets the blink and energy bookkeeping; draws the four system markers (only when
; not stunned); prints the ENERGY label and value; and, when stunned, prints 'COMCEN STUNNED!'. The
; stun flag is copied to $1413 so mainMenuLoop can notice a change and redraw.
; In:  comcenStunStatus $922A, comcenUnit $9236, unitEnergyTable $F9C4
; Out: $1413 = stun flag snapshot, zp_6E = 0, showOkMarkers = 0, blinkPhase = 0 then 1,
;     lastEnergyShown = $FF, comcenEnergySnapshot = current energy, menuSelectedItem $91D5 left at 4
;     (the (EXIT) row of the REPAIR WHICH menu), the panel redrawn
; Called from: mainMenuLoop at $1450 (stun state changed) and $14DD (REPAIR tab selected)
; Clear the panel and draw the comcen schematic with its backing colour block.
; ----------------------------------------------------------------------
drawRepairTab:
        ldy     #$66                    ; 175A  assume panel colour $66: blue text/EOR mask 6
        lda     comcenStunStatus        ; 175C  bit 7 = our comcen is stunned
        sta     repairTabStunCopy       ; 175F  snapshot for mainMenuLoop's 'has the stun state changed?' test at $1449
        bpl     L_1766                  ; 1762  not stunned - keep the blue panel
        ldy     #$22                    ; 1764  stunned: colour $22, a red panel
L_1766:
        sty     textColour              ; 1766  set the panel text colour
        jsr     bootDriveDataBit        ; 1768  blank the whole menu window in that colour  (also textEngineC000:clearTextWindow)
        jsr     drawComcenSchematic     ; 176B  $8632 in overlay B: RLE-unpacks the comcen side view into the bitmap at row 10, column 13 (the same address holds unrelated code in overlay A)  (also mapGenerator6F00:printOnOffMenuAndApplyBit)
        lda     #$0D                    ; 176E  colour-block origin column 13
        sta     windowOriginCol         ; 1770  fillScreenColourRect origin column
        lda     #$0A                    ; 1773  colour-block origin row 10
        sta     windowOriginRow         ; 1775  fillScreenColourRect origin row
        ldx     #$14                    ; 1778  20 cells wide - the width of the schematic
        ldy     #$0A                    ; 177A  10 cells tall
        lda     textColour              ; 177C  take the panel colour...
        and     #$0F                    ; 177E  ...keep its low nibble (6 blue, or 2 red)...
        ora     #$10                    ; 1780  ...and put white (1) in the upper nibble: $16 normally, $12 when stunned
        jsr     fillScreenColourRect    ; 1782  paint the schematic's background colour cells

; ----------------------------------------------------------------------
; Reset the blink/energy bookkeeping the marker code depends on.
; ----------------------------------------------------------------------
        ldx     #$00                    ; 1785  zero for the three flags below
        stx     menuBlinkTimer          ; 1787  blink countdown expired, so the next blink happens at once
        stx     showOkMarkers           ; 1789  only the REPAIR WHICH menu wants markers on healthy systems
        stx     blinkPhase              ; 178C  blink phase 0
        ldx     #$FF                    ; 178F  sentinel value
        stx     lastEnergyShown         ; 1791  force printEnergyValue to actually draw the reading
        ldx     comcenUnit              ; 1794  our comcen's unit index
        lda     unitEnergyTable,x       ; 1797  its energy byte
        and     #$3F                    ; 179A  0..50
        sta     comcenEnergySnapshot    ; 179C  remember it; attemptSystemRepair works from this snapshot

; ----------------------------------------------------------------------
; Draw the four system markers, unless the comcen is stunned (then it has no readable status at all).
; $91D5 doubles as the 3..0 loop counter.
; ----------------------------------------------------------------------
        lda     comcenStunStatus        ; 179F  stun flag once more
        bmi     L_17BE                  ; 17A2  stunned - no markers, jump straight to the ENERGY readout
        ldy     #$03                    ; 17A4  start at system 3 (DRONE)
        sty     menuSelectedItem        ; 17A6  menuSelectedItem is reused as the loop counter here
L_17A9:
        jsr     drawSystemMarker        ; 17A9  draw the marker of system Y
        dec     menuSelectedItem        ; 17AC  next system down
        ldy     menuSelectedItem        ; 17AF  reload the counter into Y (drawSystemMarker clobbers it)
        bpl     L_17A9                  ; 17B2  loop over systems 3,2,1,0
        lda     #$01                    ; 17B4  blink phase 1 = 'marker currently drawn'
        sta     blinkPhase              ; 17B6  so the first toggle erases it
        lda     #$04                    ; 17B9  menu row 4 of the REPAIR WHICH menu = (EXIT)
        sta     menuSelectedItem        ; 17BB  leave the selection on (EXIT)

; ----------------------------------------------------------------------
; ENERGY label and reading, plus the stun banner.
; ----------------------------------------------------------------------
L_17BE:
        jsr     printEnergyLabel        ; 17BE  print the word ENERGY at column 30, row 8
        jsr     printEnergyValue        ; 17C1  print the percentage at column 36, row 9 and refresh comcenSystemStatus[0]
        lda     comcenStunStatus        ; 17C4  stunned?
        bpl     L_17D7                  ; 17C7  no - done
        lda     #$05                    ; 17C9  banner starts at column 5 of the panel (screen column 7)
        sta     textCursorCol           ; 17CB  cursor column
        jsr     updateCursorPointers    ; 17CD  recompute the cell pointers (the row is wherever printEnergyValue left it)
        lda     #$42                    ; 17D0  low byte of 'COMCEN STUNNED!'
        ldy     #$87                    ; 17D2  $8742 in overlay B (msgComcenStunned)
        jsr     printString             ; 17D4  print the banner
L_17D7:
        rts                             ; 17D7  return


; comcenEnergySnapshot - the comcen's raw energy (0..50) as of the last REPAIR-tab redraw or repair
; attempt. attemptSystemRepair sizes the repair from it and adds the repaired amount back locally,
; because the real unitEnergyTable entry only changes once the $9A command has travelled through the
; lock-step exchange.
comcenEnergySnapshot:
        .byte   $00                     ; 17D8  .  0..50, refreshed by drawRepairTab and attemptSystemRepair

; repairAmount - energy points granted by the last successful ENERGY repair, computed as (50 -
; comcenEnergySnapshot)/8 + 1 and sent as the parameter of command $9A.
repairAmount:
        .byte   $00                     ; 17D9  .  parameter of the $9A cmdAddEnergy command

; ----------------------------------------------------------------------
; repairTabAction - Fire-button handler of the REPAIR tab (entry 1 of menuItemHandlerTable at $1535,
; reached through the self-modified JSR at $148D). It does nothing unless the DAMAGE option is on, the
; game is not a film playback and a game is actually running. If the comcen is stunned it runs a
; single stun-recovery attempt: message, sound, 200 frames of real-time battle, then - only while the
; stun counter is below 8 - a 1-in-8 chance of queueing command $9E, which clears the stun on both
; machines. If the comcen is not stunned it halts the comcen if it is still moving or under group
; orders, blanks the four markers and opens the REPAIR WHICH menu.
; In:  gameTypeOptions $0BA3 bit 4 (DAMAGE), filmPlaybackMode $0B9B bit 7, gameEndReason $0BA8,
;     comcenStunStatus $922A, comcenUnit $9236, unitDisplayFlagsTable $FB54,
;     unitColTable/unitRowTable, nextGameRandom
; Out: command $9E or $80 queued into the outgoing ring; sounds $12/$13/$18; the battle advanced by
;     runGameFrames; blinkPhase, showOkMarkers, $1412 (3..0 loop counter) and the four markers
;     updated; tail-calls snapshotSetupId so the menu's 'anything changed?' test is re-armed
; Called from: mainMenuLoop through menuItemHandlerTable[1] at $148D
; Guards: repairs need the DAMAGE option, a live game and player control.
; ----------------------------------------------------------------------
repairTabAction:
        lda     gameTypeOptions         ; 17DA  game settings byte
        and     #$10                    ; 17DD  bit 4 = the DAMAGE option is on
        beq     L_1822                  ; 17DF  no damage model - the REPAIR tab is decoration only
        lda     filmPlaybackMode        ; 17E1  bit 7 = a game film is playing back
        bmi     L_1822                  ; 17E4  during playback the player may not issue commands
        lda     gameEndReason           ; 17E6  0 while a game is running
        bne     L_1822                  ; 17E9  no game in progress - nothing to repair

; ----------------------------------------------------------------------
; Stunned comcen: one attempt at shaking the stun off.
; ----------------------------------------------------------------------
        lda     comcenStunStatus        ; 17EB  bit 7 = comcen stunned
        bpl     L_1823                  ; 17EE  not stunned - go and open the REPAIR WHICH menu
        jsr     printAttemptingRepair   ; 17F0  print 'ATTEMPTING REPAIR...' on the panel's message row
        lda     #$18                    ; 17F3  sound $18 = the repair work noise
        jsr     playSound               ; 17F5  start it
        lda     #$C8                    ; 17F8  200 frames...
        jsr     runGameFrames           ; 17FA  ...of real-time battle while the crew works
        jsr     clearRepairMessage      ; 17FD  erase the message row again
        ldy     #$12                    ; 1800  assume sound $12 = failure
        lda     comcenStunStatus        ; 1802  stun status byte
        and     #$0F                    ; 1805  bits 0-3 = how badly stunned (counts down elsewhere)
        cmp     #$08                    ; 1807  8 or more?
        bcs     L_1819                  ; 1809  too badly stunned - the attempt cannot succeed
        jsr     nextGameRandom          ; 180B  shared RNG so both machines and a replayed film agree
        and     #$07                    ; 180E  one chance in eight
        bne     L_1819                  ; 1810  not this time - keep the failure sound
        lda     #$9E                    ; 1812  $9E = cmdComcenRepairedOrSetupReady
        jsr     queueCmdBytePending     ; 1814  queue the 1-byte command; both machines clear the stun counter
        ldy     #$13                    ; 1817  sound $13 = success
L_1819:
        tya                             ; 1819  move the chosen sound id into A
        jsr     playSound               ; 181A  play it
        lda     #$14                    ; 181D  20 more frames...
        jsr     runGameFrames           ; 181F  ...so the sound is heard before the menu redraws
L_1822:
        rts                             ; 1822  return to mainMenuLoop


; ----------------------------------------------------------------------
; Not stunned: halt the comcen if necessary, blank the markers and open the 'REPAIR WHICH:' menu. A
; comcen that is driving or following a group order has to stop before it can be worked on, which is
; done by ordering it to move to the cell it already occupies - cmdMoveUnit also drops its group
; membership.
; ----------------------------------------------------------------------
L_1823:
        ldy     comcenUnit              ; 1823  our comcen's unit index
        lda     unitDisplayFlagsTable,y ; 1826  $FB54: bit 7 = idle/arrived, bit 6 = acting on a group order
        bpl     L_182F                  ; 1829  bit 7 clear = still moving - it must be halted
        and     #$40                    ; 182B  bit 6 = following a group order
        beq     L_1840                  ; 182D  idle and ungrouped - no halt command needed
L_182F:
        sty     cmdParam0               ; 182F  command argument 0 = the unit to move
        lda     unitColTable,y          ; 1831  current map column of the comcen
        sta     cmdParam1               ; 1834  command argument 1 = destination column
        lda     unitRowTable,y          ; 1836  current map row
        sta     cmdParam2               ; 1839  command argument 2 = destination row
        lda     #$80                    ; 183B  $80 = cmdMoveUnit
        jsr     queueCmd4               ; 183D  queue the 4-byte command: 'move to where you already are' = halt and leave the group
L_1840:
        ldy     #$01                    ; 1840  blink phase 1...
        sty     blinkPhase              ; 1842  ...so the first toggle in the menu erases the marker
        dey                             ; 1845  Y = 0
        sty     showOkMarkers           ; 1846  healthy systems stay invisible while the markers are wiped
        ldy     #$03                    ; 1849  start at system 3
        sty     repairLoopCounter       ; 184B  $1412 is the 3..0 loop counter here (drawRepairTab used $91D5 instead)
L_184E:
        ldx     #$16                    ; 184E  colour $16 = the schematic background (white/blue)
        jsr     eraseSystemMarker       ; 1850  blank system Y's 3x3 marker area
        dec     repairLoopCounter       ; 1853  next system down
        ldy     repairLoopCounter       ; 1856  reload the counter into Y
        bpl     L_184E                  ; 1859  loop over systems 3,2,1,0
        lda     #$01                    ; 185B  now show markers for healthy systems too...
        sta     showOkMarkers           ; 185D  ...so the player can pick any of the four in the menu
        jsr     runRepairWhichMenu      ; 1860  $652E: draw and run the 'REPAIR WHICH:' menu (ENERGY/BATTLE/RADAR/DRONE/(EXIT))
        jsr     consumeSpaceKey         ; 1863  swallow a pending space key so it is not re-processed
        jsr     setMenuTextWindow       ; 1866  put the menu panel's text window and colour back
        lda     #$00                    ; 1869  back to 'only damaged systems get a marker'
        sta     showOkMarkers           ; 186B  clear the flag
        ldy     #$03                    ; 186E  start at system 3 again
        sty     repairLoopCounter       ; 1870  reuse $1412 as the counter
L_1873:
        jsr     drawSystemMarker        ; 1873  redraw system Y's marker with its new status
        dec     repairLoopCounter       ; 1876  next system down
        ldy     repairLoopCounter       ; 1879  reload the counter
        bpl     L_1873                  ; 187C  loop over systems 3,2,1,0
        jmp     snapshotComcenStatus    ; 187E  re-arm selectDefaultMenuItem's 'has anything changed?' snapshot and return


; systemMarkerColumnTable - screen column of the marker of each comcen system, indexed by system
; number: 0 ENERGY, 1 BATTLE, 2 RADAR, 3 DRONE. The marker is a plus of five colour cells inside a 3x3
; block whose top-left corner is this column and systemMarkerRowTable[Y].
systemMarkerColumnTable:
        .byte   $0D,$10,$14,$19         ; 1881  ....  ENERGY $0D, BATTLE $10, RADAR $14, DRONE $19

; systemMarkerRowTable - screen row of each comcen system marker, same index as
; systemMarkerColumnTable. All four sit inside the schematic block drawn at row 10.
systemMarkerRowTable:
        .byte   $0E,$0A,$0A,$0C         ; 1885  ....  ENERGY $0E, BATTLE $0A, RADAR $0A, DRONE $0C

; blinkPhase - the shared 0/1 blink phase used by both the console tab blink (blinkSelectedTab) and
; the comcen system marker blink (toggleSystemMarker). 0 = the item is currently shown in its 'on'
; state.
blinkPhase:
        .byte   $00                     ; 1889  .  0 or 1

; showOkMarkers - when non-zero, drawSystemMarker also draws a marker for a system whose status is OK
; (green) instead of erasing its cells. Set only while the REPAIR WHICH menu is open, so all four
; systems can be selected.
showOkMarkers:
        .byte   $00                     ; 188A  .  1 only while the REPAIR WHICH menu is up

; ----------------------------------------------------------------------
; toggleSystemMarker - One blink step for the marker of the comcen system currently highlighted in the
; REPAIR WHICH menu: flips blinkPhase and then either draws the marker (new phase 0) or erases it with
; the schematic background colour $16 (new phase 1).
; In:  menuSelectedItem $91D5 = system 0..3, blinkPhase $1889
; Out: blinkPhase toggled; falls through to drawSystemMarker or jumps to eraseSystemMarker with X =
;     $16
; Called from: blinkRepairMarker at $19C1, and toggleMenuRowHighlight at $6108 whenever the menu
;     selector is running in repair mode (selectedUnit $90F8 == $FF) on a row below 4, so the marker
;     blinks in step with the highlighted row
; ----------------------------------------------------------------------
toggleSystemMarker:
        ldy     menuSelectedItem        ; 188B  the highlighted comcen system, 0 ENERGY .. 3 DRONE
        ldx     #$16                    ; 188E  $16 = white/blue, the schematic background - used if we are erasing
        lda     blinkPhase              ; 1890  current blink phase
        eor     #$01                    ; 1893  flip it
        sta     blinkPhase              ; 1895  store the new phase
        beq     drawSystemMarker        ; 1898  new phase 0 - draw the marker (falls straight into drawSystemMarker)
        jmp     eraseSystemMarker       ; 189A  new phase 1 - wipe the 3x3 marker block


; ----------------------------------------------------------------------
; drawSystemMarker - Draws the status marker of comcen system Y on the REPAIR tab schematic. The
; marker is a plus made of five 1x1 colour-cell fills around (systemMarkerRowTable[Y],
; systemMarkerColumnTable[Y]): white/green $15 when the system is OK, white/red $12 when its status
; byte has bit 7 set (damaged) and white/yellow $17 otherwise ($40 = degraded). A healthy system is
; wiped instead of drawn unless showOkMarkers is set. Y has to be stashed in a self-modified operand
; because the energy refresh in the first line destroys it.
; In:  Y = system index 0..3, comcenSystemStatus $9241, showOkMarkers $188A
; Out: five (or nine, when erasing) screen-RAM colour cells at $8C00 updated; D_2A2A holds the fill
;     colour, D_2A2D/D_2A2E the running row/column; the operand at $18A4 patched; the ENERGY reading
;     refreshed as a side effect
; Called from: drawRepairTab's loop at $17A9, repairTabAction's redraw loop at $1873, and by falling
;     through from toggleSystemMarker
; Pick the marker colour from the system's status byte.
; ----------------------------------------------------------------------
drawSystemMarker:
        sty     L_18A3+1                ; 189D  self-modifying code: save Y into the LDY #imm operand at $18A4 - printEnergyValue clobbers Y
        jsr     printEnergyValue        ; 18A0  refresh the percentage; this also recomputes comcenSystemStatus[0] for the ENERGY system
L_18A3:
        ldy     #$FF                    ; 18A3  operand written at $189D = the system index
        ldx     #$15                    ; 18A5  assume $15 = white/green (system OK)
        lda     comcenSystemStatus,y    ; 18A7  $9241+Y: 0 OK, $40 degraded, $80 damaged
        beq     L_18B5                  ; 18AA  status 0 - keep green
        ldx     #$12                    ; 18AC  $12 = white/red (damaged)
        lda     comcenSystemStatus,y    ; 18AE  look at the status again
        bmi     L_18B5                  ; 18B1  bit 7 set = damaged - keep red
        ldx     #$17                    ; 18B3  $17 = white/yellow (degraded, i.e. $40)

; ----------------------------------------------------------------------
; A healthy system normally shows nothing at all; only the REPAIR WHICH menu wants green markers so
; that every system can be picked.
; ----------------------------------------------------------------------
L_18B5:
        cpx     #$15                    ; 18B5  is the colour the 'OK' green?
        bne     L_18C3                  ; 18B7  no - draw the marker
        lda     showOkMarkers           ; 18B9  are healthy systems supposed to show a marker?
        bne     L_18C3                  ; 18BC  yes (REPAIR WHICH menu open) - draw it
        ldx     #$16                    ; 18BE  $16 = schematic background
        jmp     eraseSystemMarker       ; 18C0  wipe the whole 3x3 block instead and return to the caller


; ----------------------------------------------------------------------
; Paint the plus: (r,c+1), (r+2,c+1), (r+1,c), (r+1,c+1), (r+1,c+2). D_2A2D/D_2A2E are stepped between
; the five 1x1 fills; fillScreenColourRect reloads D_2A2A from A, so the colour is fetched again
; before each fill.
; ----------------------------------------------------------------------
L_18C3:
        stx     drawColourByte          ; 18C3  keep the chosen colour byte in the fill-colour slot
        lda     systemMarkerColumnTable,y; 18C6  screen column of this system's marker
        sta     windowOriginCol         ; 18C9  fill origin column
        lda     systemMarkerRowTable,y  ; 18CC  screen row of this system's marker
        sta     windowOriginRow         ; 18CF  fill origin row
        inc     windowOriginCol         ; 18D2  step right one cell: top arm of the plus
        lda     drawColourByte          ; 18D5  marker colour
        ldx     #$01                    ; 18D8  1 cell wide
        ldy     #$01                    ; 18DA  1 cell tall
        jsr     fillScreenColourRect    ; 18DC  paint (row, col+1)
        inc     windowOriginRow         ; 18DF  down one row...
        inc     windowOriginRow         ; 18E2  ...and one more: bottom arm
        lda     drawColourByte          ; 18E5  marker colour
        ldx     #$01                    ; 18E8  1 cell wide
        ldy     #$01                    ; 18EA  1 cell tall
        jsr     fillScreenColourRect    ; 18EC  paint (row+2, col+1)
        dec     windowOriginRow         ; 18EF  back up to the middle row
        dec     windowOriginCol         ; 18F2  back left to the original column
        lda     drawColourByte          ; 18F5  marker colour
        ldx     #$01                    ; 18F8  1 cell wide
        ldy     #$01                    ; 18FA  1 cell tall
        jsr     fillScreenColourRect    ; 18FC  paint (row+1, col) - left arm
        inc     windowOriginCol         ; 18FF  step right
        lda     drawColourByte          ; 1902  marker colour
        ldx     #$01                    ; 1905  1 cell wide
        ldy     #$01                    ; 1907  1 cell tall
        jsr     fillScreenColourRect    ; 1909  paint (row+1, col+1) - centre
        inc     windowOriginCol         ; 190C  step right again
        lda     drawColourByte          ; 190F  marker colour
        ldx     #$01                    ; 1912  1 cell wide
        ldy     #$01                    ; 1914  1 cell tall
        jmp     fillScreenColourRect    ; 1916  paint (row+1, col+2) - right arm, and return to the caller


; ----------------------------------------------------------------------
; eraseSystemMarker - Fills the whole 3x3 colour-cell block of system Y's marker with the colour byte
; in X. Every caller passes $16, the white/blue background the schematic is drawn on, so this blanks
; the marker.
; In:  Y = system index 0..3, X = colour byte (always $16)
; Out: D_2A2D/D_2A2E set to the marker origin, nine screen-RAM colour bytes overwritten
; Called from: repairTabAction's wipe loop at $1850, toggleSystemMarker at $189A (blink off),
;     drawSystemMarker at $18C0 (healthy system with showOkMarkers clear)
; ----------------------------------------------------------------------
eraseSystemMarker:
        lda     systemMarkerColumnTable,y; 1919  screen column of this system's marker
        sta     windowOriginCol         ; 191C  fill origin column
        lda     systemMarkerRowTable,y  ; 191F  screen row of this system's marker
        sta     windowOriginRow         ; 1922  fill origin row
        txa                             ; 1925  the colour byte arrives in X, fillScreenColourRect wants it in A
        ldx     #$03                    ; 1926  3 cells wide
        ldy     #$03                    ; 1928  3 cells tall - covers the whole plus
        jmp     fillScreenColourRect    ; 192A  paint the block and return to the caller


; ----------------------------------------------------------------------
; sendEnergyRepairCommand - Queues the 3-byte command $9A (cmdAddEnergy) with the comcen's unit index
; and the repaired amount, so that both machines add the same energy to that unit. This is the only
; part of a comcen repair that travels over the link - the status of systems 1-3 is purely local
; cosmetics.
; In:  comcenUnit $9236, repairAmount $17D9
; Out: zp_74 = unit, zp_75 = amount, the command written into the outgoing ring and pendingOwnCmdCount
;     $929F bumped
; Called from: attemptSystemRepair at $196C
; ----------------------------------------------------------------------
sendEnergyRepairCommand:
        lda     comcenUnit              ; 192D  our comcen (unit 49 or 99)
        sta     cmdParam1               ; 1930  command argument 1 = unit index
        lda     repairAmount            ; 1932  energy points won by this repair
        sta     cmdParam2               ; 1935  command argument 2 = amount
        lda     #$9A                    ; 1937  $9A = cmdAddEnergy
        jmp     queueCmd3               ; 1939  queue the 3-byte command and return to the caller


; ----------------------------------------------------------------------
; attemptSystemRepair - Resolves one repair attempt on the comcen system highlighted in the REPAIR
; WHICH menu. It shows the message, plays the work sound and rolls the shared RNG: the attempt
; succeeds with probability 1/2 for the ENERGY system and 1/4 for the other three, and only if the
; system is not already OK. For ENERGY it computes (50 - snapshot)/8 + 1 points, sends them with
; command $9A and updates the snapshot (capped at 50); for the others it just clears the status byte.
; Success plays sound $13, failure $12, each surrounded by about a second of marker blinking during
; which the real-time battle keeps running.
; In:  menuSelectedItem $91D5 = system 0..3, comcenSystemStatus $9241, comcenEnergySnapshot $17D8,
;     comcenUnit $9236, unitEnergyTable $F9C4, nextGameRandom
; Out: repairAmount, comcenEnergySnapshot, comcenSystemStatus[Y], one $9A command, sounds $12/$13/$18,
;     zp_6E = 0, blinkPhase = 0 and the message row cleared
; Called from: the REPAIR WHICH menu loop at $6590 (skipped in solo/film mode)
; Announce the attempt and roll for success.
; ----------------------------------------------------------------------
attemptSystemRepair:
        jsr     printAttemptingRepair   ; 193C  print 'ATTEMPTING REPAIR...'
        lda     #$18                    ; 193F  sound $18 = repair work noise
        jsr     playSound               ; 1941  start it
        ldy     menuSelectedItem        ; 1944  the system the player picked: 0 ENERGY, 1 BATTLE, 2 RADAR, 3 DRONE
        jsr     nextGameRandom          ; 1947  shared RNG - both machines and a replayed film draw the same number
        cpy     #$00                    ; 194A  the ENERGY system?
        bne     L_1950                  ; 194C  systems 1-3 keep both random bits
        and     #$01                    ; 194E  ENERGY: one bit only, so a 1-in-2 chance
L_1950:
        and     #$03                    ; 1950  1-in-4 for systems 1-3 (already narrowed to 1-in-2 above)
        bne     L_1990                  ; 1952  non-zero - the attempt failed
        lda     comcenSystemStatus,y    ; 1954  status of the chosen system
        beq     L_1990                  ; 1957  already OK - treat it as a failure, there is nothing to repair

; ----------------------------------------------------------------------
; ENERGY: work out how much energy the crew restores and tell the opponent.
; ----------------------------------------------------------------------
        cpy     #$00                    ; 1959  the ENERGY system?
        bne     L_197F                  ; 195B  systems 1-3 just get their status cleared
        lda     #$32                    ; 195D  50 = a full comcen energy bar
        sec                             ; 195F  prepare the subtraction
        sbc     comcenEnergySnapshot    ; 1960  how much energy is missing
        lsr     a                       ; 1963  divide by 8...
        lsr     a                       ; 1964  ...
        lsr     a                       ; 1965  ...so a badly hurt comcen recovers faster
        clc                             ; 1966  prepare the add
        adc     #$01                    ; 1967  always at least one point
        sta     repairAmount            ; 1969  remember what we granted
        jsr     sendEnergyRepairCommand ; 196C  queue command $9A so both machines credit the energy
        clc                             ; 196F  prepare the add
        lda     comcenEnergySnapshot    ; 1970  energy before the repair
        adc     repairAmount            ; 1973  plus the repaired amount
        cmp     #$33                    ; 1976  51 or more?
        bcc     L_197C                  ; 1978  still below the maximum
        lda     #$32                    ; 197A  clamp at 50
L_197C:
        sta     comcenEnergySnapshot    ; 197C  update the local snapshot (the real table only changes when $9A executes)

; ----------------------------------------------------------------------
; Success: blink, clear the status, chime. Failure joins at $1990.
; ----------------------------------------------------------------------
L_197F:
        jsr     blinkRepairMarker       ; 197F  about a second of marker blinking while the battle runs on
        ldy     menuSelectedItem        ; 1982  the chosen system again
        beq     L_198C                  ; 1985  ENERGY - its status is recomputed by printEnergyValue, not stored here
        lda     #$00                    ; 1987  0 = system OK
        sta     comcenSystemStatus,y    ; 1989  mark BATTLE/RADAR/DRONE repaired
L_198C:
        lda     #$13                    ; 198C  sound $13 = repair succeeded
        bne     L_1995                  ; 198E  always taken
L_1990:
        jsr     blinkRepairMarker       ; 1990  failure: the same blink pause so both outcomes take the same time
        lda     #$12                    ; 1993  sound $12 = repair failed
L_1995:
        jsr     playSound               ; 1995  play the outcome sound
        jsr     blinkRepairMarker       ; 1998  another second of blinking while it sounds

; ----------------------------------------------------------------------
; Re-read the real energy (the $9A command may have been applied in the meantime) and clean up the
; blink state and the message row.
; ----------------------------------------------------------------------
        ldy     comcenUnit              ; 199B  our comcen
        lda     unitEnergyTable,y       ; 199E  its energy byte
        and     #$3F                    ; 19A1  0..50
        sta     comcenEnergySnapshot    ; 19A3  refresh the snapshot from the live table
        lda     #$00                    ; 19A6  zero...
        sta     menuBlinkTimer          ; 19A8  ...so the menu-row blink fires again on the next pass
        sta     blinkPhase              ; 19AA  ...and the marker is left in the 'drawn' phase
        jmp     clearRepairMessage      ; 19AD  erase 'ATTEMPTING REPAIR...' and return to the menu loop


; ----------------------------------------------------------------------
; blinkRepairMarker - Blinks the marker of the selected comcen system for about one second while a
; repair attempt resolves. It arms the IRQ frame countdown zp_6F with 60 and toggles the marker every
; 20 game frames; runGameFrames is used rather than a plain delay so that the real-time battle (and
; the lock-step command exchange) keeps running. It bails out at once if the game has ended.
; In:  menuSelectedItem $91D5, gameEndCountdown $92C9
; Out: zp_6F counted down by the raster IRQ, blinkPhase toggled several times, the marker left in
;     whichever phase it ended on, the battle advanced
; Called from: attemptSystemRepair at $197F, $1990 and $1998
; ----------------------------------------------------------------------
blinkRepairMarker:
        lda     #$3C                    ; 19B0  60 frames, about one second
        sta     markerBlinkTimer        ; 19B2  the raster IRQ counts zp_6F down
L_19B4:
        lda     #$14                    ; 19B4  20 frames...
        jsr     runGameFrames           ; 19B6  ...of real-time battle between toggles
        lda     gameEndCountdown        ; 19B9  non-zero once the game is over
        bne     L_19C8                  ; 19BC  the game ended while we blinked - stop immediately
        ldy     menuSelectedItem        ; 19BE  the highlighted comcen system
        jsr     toggleSystemMarker      ; 19C1  flip the marker on or off
        lda     markerBlinkTimer        ; 19C4  has the one-second countdown expired?
        bne     L_19B4                  ; 19C6  no - keep blinking
L_19C8:
        rts                             ; 19C8  return


; ----------------------------------------------------------------------
; printAttemptingRepair - Positions the REPAIR panel's own message row (window column 10, screen
; column 12, screen row 20) and prints 'ATTEMPTING REPAIR...' there, then restores the text window the
; caller had.
; In:  none
; Out: 20 characters drawn on the panel's message row; the text window restored from the single save
;     slot $92B6
; Called from: repairTabAction at $17F0 and attemptSystemRepair at $193C
; ----------------------------------------------------------------------
printAttemptingRepair:
        jsr     setRepairMessageWindow  ; 19C9  save the caller's window and move to column 10, row 20
        lda     #$E5                    ; 19CC  low byte of the message
        ldy     #$CB                    ; 19CE  $CBE5 = 'ATTEMPTING REPAIR...'
        jsr     printString             ; 19D0  print it
        jmp     restoreTextWindow       ; 19D3  put the caller's text window back and return


; ----------------------------------------------------------------------
; clearRepairMessage - Erases the REPAIR panel's message row by printing 20 copies of the blank glyph
; zp_3E over it, then restores the caller's text window. (The routine it calls is the $C000 overlay's
; printFillChars at $C0EF; the listing's bootReceiveSectorData label is a stale leftover from the boot
; loader that used to live at $C000.)
; In:  zp_3E fill glyph
; Out: 20 cells of the panel's message row blanked; the text window restored from $92B6
; Called from: repairTabAction at $17FD and attemptSystemRepair at $19AD
; ----------------------------------------------------------------------
clearRepairMessage:
        jsr     setRepairMessageWindow  ; 19D6  same window and cursor position as printAttemptingRepair
        lda     #$14                    ; 19D9  20 characters - the length of 'ATTEMPTING REPAIR...'
        jsr     bootReceiveSectorData   ; 19DB  printFillChars ($C0EF): print the blank glyph that many times  (also textEngineC000:printFillChars)
        jmp     restoreTextWindow       ; 19DE  put the caller's text window back and return


; ----------------------------------------------------------------------
; setRepairMessageWindow - Shared prologue of printAttemptingRepair and clearRepairMessage: saves the
; caller's 12-byte text state, picks the menu text colour, applies the menu panel geometry (left
; column 2, width $24, rows 7..$16) and places the cursor at window column 10 = screen column 12 of
; screen row 20.
; In:  comcenStunStatus and menuTabIndex through setMenuTextColour
; Out: $92B6-$92C1 = saved window, zp_39-zp_40 = menu window, zp_3D = $61 or $21, cursor pointers
;     recomputed
; Called from: printAttemptingRepair $19C9, clearRepairMessage $19D6
; ----------------------------------------------------------------------
setRepairMessageWindow:
        jsr     saveTextWindow          ; 19E1  copy zp_39-zp_44 to $92B6 (there is only one save slot, so this cannot nest)
        jsr     setMenuTextColour       ; 19E4  $61, or $21 while the stunned-comcen panel is red
        jsr     setMenuWindowBounds     ; 19E7  menu panel window: left column 2, width $24, rows 7..$16
        lda     #$0A                    ; 19EA  column 10 of the window (screen column 12)
        sta     textCursorCol           ; 19EC  cursor column
        lda     #$14                    ; 19EE  screen row 20 - the panel's message row
        jmp     setCursorRow            ; 19F0  set the row, recompute the pointers and return to the caller


; ----------------------------------------------------------------------
; printOptionsList - Prints the body of the MISC tab. While a game is running it lists the in-game
; options ABORT GAME, VOICE PAUSE (only for a modem game that is not a film playback) and ' (EXIT)'.
; Otherwise it prints the five main-menu lines COMPETE WITH MODEM OPPONENT / PRACTICE WITH SOLO
; TRAINER / WATCH GAME FILM / SAVE GAME FILM / LOAD GAME FILM, plus DISCONNECT/SET VOICE when the comm
; module reports the link is up.
; In:  gameEndReason $0BA8, gameEndCountdown $92C9, filmPlaybackMode $0B9B, isSoloTrainer $0BA5,
;     link-active flag $E03B
; Out: the item text drawn from row 9 down; zp_39 = 9, zp_3B = 6, zp_3C = $1D, zp_3D = $61, zp_3F = 0,
;     zp_40 left after the last line
; Called from: mainMenuLoop at $14EA, miscTabAction at $1A53, $1A83 and $1AD0
; Set up the option-list window and print the '   OPTIONS' heading.
; ----------------------------------------------------------------------
printOptionsList:
        lda     #$06                    ; 19F3  items start at screen column 6
        sta     textWindowLeft          ; 19F5  window left edge
        lda     #$09                    ; 19F7  first row of the list is screen row 9
        sta     textWindowTop           ; 19F9  window top
        sta     textCursorRow           ; 19FB  and the cursor row
        lda     #$1D                    ; 19FD  29 columns wide - also the width of the menu highlight bar
        sta     textWindowWidth         ; 19FF  window width
        lda     #$00                    ; 1A01  column 0 of the window
        sta     textCursorCol           ; 1A03  cursor column
        jsr     updateCursorPointers    ; 1A05  recompute the cell pointers
        lda     #$61                    ; 1A08  white text, EOR mask 6 for the highlight bar
        sta     textColour              ; 1A0A  set the colour
        lda     #$13                    ; 1A0C  low byte of the heading
        ldy     #$CB                    ; 1A0E  $CB13 = '   OPTIONS' followed by a CR
        jsr     printString             ; 1A10  print it

; ----------------------------------------------------------------------
; Decide which list to print.
; ----------------------------------------------------------------------
        lda     gameEndReason           ; 1A13  0 while a game is running
        ora     gameEndCountdown        ; 1A16  non-zero while the end-of-game sequence runs (endGame sets it to $78 and the IRQ counts it up to $80)
        bne     L_1A36                  ; 1A19  no live game - print the main menu instead

; ----------------------------------------------------------------------
; In-game options.
; ----------------------------------------------------------------------
        lda     #$A6                    ; 1A1B  low byte of 'ABORT GAME'
        ldy     #$CC                    ; 1A1D  $CCA6 = 'ABORT GAME' + CR
        jsr     printString             ; 1A1F  print it; the pointer is left at $CCB1 = 'VOICE PAUSE'
        lda     filmPlaybackMode        ; 1A22  bit 7 = film playback
        bmi     L_1A2F                  ; 1A25  you cannot ring your opponent during a replay
        lda     isSoloTrainer           ; 1A27  bit 7 = solo trainer game
        bmi     L_1A2F                  ; 1A2A  the trainer has no phone - skip VOICE PAUSE
        jsr     printStringAtPointer    ; 1A2C  print the next string in the block: 'VOICE PAUSE' + CR
L_1A2F:
        lda     #$B2                    ; 1A2F  low byte of ' (EXIT)'
        ldy     #$CA                    ; 1A31  $CAB2 = ' (EXIT)', the tail of the formation-menu string block
        jmp     printString             ; 1A33  print the last item and return


; ----------------------------------------------------------------------
; Main menu (no game running).
; ----------------------------------------------------------------------
L_1A36:
        lda     #$1F                    ; 1A36  low byte of mainMenuItemText
        ldy     #$CC                    ; 1A38  $CC1F = four CR-separated lines: COMPETE WITH MODEM OPPONENT / PRACTICE WITH SOLO TRAINER / WATCH GAME FILM / SAVE GAME FILM
        jsr     printString             ; 1A3A  print all four; the pointer is left at $CC75
        jsr     printStringAtPointer    ; 1A3D  print the next string: 'LOAD GAME FILM '
        lda     isLinkActive            ; 1A40  comm module's link-active flag  (also trainerAiE000:isLinkActive)
        beq     L_1A48                  ; 1A43  no link - the menu has five items
        jsr     printStringAtPointer    ; 1A45  link up: print 'DISCONNECT/SET VOICE ' as item 5
L_1A48:
        rts                             ; 1A48  return


; ----------------------------------------------------------------------
; miscTabAction - Fire-button handler of the MISC tab (entry 2 of menuItemHandlerTable at $1535) and,
; through the JMP at $0AC5, the top of the whole main-menu loop at cold start. With a game running it
; runs the in-game options menu; picking ABORT GAME tells a connected opponent with command $8C reason
; 0, waits up to $26 ticks for the exchange to drain, hangs up and restores the status message. It
; then falls into an endless loop that clears the panel, undoes any film-playback settings, reprints
; the option list and runs the main menu.
; In:  gameEndCountdown $92C9, gameEndReason $0BA8, filmPlaybackMode $0B9B, isSoloTrainer $0BA5, link
;     flags $E03B/$E030, linkSessionState $0B9C, persistentMessageId $92A1
; Out: menu state $91D4/$91D5/$91D6, command $8C queued, linkPollDelay $9163 = 0, zp_7F timeout, the
;     operand at $1AC0 patched with the saved message id; may end the session through
;     endGameToMainMenu. Never returns while no game is running - runMainMenu leaves through
;     handleMainMenuChoice instead
; Called from: mainMenuLoop through menuItemHandlerTable[2] at $148D, and JMP $0AC5 (cold start into
;     the main menu)
; Run the in-game options menu, if there is a game to have options about.
; ----------------------------------------------------------------------
miscTabAction:
        lda     gameEndCountdown        ; 1A49  non-zero during the countdown after a game has just ended
        bne     L_1AC7                  ; 1A4C  the game is over - go straight to the session teardown
        lda     gameEndReason           ; 1A4E  0 while a game is running
        bne     L_1ACA                  ; 1A51  no game at all - go straight to the main-menu loop
        jsr     printOptionsList        ; 1A53  print ABORT GAME / [VOICE PAUSE] / (EXIT)
        ldy     #$02                    ; 1A56  assume two items
        lda     filmPlaybackMode        ; 1A58  bit 7 = film playback
        bmi     L_1A63                  ; 1A5B  no VOICE PAUSE during a replay
        lda     isSoloTrainer           ; 1A5D  bit 7 = solo trainer
        bmi     L_1A63                  ; 1A60  no VOICE PAUSE against the trainer
        iny                             ; 1A62  modem game: three items
L_1A63:
        sty     menuItemCount           ; 1A63  tell the menu selector how many rows there are
        dey                             ; 1A66  index of the last item
        sty     menuSelectedItem        ; 1A67  preselect (EXIT)
        dec     textWindowLeft          ; 1A6A  shift the window left edge to 5 so the highlight bar starts one column left of the text
        lda     textWindowLeft          ; 1A6C  the new left edge...
        sta     textCursorCol           ; 1A6E  ...also used as the cursor column; toggleMenuRowHighlight forces column 0 anyway, so this looks vestigial
        lda     #$0B                    ; 1A70  the first menu row is screen row 11
        sta     menuFirstRow            ; 1A72  the selector adds the selection index to this
        jsr     setCursorRow            ; 1A75  put the cursor on that row
        jsr     runInGameOptionsMenu    ; 1A78  run the selector; it handles VOICE PAUSE itself and returns Y = chosen item
        ldy     menuSelectedItem        ; 1A7B  which item was picked?
        beq     L_1A86                  ; 1A7E  item 0 = ABORT GAME
        jsr     setMenuTextWindow       ; 1A80  (EXIT): restore the menu window and colour...
        jmp     printOptionsList        ; 1A83  ...reprint the list and return to mainMenuLoop


; ----------------------------------------------------------------------
; ABORT GAME (also the cold-start entry from $0AC5). Tell the opponent, drain the link and hang up,
; then fall through into the session teardown.
; ----------------------------------------------------------------------
L_1A86:
        lda     persistentMessageId     ; 1A86  the message currently pinned on the status line
        sta     L_1ABF+1                ; 1A89  self-modifying code: it becomes the LDA #imm operand at $1AC0, so it can be restored at $1ABF
        lda     isLinkActive            ; 1A8C  is the comm link up?  (also trainerAiE000:isLinkActive)
        beq     L_1ABF                  ; 1A8F  no link - nothing to tell anybody
        lda     linkSessionState        ; 1A91  0 none, 2 game session agreed, $80 link up at menu level, $FF link lost
        bmi     L_1ABF                  ; 1A94  $80/$FF: no game session to abort
        beq     L_1ABC                  ; 1A96  0: no session at all - just hang up
        lda     #$00                    ; 1A98  reason 0 = 'I am leaving the game'
        sta     cmdParam0               ; 1A9A  command argument 0
        sta     linkPollDelay           ; 1A9C  clear the exchange throttle so the packet goes out at full speed
        lda     commBuildId             ; 1A9F  $E030 = comm build id: $00 modem driver, $FF solo trainer  (also trainerAiE000:commBuildId)
        bmi     L_1ABC                  ; 1AA2  the trainer discards everything we send - skip the handshake
        jsr     showWorkingMessage      ; 1AA4  put 'WORKING...' on the status line
        lda     #$8C                    ; 1AA7  $8C = cmdSessionControl
        jsr     queueCmd2               ; 1AA9  queue the 2-byte command (code + reason)
        lda     #$26                    ; 1AAC  $26 ticks...
        sta     linkWaitTimer           ; 1AAE  ...as the timeout (zp_7F is decremented every 8th frame by the IRQ)
L_1AB0:
        jsr     serviceCommandExchange  ; 1AB0  keep the lock-step exchange running so the packet is actually sent
        lda     exchangeFlags           ; 1AB3  $E01D = the comm module's exchange flag byte  (also trainerAiE000:commInterfaceBlock)
        beq     L_1ABF                  ; 1AB6  all flags clear - nothing is in flight any more
        lda     linkWaitTimer           ; 1AB8  has the timeout expired?
        bne     L_1AB0                  ; 1ABA  no - keep servicing the link
L_1ABC:
        jsr     hangUpModem             ; 1ABC  drop the line (prompting the player to pick up the handset first)
L_1ABF:
        lda     #$FF                    ; 1ABF  operand patched at $1A89 = the message id that was on the status line
        sta     persistentMessageId     ; 1AC1  pin it again
        jsr     showStatusMessage       ; 1AC4  and redisplay it

; ----------------------------------------------------------------------
; Session teardown and the main-menu loop. runMainMenu normally never comes back: it jumps into
; handleMainMenuChoice in the $6F00 overlay.
; ----------------------------------------------------------------------
L_1AC7:
        jsr     endGameToMainMenu       ; 1AC7  reset all the per-game state (also entered directly when the game just ended)
L_1ACA:
        jsr     clearMenuTextWindow     ; 1ACA  clear the menu panel
        jsr     restoreSettingsAfterFilm; 1ACD  if we were watching a film, put the real game settings back
        jsr     printOptionsList        ; 1AD0  print the five- or six-item main menu
        jsr     runMainMenu             ; 1AD3  run it; only DISCONNECT/SET VOICE returns here
        jsr     clearMenuTextWindow     ; 1AD6  clear the panel again
        jmp     miscTabAction           ; 1AD9  and start over


; ----------------------------------------------------------------------
; endGameToMainMenu - Tears a finished game session down and prepares the main menu. It clears the
; end-of-game countdown and the pause state, empties the outgoing command ring, silences the sound
; unless a win/lose jingle is still playing, marks 'no game in progress', writes the game film out
; when the battle really ended, chooses a sensible default main-menu item, and leaves the link either
; flushed and up ($0B9C = $80) or down (0).
; In:  sndVoiceSoundId $67C8, comm build id $E030, link-active flag $E03B, gamePhase zp_B1,
;     filmPlaybackMode $0B9B, linkSessionState $0B9C, gameEndReason $0BA8
; Out: $92C9 = 0, zp_BE = 0, $908D/$908E = 0, defaultMainMenuItem $92FD, gameEndReason $0BA8 >= 1,
;     film header written, $0B9C = 0 or $80
; Called from: miscTabAction at $1AC7 and by JMP from the $C000 overlay at $C879 when $92C9 goes
;     negative
; Clear the per-game state.
; ----------------------------------------------------------------------
endGameToMainMenu:
        lda     #$00                    ; 1ADC  zero for the five stores below
        sta     gameEndCountdown        ; 1ADE  cancel the end-of-game countdown
        sta     pauseState              ; 1AE1  clear own/opponent pause and voice-pause bits
        sta     outgoingCmdWriteIndex   ; 1AE3  empty the outgoing command ring (write index)
        sta     outgoingCmdReadIndex    ; 1AE6  and its read index
        sta     defaultMainMenuItem     ; 1AE9  default main-menu item 0 = COMPETE WITH MODEM OPPONENT
        lda     sndVoiceSoundId         ; 1AEC  sound id currently playing on the 'voice' channel
        cmp     #$0D                    ; 1AEF  $0D = the 'we won' fanfare
        beq     L_1AFA                  ; 1AF1  let it finish
        cmp     #$0E                    ; 1AF3  $0E = the 'we lost' theme
        beq     L_1AFA                  ; 1AF5  let that finish too
        jsr     stopAllSound            ; 1AF7  otherwise silence all three SID voices

; ----------------------------------------------------------------------
; Pick the default main-menu item and finalise the film.
; ----------------------------------------------------------------------
L_1AFA:
        ldy     commBuildId             ; 1AFA  $E030: $FF = the solo trainer module is behind the $E000 jump table  (also trainerAiE000:commBuildId)
        bpl     L_1B07                  ; 1AFD  modem build - the link flag means something
        lda     #$00                    ; 1AFF  a trainer game never has a real link...
        sta     isLinkActive            ; 1B01  ...so force the link-active flag down  (also trainerAiE000:isLinkActive)
        inc     defaultMainMenuItem     ; 1B04  default item 1 = PRACTICE WITH SOLO TRAINER
L_1B07:
        lda     gameEndReason           ; 1B07  0 while a game was running
        bne     L_1B0F                  ; 1B0A  already non-zero - leave the real end reason alone
        inc     gameEndReason           ; 1B0C  0 -> 1 = 'no game in progress'
L_1B0F:
        lda     gamePhase               ; 1B0F  zp_B1 is 0 only during the actual battle
        bne     L_1B20                  ; 1B11  we were in setup, film or menu phase - no film to close
        lda     filmPlaybackMode        ; 1B13  bit 7 = we were watching a film, not recording one
        bmi     L_1B20                  ; 1B16  nothing to write in that case
        jsr     finalizeFilm            ; 1B18  write the end marker, length, clock, scores and checksum into the film header
        lda     #$02                    ; 1B1B  default item 2 = WATCH GAME FILM
        sta     defaultMainMenuItem     ; 1B1D  so the player can review what just happened

; ----------------------------------------------------------------------
; Leave the link in a defined state.
; ----------------------------------------------------------------------
L_1B20:
        lda     isLinkActive            ; 1B20  is the comm link up?  (also trainerAiE000:isLinkActive)
        beq     L_1B2F                  ; 1B23  no - session state 0
        lda     linkSessionState        ; 1B25  current session state
        bmi     L_1B2D                  ; 1B28  $80/$FF - already at menu level, nothing in flight
        jsr     completeExchange        ; 1B2A  flush the packet exchange that is still in progress
L_1B2D:
        lda     #$80                    ; 1B2D  $80 = link still up, but at menu level
L_1B2F:
        sta     linkSessionState        ; 1B2F  store the new session state
        rts                             ; 1B32  return


; ----------------------------------------------------------------------
; hangUpModem - Convenience entry: hang up and leave linkSessionState at 0 (no session).
; In:  none
; Out: see hangUpModemSetState
; Called from: miscTabAction $1ABC, disconnectFromOpponent $1C28, and by JMP from $56DB, $5722, $5771
; ----------------------------------------------------------------------
hangUpModem:
        lda     #$00                    ; 1B33  the new linkSessionState is 0 = no session

; ----------------------------------------------------------------------
; hangUpModemSetState - Drops the modem link and stores A as the new linkSessionState. On the modem
; build it first tells the player 'PICK UP PHONE THEN PRESS SPACE.' (message $17) and waits for the
; space bar, so the voice call survives the modem going on-hook; then it waits up to $26 ticks for bit
; 7 of the debounced carrier state $E03C to clear before calling the comm module's link-control entry
; with X = 3 (hang up, then shut the UART down).
; In:  A = new value for linkSessionState $0B9C; link-active flag $E03B, comm build id $E030, carrier
;     state $E03C
; Out: $0B9C = A, the comm module's link variables $E039-$E047 cleared, zp_7F used as the timeout; the
;     operand at $1B5A is patched with A
; Called from: hangUpModem (fall-through) and hangUpAndPause at $1C0F.  NOTE: the survey text had
;     messages $17 and $18 swapped between this routine and openCommLink; the pointer tables at
;     $0500/$0524 give id $17 = $8897 'PICK UP PHONE THEN PRESS SPACE.' and id $18 = $88B6 'PRESS
;     SPACE, WAIT, HANGUP PHONE.'
; ----------------------------------------------------------------------
hangUpModemSetState:
        sta     L_1B59+1                ; 1B35  self-modifying code: A becomes the LDA #imm operand at $1B5A, restored at $1B59
        lda     isLinkActive            ; 1B38  is the link up at all?  (also trainerAiE000:isLinkActive)
        beq     L_1B5B                  ; 1B3B  no - just record the new session state
        lda     commBuildId             ; 1B3D  $E030: $FF = solo trainer module, $00 = real modem driver  (also trainerAiE000:commBuildId)
        bmi     L_1B54                  ; 1B40  the trainer has no phone line - skip the prompt
        lda     #$17                    ; 1B42  message $17 = 'PICK UP PHONE THEN PRESS SPACE.'
        jsr     showMessageWaitForSpace ; 1B44  show it and block until the player presses space
        lda     #$26                    ; 1B47  $26 ticks...
        sta     linkWaitTimer           ; 1B49  ...as a timeout (zp_7F is decremented every 8th frame by the IRQ)
L_1B4B:
        lda     linkStatus              ; 1B4B  debounced carrier state; bit 7 mirrors the 'sample has settled' bit the driver puts in $E03D  (also trainerAiE000:linkStatus)
        bpl     L_1B54                  ; 1B4E  bit 7 clear = the line state is no longer settled, i.e. the modem has reacted - stop waiting (probably)
        lda     linkWaitTimer           ; 1B50  timeout expired?
        bne     L_1B4B                  ; 1B52  no - keep watching the carrier
L_1B54:
        ldx     #$03                    ; 1B54  X = 3: hang up, wait for the UART to idle, then shut it down and clear the link variables
        jsr     commLinkControl         ; 1B56  call the $E003 entry of whichever comm module is loaded  (also trainerAiE000:commSetLinkStateEntry)
L_1B59:
        lda     #$FF                    ; 1B59  operand patched at $1B35 = the requested session state
L_1B5B:
        sta     linkSessionState        ; 1B5B  store it
        rts                             ; 1B5E  return


; ----------------------------------------------------------------------
; openCommLink - Brings the link up if it is not already. On the modem build it first shows message
; $18 'PRESS SPACE, WAIT, HANGUP PHONE.' and waits for the space bar - the player presses space so the
; modem goes off-hook, waits, and then puts the handset down - and then calls the comm module's
; link-control entry with X = 0, which sets the link flags $E03B/$E03C/$E03D.
; In:  link-active flag $E03B, isSoloTrainer $0BA5
; Out: the comm module's link flags set
; Called from: openLinkAndWaitForConnection $1B74 and the $6F00 overlay A at $7962.  NOTE: the survey
;     text had this message swapped with the one used by hangUpModemSetState
; ----------------------------------------------------------------------
openCommLink:
        lda     isLinkActive            ; 1B5F  is the link already up?  (also trainerAiE000:isLinkActive)
        bne     L_1B73                  ; 1B62  yes - nothing to do
        lda     isSoloTrainer           ; 1B64  bit 7 = solo trainer game
        bmi     L_1B6E                  ; 1B67  the trainer needs no prompt
        lda     #$18                    ; 1B69  message $18 = 'PRESS SPACE, WAIT, HANGUP PHONE.'
        jsr     showMessageWaitForSpace ; 1B6B  show it and block until the player presses space
L_1B6E:
        ldx     #$00                    ; 1B6E  X = 0: open the link
        jsr     commLinkControl         ; 1B70  call the $E003 entry of whichever comm module is loaded  (also trainerAiE000:commSetLinkStateEntry)
L_1B73:
        rts                             ; 1B73  return


; ----------------------------------------------------------------------
; openLinkAndWaitForConnection - openCommLink followed by waitForConnection (fall-through).
; In:  none
; Out: see the two callees
; Called from: voicePauseReconnect at $1BFA
; ----------------------------------------------------------------------
openLinkAndWaitForConnection:
        jsr     openCommLink            ; 1B74  bring the line up (with the player prompt on a modem build)

; ----------------------------------------------------------------------
; waitForConnection - On the modem build, pins the status message 'WAITING FOR CONNECTION...', spins
; in waitForCarrier until the modem reports carrier, then clears the voice-pause bit of pauseState and
; blanks the message line. On the solo trainer build it returns immediately.
; In:  isSoloTrainer $0BA5
; Out: persistentMessageId $92A1, zp_18, zp_BE bit 7 cleared, message line cleared
; Called from: openLinkAndWaitForConnection (fall-through) and the $6F00 overlay A at $7965
; ----------------------------------------------------------------------
waitForConnection:
        lda     isSoloTrainer           ; 1B77  bit 7 = solo trainer game
        bmi     L_1B99                  ; 1B7A  no line to wait for - return at once
        lda     #$10                    ; 1B7C  message $10 = 'WAITING FOR CONNECTION...'
        sta     persistentMessageId     ; 1B7E  pin it so it is not replaced by queued messages
        jsr     showStatusMessage       ; 1B81  show it immediately
        lda     #$FF                    ; 1B84  preset the (never tested) wait counter
        sta     scratch18               ; 1B86  zp_18 - see the note in waitForCarrier
        jsr     waitForCarrier          ; 1B88  spin until the modem reports carrier
        lda     pauseState              ; 1B8B  pause/disconnect flags
        and     #$7F                    ; 1B8D  clear bit 7 = the voice pause is over
        sta     pauseState              ; 1B8F  store them back
        lda     #$00                    ; 1B91  no pinned message any more
        sta     persistentMessageId     ; 1B93  release the status line
        jsr     clearMessageLine        ; 1B96  blank the status line and return
L_1B99:
        rts                             ; 1B99  return


; ----------------------------------------------------------------------
; waitForCarrier - Polling loop that runs until the comm module reports carrier (bit 6 of the
; debounced carrier state $E03C). Each pass it services the status/message system, runs the link
; watchdog when the game is in the $FF phase, waits two frames and steps a countdown in zp_18 while
; the raw carrier sample shows no carrier. Once carrier appears it silences the sound and returns.
; In:  gamePhase zp_B1, carrier state $E03C, raw carrier sample $E03D, zp_18
; Out: sound silenced; zp_18 counted down but never tested by anybody, so the countdown is vestigial
;     (and zp_18 is a shared scratch that the printing inside updateStatusMessage may clobber anyway)
; Called from: waitForConnection at $1B88 and by its own loop
; ----------------------------------------------------------------------
waitForCarrier:
        jsr     updateStatusMessage     ; 1B9A  keep the status line and chat display alive while we wait
        lda     gamePhase               ; 1B9D  zp_B1 = $FF marks the pre-game / side-selection phase
        cmp     #$FF                    ; 1B9F  the only phase in which a stalled link has to be noticed here
        bne     L_1BA6                  ; 1BA1  any other phase - no watchdog
        jsr     checkLinkTimeout        ; 1BA3  'PHONE TROUBLE.' / 'TRY AGAIN.' watchdog on the packet exchange
L_1BA6:
        lda     #$02                    ; 1BA6  two frames...
        jsr     waitFrames              ; 1BA8  ...of raster-timed delay per poll
        bit     linkStatusSample        ; 1BAB  raw carrier sample from the last IRQ  (also trainerAiE000:linkStatusSample)
        bvs     L_1BB6                  ; 1BAE  bit 6 set = the sample sees carrier, so do not count down
        dec     scratch18               ; 1BB0  step the (unused) no-carrier countdown
        bne     L_1BB6                  ; 1BB2  not at zero yet
        inc     scratch18               ; 1BB4  hold it at 1 instead of wrapping round to $FF
L_1BB6:
        bit     linkStatus              ; 1BB6  debounced carrier state  (also trainerAiE000:linkStatus)
        bvc     waitForCarrier          ; 1BB9  bit 6 clear = still no carrier - keep waiting
        lda     #$00                    ; 1BBB  sound 0 = silence all voices
        jmp     playSound               ; 1BBD  silence the dial tone/noise and return to waitForConnection


; orphanRts1BC0 - a stray $60 (RTS) between waitForCarrier and showMessageWaitForSpace. Nothing
; branches or jumps to it and waitForCarrier always leaves through the JMP at $1BBD, so it is
; unreachable - probably the tail of an earlier version of the wait loop.
orphanRts1BC0:
        .byte   $60                     ; 1BC0  `  unreachable RTS

; ----------------------------------------------------------------------
; showMessageWaitForSpace - Pins status message A on the message line and blocks until the player
; presses the space bar. It discards any buffered incoming chat text so the prompt is not overwritten,
; leaves chat typing mode, and while waiting keeps the message system running and the shared RNG
; stirring. On exit it empties the message queue, locks the keyboard out for 30 frames and clears the
; key latch so the space press is not seen twice.
; In:  A = message id (used with $17 'PICK UP PHONE THEN PRESS SPACE.' and $18 'PRESS SPACE, WAIT,
;     HANGUP PHONE.')
; Out: persistentMessageId $92A1, incomingChatLength $9277 = 0, chatState $929C = 0, inputLockoutTimer
;     $0B7D = $1E, lastTypedKey $9248 = $FF, message queue emptied
; Called from: hangUpModemSetState $1B44 and openCommLink $1B6B
; ----------------------------------------------------------------------
showMessageWaitForSpace:
        sta     persistentMessageId     ; 1BC1  pin the message so nothing queued replaces it
        ldy     #$00                    ; 1BC4  zero
        sty     incomingChatLength      ; 1BC6  throw away buffered incoming chat text so it cannot overwrite the prompt
        jsr     showStatusMessage       ; 1BC9  A still holds the message id: show it with its sound now
        lda     #$FF                    ; 1BCC  no key seen yet
        sta     lastTypedKey            ; 1BCE  clear the key latch
        lda     #$00                    ; 1BD1  zero for the three stores below
        sta     chatState               ; 1BD3  leave chat typing mode
        sta     inputLockoutTimer       ; 1BD6  accept keys immediately (no input lockout)
        sta     incomingChatLength      ; 1BD9  clear the chat length again - showStatusMessage may have refilled it at $C52E
L_1BDC:
        jsr     updateStatusMessage     ; 1BDC  service the status line and chat display
        jsr     nextGameRandom          ; 1BDF  keep stepping the shared RNG so the wait does not bias it
        ldy     lastTypedKey            ; 1BE2  last key code seen by the IRQ keyboard scan
        cpy     #$A0                    ; 1BE5  $A0 = space (the game sets bit 7 on its key codes)
        bne     L_1BDC                  ; 1BE7  anything else - keep waiting
        jsr     resetMessageQueue       ; 1BE9  empty the message queue and refresh the status line
        lda     #$1E                    ; 1BEC  30 frames...
        sta     inputLockoutTimer       ; 1BEE  ...during which the IRQ ignores the keyboard
        lda     #$FF                    ; 1BF1  no key
        sta     lastTypedKey            ; 1BF3  clear the latch so the space press is not processed again
        rts                             ; 1BF6  return


; ----------------------------------------------------------------------
; voicePauseReconnect - The VOICE PAUSE round trip: hang up so both players can talk on the phone,
; wait five seconds, then bring the line back up and wait for carrier. Finally it clears bit 7 of
; menuSelectedItem, which is the flag runInGameOptionsMenu spins on at $65D5 while the pause lasts.
; In:  linkSessionState $0B9C
; Out: zp_7B (see below), menuSelectedItem $91D5 bit 7 cleared, the link re-established
; Called from: the VOICE PAUSE item of runInGameOptionsMenu at $65C1, the link watchdog at $5A42 and
;     the $C000 overlay at $C768 (when the opponent requested the pause)
; ----------------------------------------------------------------------
voicePauseReconnect:
        jsr     hangUpAndPause          ; 1BF7  hang up and wait 250 frames so the players can talk
        jsr     openLinkAndWaitForConnection; 1BFA  put the call back into data mode and wait for carrier
        sta     shownMessageId          ; 1BFD  stores whatever A the return path left; in practice bit 7 is always set ($FF on the trainer path, a screen/colour RAM page high byte otherwise), which marks the status line as free - almost certainly unintended but harmless
        lda     menuSelectedItem        ; 1BFF  the menu selection doubles as the pause flag
        and     #$7F                    ; 1C02  clear bit 7
        sta     menuSelectedItem        ; 1C04  this releases the wait loop in runInGameOptionsMenu at $65D5
        rts                             ; 1C07  return


; ----------------------------------------------------------------------
; hangUpAndPause - Hangs up for a voice pause and then waits about five seconds. The new session state
; is the current one, except that a negative state ($80 link up at menu level, $FF link lost) is
; replaced by 0.
; In:  linkSessionState $0B9C
; Out: $0B9C updated by hangUpModemSetState; 250 frames of raster-timed delay
; Called from: voicePauseReconnect at $1BF7
; ----------------------------------------------------------------------
hangUpAndPause:
        lda     linkSessionState        ; 1C08  current session state
        bpl     L_1C0F                  ; 1C0B  0 or 2 - keep it as the state to restore
        lda     #$00                    ; 1C0D  $80/$FF becomes 0 = no session
L_1C0F:
        jsr     hangUpModemSetState     ; 1C0F  prompt, drop carrier and store the state
        lda     #$FA                    ; 1C12  250 frames, about five seconds...
        jmp     waitFrames              ; 1C14  ...to give the modems time to release the line, then return


; ----------------------------------------------------------------------
; disconnectFromOpponent - Ends a link session cleanly: tells the opponent with command $8C reason 5
; (disconnect), pushes the packet out immediately and hangs up. Does nothing when no session is active
; (linkSessionState bit 7 clear).
; In:  linkSessionState $0B9C
; Out: zp_73 = 5, the $8C command queued and transmitted, the link closed and $0B9C = 0
; Called from: runMainMenu at $1C60 (DISCONNECT/SET VOICE) and $1C6C (before starting a solo game),
;     the link watchdog at $5A2F, and by JMP from $56B8
; ----------------------------------------------------------------------
disconnectFromOpponent:
        lda     linkSessionState        ; 1C17  session state; bit 7 set = link up
        bpl     L_1C2B                  ; 1C1A  no session - nothing to disconnect
        lda     #$05                    ; 1C1C  reason 5 = disconnect (cmdSessionControl sets the opponent's pauseState to $80)
        sta     cmdParam0               ; 1C1E  command argument 0
        lda     #$8C                    ; 1C20  $8C = cmdSessionControl
        jsr     queueCmd2               ; 1C22  queue the 2-byte command
        jsr     sendOutgoingPacket      ; 1C25  build and hand the packet to the comm module right away
        jsr     hangUpModem             ; 1C28  then drop the line, leaving linkSessionState = 0
L_1C2B:
        rts                             ; 1C2B  return


; ----------------------------------------------------------------------
; runMainMenu - Runs the main menu whose text printOptionsList has just drawn: five items, or six when
; a link session is up and DISCONNECT/SET VOICE is offered. It preselects defaultMainMenuItem, marks
; the menu phase (selectedUnit and gamePhase = $F0, so the selector services the command exchange
; instead of the battle) and calls the generic selector with C = 0 (no slow-down before the last row).
; Item 5 just disconnects and returns to miscTabAction; every other item reloads the $6F00 overlay and
; jumps into handleMainMenuChoice, so the routine does not return.
; In:  linkSessionState $0B9C, defaultMainMenuItem $92FD, the text window left by printOptionsList
; Out: menuItemCount $91D4, menuSelectedItem $91D5, menuFirstRow $91D6 = $0B, selectedUnit $90F8 =
;     $F0, gamePhase zp_B1 = $F0, zp_3D/zp_42 = $60, diskLoadDepth $0BA7 bumped around the overlay
;     reload
; Called from: miscTabAction at $1AD3
; Set the menu up: item count, preselection, geometry and the menu-phase flags.
; ----------------------------------------------------------------------
runMainMenu:
        ldy     #$05                    ; 1C2C  five items: COMPETE / PRACTICE / WATCH FILM / SAVE FILM / LOAD FILM
        lda     linkSessionState        ; 1C2E  session state; bit 7 set = a link session exists
        bpl     L_1C34                  ; 1C31  no session - DISCONNECT/SET VOICE was not printed
        iny                             ; 1C33  six items, the last one being DISCONNECT/SET VOICE
L_1C34:
        sty     menuItemCount           ; 1C34  tell the selector how many rows there are
        lda     defaultMainMenuItem     ; 1C37  the item endGameToMainMenu or selectDefaultMenuItem thought most useful
        sta     menuSelectedItem        ; 1C3A  preselect it
        dec     textWindowLeft          ; 1C3D  shift the window left edge to 5 so the highlight bar starts left of the text
        lda     textWindowLeft          ; 1C3F  the new left edge...
        sta     textCursorCol           ; 1C41  ...also stored as the cursor column; toggleMenuRowHighlight forces column 0, so this looks vestigial
        lda     #$0B                    ; 1C43  the first menu row is screen row 11
        sta     menuFirstRow            ; 1C45  the selector adds the selection index to this
        jsr     setCursorRow            ; 1C48  put the cursor on that row
        lda     #$F0                    ; 1C4B  $F0 = 'no unit is being watched'...
        sta     selectedUnit            ; 1C4D  ...so the selector skips the unit-death and group-highlight logic
        sta     gamePhase               ; 1C50  zp_B1 = $F0: menu phase, bits 6/7 set, so the selector runs serviceCommandExchange instead of the battle tick
        lda     #$60                    ; 1C52  colour $60: black text, EOR mask 6 for the highlight bar
        sta     textColour              ; 1C54  text colour
        sta     textPadColour           ; 1C56  and the colour used to pad centred strings
        clc                             ; 1C58  C = 0: no extra delay before the joystick can reach the last row
        jsr     runMenuLoopWithFlag     ; 1C59  run the selector; it returns with Y = the chosen item

; ----------------------------------------------------------------------
; Act on the choice.
; ----------------------------------------------------------------------
        cpy     #$05                    ; 1C5C  item 5 = DISCONNECT/SET VOICE
        bne     L_1C63                  ; 1C5E  any other item starts or loads something
        jmp     disconnectFromOpponent  ; 1C60  just drop the link and return to miscTabAction, which reprints the menu


; ----------------------------------------------------------------------
; Tail of runMainMenu, reached for menu items 0-4 (0 COMPETE WITH MODEM OPPONENT, 1 PRACTICE WITH SOLO
; TRAINER, 2 WATCH GAME FILM, 3 SAVE GAME FILM, 4 LOAD GAME FILM; item 5 DISCONNECT/SET VOICE was
; dealt with at $1C60).  The chosen item is still in Y and is acted on by overlay A.
; ----------------------------------------------------------------------
L_1C63:
        lda     #$0F                    ; 1C63  15 frames
        jsr     waitFrames              ; 1C65  let the player see the item he picked before the drive starts up
        cpy     #$01                    ; 1C68  menu item 1 = PRACTICE WITH SOLO TRAINER?
        bne     L_1C6F                  ; 1C6A  any other choice leaves an open modem link alone
        jsr     disconnectFromOpponent  ; 1C6C  a solo game has nobody on the line: send $8C reason 5 and hang up first
L_1C6F:
        inc     diskLoadDepth           ; 1C6F  $0BA7 non-zero makes the raster IRQ swallow keys while the drive is busy
        jsr     ensureOverlayAResident  ; 1C72  reload the $6F00 overlay variant named by $87FF plus the message strings and comm tail
        dec     diskLoadDepth           ; 1C75  load finished - the keyboard is live again
        jmp     handleMainMenuChoice    ; 1C78  enter overlay A at $7DE3, which acts on menu item Y; it never comes back here


; ----------------------------------------------------------------------
; isAsymmetricGameType - Tests the game type held in gameTypeOptions bits 0-2 and returns Z=1 for type
; 1 (QB SNEAK) or type 6 (DEFENDER), the two scenarios in which the two sides have different jobs and
; the setup summary therefore prints '1ST PLAY' / '2ND PLAY'.
; In:  gameTypeOptions ($0BA3) bits 0-2
; Out: A = game type 0-6, Z=1 when the type is asymmetric
; Called from: drawGameSetupSummary ($15B6 and $15E0)
; ----------------------------------------------------------------------
isAsymmetricGameType:
        lda     gameTypeOptions         ; 1C7B  game options: bits 0-2 type, bit 3 unit menus, bit 4 damage, bits 6-7 custom rules/map
        and     #$07                    ; 1C7E  keep only the game type 0-6 (SCRIMAGE, QB SNEAK, THE BOMB, FACE-OFF, SLUGGERS, FULL WAR, DEFENDER)
        cmp     #$01                    ; 1C80  type 1 = QB SNEAK: one side sneaks, the other defends
        beq     L_1C86                  ; 1C82  asymmetric - return with Z=1
        cmp     #$06                    ; 1C84  type 6 = DEFENDER, the other asymmetric scenario; this compare sets the returned Z
L_1C86:
        rts                             ; 1C86  Z=1 the two sides have different jobs, Z=0 both play the same game


; ----------------------------------------------------------------------
; restoreSettingsAfterFilm - Undoes what WATCH GAME FILM overwrote.  When the film-playback flag is
; set it leaves film mode and, unless the backup slot is marked empty ($FF), copies the pre-film map
; name, map seed, play order, map orientation and game options back over the live ones, so the main
; menu again describes the player's own game instead of the film's.
; In:  filmPlaybackMode ($0B9B) bit 7; prevGameTypeOptions ($0BA4) = $FF when nothing was saved; the
;      backups $0BAF-$0BB4, $0B98-$0B9A, $0BA2 and $0B9E
; Out: filmPlaybackMode = 0; mapNameText $0BA9-$0BAE, mapSeed $0B95-$0B97, playsFirstFlag $0BA1,
;      mapFlipFlag $0B9D and gameTypeOptions $0BA3 restored
; Called from: the main-menu flow at $1ACD, just before the options list is reprinted
; ----------------------------------------------------------------------
restoreSettingsAfterFilm:
        lda     filmPlaybackMode        ; 1C87  $0B9B bit 7 = a game film is being watched
        bpl     L_1CC0                  ; 1C8A  not a film: the live settings are already the real ones
        lda     #$00                    ; 1C8C  leave film mode
        sta     filmPlaybackMode        ; 1C8E  $0B9B = 0 also lets outgoing commands be queued again
        lda     prevGameTypeOptions     ; 1C91  $0BA4 = the game options saved before the film was started
        cmp     #$FF                    ; 1C94  $FF marks 'nothing was ever saved here'
        beq     L_1CC0                  ; 1C96  no backup: keep whatever the film left behind
        ldy     #$05                    ; 1C98  6 bytes: the 5-character map name plus its bit-7 terminator
L_1C9A:
        lda     mapNameBackup,y         ; 1C9A  $0BAF+y = the saved map id text
        sta     mapNameText,y           ; 1C9D  $0BA9+y = the map id the STATS tab and the setup summary print
        dey                             ; 1CA0  next byte down
        bpl     L_1C9A                  ; 1CA1  loop over all 6 bytes
        ldy     #$02                    ; 1CA3  3 bytes of the 24-bit map seed
L_1CA5:
        lda     prevMapSeed,y           ; 1CA5  $0B98+y = saved seed byte
        sta     mapSeed,y               ; 1CA8  $0B95+y = the seed the generator turns into the battlefield
        dey                             ; 1CAB  next seed byte down
        bpl     L_1CA5                  ; 1CAC  loop over all three
        lda     savedPlaysFirstFlag     ; 1CAE  $0BA2 = who had the first play before the film
        sta     playsFirstFlag          ; 1CB1  $0BA1 drives the '1ST PLAY' / '2ND PLAY' line
        lda     prevMapFlipFlag         ; 1CB4  $0B9E = saved map orientation (0 normal, 1 rotated 180 degrees)
        sta     mapFlipFlag             ; 1CB7  $0B9D, read by the map generator
        lda     prevGameTypeOptions     ; 1CBA  and finally the scenario and rule bits
        sta     gameTypeOptions         ; 1CBD  $0BA3 back to the player's own game type
L_1CC0:
        rts                             ; 1CC0  done - the main menu can be reprinted


; ----------------------------------------------------------------------
; setViewVerticalLimit - Sets viewRowLimit, the exclusive map row limit the centre cell of the
; battlefield view has to stay under: 40 during the battle, 20 in the setup/film/menu phases where a
; player may only look at his own half of the map.  In the restricted case the row passed in Y is
; biased by -20 as well, so the caller can test it against 0..19; scrollViewBy adds the 20 back before
; it stores the new view origin.
; In:  Y = a map row (a row delta from scrollViewBy, the view origin from keepGroupBoxInsideMap),
;      gamePhase (zp_B1)
; Out: viewRowLimit (zp_F8) = $28 or $14, Y reduced by $14 in the restricted phase, A clobbered
; Called from: scrollViewBy ($1CD7) and keepGroupBoxInsideMap ($1F56)
; ----------------------------------------------------------------------
setViewVerticalLimit:
        lda     #$28                    ; 1CC1  40 = the full height of the battlefield map
        sta     viewRowLimit            ; 1CC3  zp_F8 bounds the centre (cursor) row of the view
        lda     gamePhase               ; 1CC5  zp_B1 is 0 only while the battle proper runs
        beq     L_1CD2                  ; 1CC7  battle: all 40 rows may be looked at
        lda     #$14                    ; 1CC9  20 rows
        sta     viewRowLimit            ; 1CCB  setup/film/menu phase: a player may only see his own half of the map
        tya                             ; 1CCD  take the row value the caller handed in
        sec                             ; 1CCE  prepare an exact subtraction
        sbc     #$14                    ; 1CCF  shift it down by 20 so map rows 20-39 test as 0-19
        tay                             ; 1CD1  hand the biased row back in Y
L_1CD2:
        rts                             ; 1CD2  zp_F8 is set either way


; ----------------------------------------------------------------------
; scrollViewBy - Moves the 7x5-cell tactical view by one map cell: X = column delta, Y = row delta,
; each -1, 0 or +1.  The cursor is always the centre cell (viewOriginCol+3, viewOriginRow+2), so a
; step is refused when that cell - or, while a group formation box is being dragged, any edge of the
; box - would leave the 40-column map or the row window set by setViewVerticalLimit.  A refused axis
; has its delta zeroed; the deltas that survive stay in scrollDeltaCol/scrollDeltaRow, the mini-map
; cursor sprite is moved, and control falls through into scrollViewBitmap, which shifts the pixels.
; In:  X = dx, Y = dy (-1/0/+1); viewOriginCol/Row (zp_A5/zp_A6); gamePhase; groupBoxActive ($920A)
;      bit 7 with the box edge offsets $922E-$9231
; Out: viewOriginCol/Row updated; scrollDeltaCol/scrollDeltaRow (zp_F0/zp_F1) = the deltas actually
;      applied; viewRowLimit set; activeSpriteIndex = 0 and sprite 0 repositioned; jumps into
;      scrollViewBitmap and returns from there.  Self-modifies the LDA # operands at $1CF6+1 and
;      $1D2E+1
; Called from: scrollViewToUnit ($1D8F) and scrollViewportByDirection ($2179)
; ----------------------------------------------------------------------
scrollViewBy:
        stx     scrollDeltaCol          ; 1CD3  remember the requested column step (-1/0/+1)
        sty     scrollDeltaRow          ; 1CD5  and the requested row step
        jsr     setViewVerticalLimit    ; 1CD7  zp_F8 = 40 or 20; in the restricted phases Y comes back biased by -20
        tya                             ; 1CDA  row delta
        clc                             ; 1CDB  plain add
        adc     viewOriginRow           ; 1CDC  candidate new top row of the view
        tay                             ; 1CDE  keep the candidate origin in Y
        clc                             ; 1CDF  plain add
        adc     #$02                    ; 1CE0  the cursor cell sits 2 rows below the top edge of the 5-cell-high view
        cmp     viewRowLimit            ; 1CE2  is the new cursor row still inside the allowed rows?
        bcs     L_1D0E                  ; 1CE4  off the map (or out of your own half): refuse the vertical step
        bit     groupBoxActive          ; 1CE6  $920A bit 7 = a group formation box is riding on the cursor
        bpl     L_1D00                  ; 1CE9  no box: the centre cell alone decides
        sta     L_1CF6+1                ; 1CEB  self-modifying: park the candidate cursor row in the LDA # operand at $1CF6+1
        clc                             ; 1CEE  plain add
        adc     groupBoxTopOffset       ; 1CEF  $9231 = signed offset from the cursor to the top edge of the box
        cmp     viewRowLimit            ; 1CF2  unsigned compare, so a wrapped (negative) row fails too
        bcs     L_1D0E                  ; 1CF4  the box would hang off the top of the map: refuse
L_1CF6:
        lda     #$FF                    ; 1CF6  operand written at $1CEB: the candidate cursor row
        clc                             ; 1CF8  plain add
        adc     groupBoxBottomOffset    ; 1CF9  $9230 = signed offset to the bottom edge of the box
        cmp     viewRowLimit            ; 1CFC  bottom edge still on the map?
        bcs     L_1D0E                  ; 1CFE  the box would hang off the bottom: refuse
L_1D00:
        lda     gamePhase               ; 1D00  which phase are we in?
        beq     L_1D09                  ; 1D02  battle: the candidate row needs no correction
        tya                             ; 1D04  candidate origin row, still biased
        clc                             ; 1D05  plain add
        adc     #$14                    ; 1D06  undo the -20 bias setViewVerticalLimit applied
        tay                             ; 1D08  back into Y for the store below
L_1D09:
        sty     viewOriginRow           ; 1D09  accept the vertical scroll
        jmp     L_1D12                  ; 1D0B  now try the horizontal step

L_1D0E:
        lda     #$00                    ; 1D0E  no vertical movement
        sta     scrollDeltaRow          ; 1D10  vertical step clamped: tell scrollViewBitmap not to shift any rows
L_1D12:
        txa                             ; 1D12  column delta
        clc                             ; 1D13  plain add
        adc     viewOriginCol           ; 1D14  candidate new left column of the view
        tax                             ; 1D16  keep the candidate origin in X
        clc                             ; 1D17  plain add
        adc     #$03                    ; 1D18  the cursor cell sits 3 columns right of the left edge of the 7-cell-wide view
        cmp     #$28                    ; 1D1A  40 map columns
        bcs     L_1D3D                  ; 1D1C  off the map: refuse the horizontal step
        bit     groupBoxActive          ; 1D1E  group formation box active?
        bpl     L_1D38                  ; 1D21  no box: accept
        sta     L_1D2E+1                ; 1D23  self-modifying: park the candidate cursor column in the LDA # operand at $1D2E+1
        clc                             ; 1D26  plain add
        adc     groupBoxLeftOffset      ; 1D27  $922F = signed offset to the left edge of the box
        cmp     #$28                    ; 1D2A  left edge still on the map?
        bcs     L_1D3D                  ; 1D2C  box would stick out to the left: refuse
L_1D2E:
        lda     #$FF                    ; 1D2E  operand written at $1D23: the candidate cursor column
        clc                             ; 1D30  plain add
        adc     groupBoxRightOffset     ; 1D31  $922E = signed offset to the right edge of the box
        cmp     #$28                    ; 1D34  right edge still on the map?
        bcs     L_1D3D                  ; 1D36  box would stick out to the right: refuse
L_1D38:
        stx     viewOriginCol           ; 1D38  accept the horizontal scroll
        jmp     L_1D41                  ; 1D3A  both axes decided

L_1D3D:
        lda     #$00                    ; 1D3D  no horizontal movement
        sta     scrollDeltaCol          ; 1D3F  horizontal step clamped: no column shift
L_1D41:
        lda     #$00                    ; 1D41  sprite 0
        sta     activeSpriteIndex       ; 1D43  $91CC = the sprite positionCursorSprite works on: the mini-map cursor box
        jsr     positionCursorSprite    ; 1D46  move it onto the new centre cell (the mini-map draws each map cell 4x4 pixels)
        jmp     scrollViewBitmap        ; 1D49  shift bitmap, screen and colour RAM and redraw the cells that came into sight


; scrollTargetUnit / scrollStepDelay - the two parameters of scrollViewToUnit.  They live in memory
; because that routine gives the registers up to updateGameFrame between steps.
scrollTargetUnit:
        .byte   $00                     ; 1D4C  .  scrollTargetUnit: unit index the view is being scrolled onto

; scrollStepDelay - frames between two scroll steps; 0 makes scrollViewToUnit jump to the unit in one
; go without animating.
scrollStepDelay:
        .byte   $00                     ; 1D4D  .  scrollStepDelay: frames between steps; 0 = jump there without animating

; ----------------------------------------------------------------------
; scrollViewToUnit - Scrolls the battlefield view until unit X sits under the centre cursor, one map
; cell per step along a Bresenham line that starts at the cell the caller left in lineCurRow/
; lineCurCol (normally the current cursor cell).  With A = 0 the whole walk runs at once; with A != 0
; each step waits A frames on timer zp_6E while updateGameFrame keeps the game, the sound and the link
; running, and the walk is abandoned as soon as the player touches the joystick.  The target is
; re-read every step, so the view follows a unit that is still moving.
; In:  A = frames per step (0 = no animation), X = unit index, lineCurRow/lineCurCol (zp_92/zp_93) =
;      the cell the walk starts from
; Out: view scrolled (viewOriginCol/Row); scrollTargetUnit/scrollStepDelay; line stepper state
;      zp_8C-zp_97; zp_6E used as the step timer
; Called from: recenterViewportOnUnit ($25B3, A=1, the selected unit) and $485C (A=0, the unit shown
;      in the info panel)
; ----------------------------------------------------------------------
scrollViewToUnit:
        sta     scrollStepDelay         ; 1D4E  frames between steps (0 = immediate)
        stx     scrollTargetUnit        ; 1D51  unit to centre the view on
        jsr     setLineTargetToUnit     ; 1D54  line end point (zp_95/zp_94) = the unit's map cell
        jsr     initLineStepper         ; 1D57  set up the Bresenham walk from the cursor cell to the unit
L_1D5A:
        lda     scrollStepDelay         ; 1D5A  animated or immediate?
        beq     L_1D7D                  ; 1D5D  no animation wanted: take the next step straight away
        lda     lineCurRow              ; 1D5F  updateGameFrame steps the units through the same line stepper,
        pha                             ; 1D61  so save the walker's row
        lda     lineCurCol              ; 1D62  and its column
        pha                             ; 1D64  on the stack across the call
        jsr     updateGameFrame         ; 1D65  one frame of engine, message, sound and link work
        pla                             ; 1D68  restore our own walk position
        sta     lineCurCol              ; 1D69  zp_93 = current column
        pla                             ; 1D6B  and the row
        sta     lineCurRow              ; 1D6C  zp_92
        lda     joyDirection            ; 1D6E  zp_5D = joystick nibble latched by the raster IRQ
        cmp     #$0F                    ; 1D70  $0F = stick centred
        bne     L_1D96                  ; 1D72  the player took over: stop the automatic scroll
        lda     menuBlinkTimer          ; 1D74  zp_6E, an IRQ frame countdown reused here as the step timer
        bne     L_1D5A                  ; 1D76  this step is not due yet: spin another frame
        lda     scrollStepDelay         ; 1D78  step delay
        sta     menuBlinkTimer          ; 1D7B  reload the timer for the next step
L_1D7D:
        ldx     scrollTargetUnit        ; 1D7D  the unit we are chasing
        jsr     setLineTargetToUnit     ; 1D80  re-read its position: it may have moved a cell meanwhile
        jsr     initLineStepper         ; 1D83  re-aim the line from the cell we are on now
        jsr     stepLine                ; 1D86  walk one cell; the applied deltas land in $90F4/$90F5
        ldx     lastStepColDelta        ; 1D89  $90F5 = column step just taken (-1/0/+1)
        ldy     lastStepRowDelta        ; 1D8C  $90F4 = row step just taken (-1/0/+1)
        jsr     scrollViewBy            ; 1D8F  scroll the view by that one cell
        lda     lineStepsLeft           ; 1D92  zp_91 = steps still to walk
        bne     L_1D5A                  ; 1D94  keep going until the unit is under the cursor
L_1D96:
        rts                             ; 1D96  view centred (or the player interrupted)


; ----------------------------------------------------------------------
; setLineTargetToUnit - Points the line stepper's end point at unit X.  The unit records are parallel
; 100-byte arrays $64 apart from $F640, so the unit's column and row are unitColTable[X] and
; unitRowTable[X].
; In:  X = unit index 0-99
; Out: lineEndCol (zp_95), lineEndRow (zp_94)
; Called from: scrollViewToUnit ($1D54 and $1D80)
; ----------------------------------------------------------------------
setLineTargetToUnit:
        lda     unitColTable,x          ; 1D97  $F640+unit = the unit's map column
        sta     lineEndCol              ; 1D9A  zp_95 = target column of the line stepper
        lda     unitRowTable,x          ; 1D9C  $F6A4+unit = the unit's map row (the next 100-byte array, $64 further on)
        sta     lineEndRow              ; 1D9F  zp_94 = target row
        rts                             ; 1DA1  the caller now calls initLineStepper


; Parameter tables for scrollViewBitmap: 22 tables of three bytes, all indexed by a scroll direction
; biased to 0/1/2 = -1/0/+1.  The 9 tables read with X describe the horizontal shift (which byte or
; character range of a row is copied, and in which direction), the 13 read with Y describe the
; vertical shift (which character row the copy starts on, where it goes and the row stride). The view
; is 7x5 map cells of 2x2 characters at screen column 25, character row 1: bitmap $A208, screen RAM
; $8C41, colour RAM $D841 (screen RAM + $4C00).  One character row of it is 14 characters = 112
; consecutive bitmap bytes, and one map cell is 2 characters = 16 bitmap bytes. scrollXStartIndex -
; first Y index of the bitmap byte copy.
scrollXStartIndex:
        .byte   $5F,$00,$00             ; 1DA2  _..  $5F,$00,$00 = first Y index of the bitmap byte copy (count down from $5F, or up from 0)

; scrollXEndIndex: byteTable, 3 bytes. end Y index of the bitmap byte copy ($FF,$70,$60)
scrollXEndIndex:
        .byte   $FF,$70,$60             ; 1DA5  .p`  $FF,$70,$60 = index the copy stops at: $5F..0 down, 0..$6F up, 0..$5F up

; scrollXStepOpcode: byteTable, 3 bytes. DEY ($88) or INY ($C8) opcode patched into both copy loops
scrollXStepOpcode:
        .byte   $88,$C8,$C8             ; 1DA8  ...  $88 DEY (copy right, backwards) or $C8 INY (copy left / straight, forwards)

; scrollXBitmapSrcOffset: byteTable, 3 bytes. byte offset added to the bitmap source address (0,0,$10)
scrollXBitmapSrcOffset:
        .byte   $00,$00,$10             ; 1DAB  ...  $00,$00,$10 = +16 bytes (2 characters = 1 map cell) is added to the source when the view moves right

; scrollXBitmapDstOffset: byteTable, 3 bytes. byte offset added to the bitmap destination address
; ($10,0,0)
scrollXBitmapDstOffset:
        .byte   $10,$00,$00             ; 1DAE  ...  $10,$00,$00 = the same 16-byte shift on the destination when the view moves left

; scrollYBitmapSrcLo: byteTable, 3 bytes. per vertical direction: bitmap source address lo
; ($AAC8,$A208,$A488)
scrollYBitmapSrcLo:
        .byte   $C8,$08,$88             ; 1DB1  ...  $C8,$08,$88 = low byte of $AAC8/$A208/$A488, the first bitmap row copied (char rows 8/1/3)

; scrollYBitmapSrcHi: byteTable, 3 bytes. bitmap source address hi
scrollYBitmapSrcHi:
        .byte   $AA,$A2,$A4             ; 1DB4  ...  $AA,$A2,$A4 = high bytes of those three bitmap addresses

; scrollYBitmapDstLo: byteTable, 3 bytes. bitmap destination address lo ($AD48,$A208,$A208)
scrollYBitmapDstLo:
        .byte   $48,$08,$08             ; 1DB7  H..  $48,$08,$08 = low byte of $AD48/$A208/$A208, where that row is copied to (char rows 10/1/1)

; scrollYBitmapDstHi: byteTable, 3 bytes. bitmap destination address hi
scrollYBitmapDstHi:
        .byte   $AD,$A2,$A2             ; 1DBA  ...  $AD,$A2,$A2 = high bytes of the destinations

; scrollYBitmapStrideLo: byteTable, 3 bytes. bitmap row stride lo ($FEC0 = -320 when scrolling up,
; $0140 = +320 otherwise)
scrollYBitmapStrideLo:
        .byte   $C0,$40,$40             ; 1DBD  .@@  $C0,$40,$40 = low byte of the row stride -320/+320/+320 (one character row of the bitmap)

; scrollYBitmapStrideHi: byteTable, 3 bytes. bitmap row stride hi
scrollYBitmapStrideHi:
        .byte   $FE,$01,$01             ; 1DC0  ...  $FE,$01,$01 = high byte of the same stride

; scrollYRowCount: byteTable, 3 bytes. character rows to copy (8,10,8)
scrollYRowCount:
        .byte   $08,$0A,$08             ; 1DC3  ...  8,10,8 character rows to copy: all 10 when only columns move, 8 when 2 rows are freed

; scrollXScreenStartIndex: byteTable, 3 bytes. start Y index of the screen/colour copy ($0B,0,0)
scrollXScreenStartIndex:
        .byte   $0B,$00,$00             ; 1DC6  ...  $0B,$00,$00 = first Y index of the 14-character screen/colour copy

; scrollXScreenEndIndex: byteTable, 3 bytes. end Y index of the screen/colour copy ($FF,$0E,$0C)
scrollXScreenEndIndex:
        .byte   $FF,$0E,$0C             ; 1DC9  ...  $FF,$0E,$0C = index it stops at: $0B..0 down, 0..$0D up, 0..$0B up

; scrollXScreenSrcOffset: byteTable, 3 bytes. column offset of the screen source (0,0,2)
scrollXScreenSrcOffset:
        .byte   $00,$00,$02             ; 1DCC  ...  $00,$00,$02 = 2 characters added to the screen source when the view moves right

; scrollXScreenDstOffset: byteTable, 3 bytes. column offset of the screen destination (2,0,0)
scrollXScreenDstOffset:
        .byte   $02,$00,$00             ; 1DCF  ...  $02,$00,$00 = 2 characters added to the screen destination when the view moves left

; scrollYScreenSrcLo: byteTable, 3 bytes. screen RAM source address lo ($8D59 row 8, $8C41 row 1,
; $8C91 row 3; column 25)
scrollYScreenSrcLo:
        .byte   $59,$41,$91             ; 1DD2  YA.  $59,$41,$91 = low byte of $8D59/$8C41/$8C91: screen RAM column 25 of char rows 8/1/3

; scrollYScreenSrcHi: byteTable, 3 bytes. screen RAM source address hi (colour RAM = +$4C00)
scrollYScreenSrcHi:
        .byte   $8D,$8C,$8C             ; 1DD5  ...  $8D,$8C,$8C = high bytes; the colour RAM copy uses the same addresses + $4C00

; scrollYScreenDstLo: byteTable, 3 bytes. screen RAM destination address lo ($8DA9, $8C41, $8C41)
scrollYScreenDstLo:
        .byte   $A9,$41,$41             ; 1DD8  .AA  $A9,$41,$41 = low byte of $8DA9/$8C41/$8C41: column 25 of char rows 10/1/1

; scrollYScreenDstHi: byteTable, 3 bytes. screen RAM destination address hi
scrollYScreenDstHi:
        .byte   $8D,$8C,$8C             ; 1DDB  ...  $8D,$8C,$8C = high bytes of the screen destinations

; scrollYScreenStrideLo: byteTable, 3 bytes. screen row stride lo ($FFD8 = -40 up, $0028 = +40)
scrollYScreenStrideLo:
        .byte   $D8,$28,$28             ; 1DDE  .((  $D8,$28,$28 = low byte of the screen row stride -40/+40/+40

; scrollYScreenStrideHi: byteTable, 3 bytes. screen row stride hi
scrollYScreenStrideHi:
        .byte   $FF,$00,$00             ; 1DE1  ...  $FF,$00,$00 = high byte of the screen row stride

; ----------------------------------------------------------------------
; scrollViewBitmap - Shifts the whole 7x5-cell tactical view one map cell (16 pixels = 2 characters)
; in the direction left in scrollDeltaCol/scrollDeltaRow and then redraws the cells that scrolled into
; sight.  Both deltas are first biased to 0/1/2 = -1/0/+1 and used to index the 22 tables at
; $1DA2; the values are poked into two self-modified copy loops - one moving 96 or 112 bitmap bytes
; per character row, one moving 12 or 14 bytes of screen RAM and of colour RAM - which are run once
; per character row (8 or 10 of them) with the row stride added between passes.  A one-cell shift is
; 16 bitmap bytes or 2 characters, so a 12-character window is copied 2 characters over and the
; exposed column (5 cells) and/or row (7 cells) is drawn from the map.
; In:  scrollDeltaCol/scrollDeltaRow (zp_F0/zp_F1) = -1/0/+1; viewOriginCol/Row, already updated by
;      scrollViewBy
; Out: bitmap $A208.., screen RAM $8C41.. and colour RAM $D841.. scrolled and the new edge drawn;
;      zp_F0/zp_F1 left biased to 0/1/2; zp_F2-zp_F7 and $54-$56 used as scratch.  Self-modifies
;      $1E81+1, $1E83+1/+2, $1E86+1/+2, $1E89, $1E8A+1, $1EB0+1, $1EB2+1/+2, $1EB5+1/+2, $1EB8+1/+2,
;      $1EBB+1/+2, $1EBE and $1EBF+1
; Called from: scrollViewBy ($1D49, by JMP - this is that routine's tail)
; ----------------------------------------------------------------------
scrollViewBitmap:
        inc     scrollDeltaCol          ; 1DE4  bias the column step -1/0/+1 into a table index 0/1/2
        ldx     scrollDeltaCol          ; 1DE6  X selects the horizontal-shift parameters
        inc     scrollDeltaRow          ; 1DE8  same for the row step
        ldy     scrollDeltaRow          ; 1DEA  Y selects the vertical-shift parameters
        lda     scrollXStartIndex,x     ; 1DEC  start index of the bitmap copy
        sta     L_1E81+1                ; 1DEF  patch the LDY # that opens the bitmap loop
        lda     scrollXEndIndex,x       ; 1DF2  end index of the bitmap copy
        sta     L_1E8A+1                ; 1DF5  patch the CPY # that closes it
        lda     scrollYBitmapSrcLo,y    ; 1DF8  first bitmap row to read from
        clc                             ; 1DFB  plain add
        adc     scrollXBitmapSrcOffset,x; 1DFC  +16 bytes = one map cell to the right on the source when the view scrolls right
        sta     L_1E83+1                ; 1DFF  patch the low byte of the LDA abs,y source operand
        lda     scrollYBitmapSrcHi,y    ; 1E02  high byte of the source row address
        adc     #$00                    ; 1E05  take the carry out of the low byte
        sta     L_1E83+2                ; 1E07  patch the high byte of the source operand
        lda     scrollYBitmapDstLo,y    ; 1E0A  bitmap row to write to
        clc                             ; 1E0D  plain add
        adc     scrollXBitmapDstOffset,x; 1E0E  +16 bytes on the destination instead when the view scrolls left
        sta     L_1E86+1                ; 1E11  patch the low byte of the STA abs,y destination operand
        lda     scrollYBitmapDstHi,y    ; 1E14  high byte of the destination row address
        adc     #$00                    ; 1E17  carry out of the low byte
        sta     L_1E86+2                ; 1E19  patch the high byte of the destination operand
        lda     scrollXStepOpcode,x     ; 1E1C  $88 DEY or $C8 INY
        sta     scrollBitmapStepOpcode  ; 1E1F  patch the step instruction of the bitmap loop
        lda     scrollYRowCount,y       ; 1E22  8 or 10 character rows
        sta     scrollRowCount          ; 1E25  zp_F6 counts the rows still to move
        lda     scrollYBitmapStrideLo,y ; 1E27  +320 or -320 = one character row of the bitmap
        sta     bitmapRowStride         ; 1E2A  zp_F2 low byte of the bitmap row stride
        lda     scrollYBitmapStrideHi,y ; 1E2C  sign/high byte of the stride
        sta     bitmapRowStrideHi       ; 1E2F  zp_F3
        lda     scrollXScreenStartIndex,x; 1E31  start index of the screen/colour copy
        sta     L_1EB0+1                ; 1E34  patch the LDY # that opens the screen loop
        lda     scrollXScreenEndIndex,x ; 1E37  end index of the screen copy
        sta     L_1EBF+1                ; 1E3A  patch its CPY #
        lda     scrollYScreenSrcLo,y    ; 1E3D  first screen RAM row to read from
        clc                             ; 1E40  plain add
        adc     scrollXScreenSrcOffset,x; 1E41  +2 characters on the source when the view scrolls right
        sta     L_1EB2+1                ; 1E44  patch the screen RAM source operand (low)
        sta     L_1EB8+1                ; 1E47  the colour RAM source has the same low byte
        lda     scrollYScreenSrcHi,y    ; 1E4A  high byte of the screen row address ($8C/$8D)
        adc     #$00                    ; 1E4D  carry out of the low byte
        sta     L_1EB2+2                ; 1E4F  patch the screen RAM source operand (high) - screen RAM is at $8C00
        adc     #$4C                    ; 1E52  +$4C00 turns a screen RAM address into the matching colour RAM address ($D800)
        sta     L_1EB8+2                ; 1E54  patch the colour RAM source operand (high)
        lda     scrollYScreenDstLo,y    ; 1E57  screen RAM row to write to
        clc                             ; 1E5A  plain add
        adc     scrollXScreenDstOffset,x; 1E5B  +2 characters on the destination instead when the view scrolls left
        sta     L_1EB5+1                ; 1E5E  patch the screen RAM destination operand (low)
        sta     L_1EBB+1                ; 1E61  colour RAM destination shares the low byte
        lda     scrollYScreenDstHi,y    ; 1E64  high byte of the destination row
        adc     #$00                    ; 1E67  carry out of the low byte
        sta     L_1EB5+2                ; 1E69  patch the screen RAM destination operand (high)
        adc     #$4C                    ; 1E6C  same $4C00 bias into colour RAM
        sta     L_1EBB+2                ; 1E6E  patch the colour RAM destination operand (high)
        lda     scrollXStepOpcode,x     ; 1E71  $88 DEY or $C8 INY again
        sta     scrollScreenStepOpcode  ; 1E74  patch the step instruction of the screen loop
        lda     scrollYScreenStrideLo,y ; 1E77  +40 or -40 = one screen row
        sta     screenRowStride         ; 1E7A  zp_F4 low byte of the screen row stride
        lda     scrollYScreenStrideHi,y ; 1E7C  sign/high byte
        sta     screenRowStrideHi       ; 1E7F  zp_F5

; ----------------------------------------------------------------------
; One character row of the view: move 96 bytes (a 12-character window shifted by one map cell) or all
; 112 bytes of the row in the bitmap, then the matching 12 or 14 bytes of screen RAM and of colour
; RAM.  Every operand that the listing shows as irqVectorHi ($FFFF) is a placeholder that was
; rewritten above, and the INY at $1E89/$1EBE may have been replaced by a DEY, so that a shifted copy
; walks away from the overlap instead of into it.
; ----------------------------------------------------------------------
L_1E81:
        ldy     #$FF                    ; 1E81  start index, patched at $1DEF ($5F downwards, or 0 upwards)
L_1E83:
        lda     irqVectorHi,y           ; 1E83  source byte of this bitmap row (operand patched at $1DFF/$1E07)
L_1E86:
        sta     irqVectorHi,y           ; 1E86  destination byte (operand patched at $1E11/$1E19)
scrollBitmapStepOpcode:
        iny                             ; 1E89  INY or DEY, patched at $1E1F: copy forwards or backwards so a shift cannot eat itself
L_1E8A:
        cpy     #$FF                    ; 1E8A  end index, patched at $1DF5
        bne     L_1E83                  ; 1E8C  next byte of this character row (96 or 112 bytes)
        lda     L_1E86+1                ; 1E8E  step the destination pointer one character row on
        clc                             ; 1E91  plain add
        adc     bitmapRowStride         ; 1E92  +320 or -320
        sta     L_1E86+1                ; 1E94  write the new low byte back into the STA operand
        lda     L_1E86+2                ; 1E97  high byte of the destination
        adc     bitmapRowStrideHi       ; 1E9A  with the sign of the stride
        sta     L_1E86+2                ; 1E9C  write it back
        lda     L_1E83+1                ; 1E9F  the same for the source pointer
        clc                             ; 1EA2  plain add
        adc     bitmapRowStride         ; 1EA3  +320 or -320
        sta     L_1E83+1                ; 1EA5  write the new low byte into the LDA operand
        lda     L_1E83+2                ; 1EA8  high byte of the source
        adc     bitmapRowStrideHi       ; 1EAB  with the sign of the stride
        sta     L_1E83+2                ; 1EAD  write it back
L_1EB0:
        ldy     #$FF                    ; 1EB0  start index of the character copy, patched at $1E34
L_1EB2:
        lda     irqVectorHi,y           ; 1EB2  screen RAM source (the tile's colour nibbles), operand patched at $1E44/$1E4F
L_1EB5:
        sta     irqVectorHi,y           ; 1EB5  screen RAM destination, patched at $1E5E/$1E69
L_1EB8:
        lda     irqVectorHi,y           ; 1EB8  the same character in colour RAM at $D800, patched at $1E47/$1E54
L_1EBB:
        sta     irqVectorHi,y           ; 1EBB  colour RAM destination, patched at $1E61/$1E6E
scrollScreenStepOpcode:
        iny                             ; 1EBE  INY or DEY, patched at $1E74
L_1EBF:
        cpy     #$FF                    ; 1EBF  end index, patched at $1E3A
        bne     L_1EB2                  ; 1EC1  next character of this row (12 or 14 of them)
        lda     L_1EB5+1                ; 1EC3  step the screen destination one row on
        clc                             ; 1EC6  plain add
        adc     screenRowStride         ; 1EC7  +40 or -40
        sta     L_1EB5+1                ; 1EC9  write it back into the screen STA operand
        sta     L_1EBB+1                ; 1ECC  and into the colour RAM STA operand, which shares the low byte
        lda     L_1EB5+2                ; 1ECF  high byte of the screen destination
        adc     screenRowStrideHi       ; 1ED2  with the sign of the stride
        sta     L_1EB5+2                ; 1ED4  write it back
        clc                             ; 1ED7  prepare the colour RAM bias
        adc     #$4C                    ; 1ED8  screen page + $4C = colour RAM page
        sta     L_1EBB+2                ; 1EDA  write the colour RAM destination high byte
        lda     L_1EB2+1                ; 1EDD  step the screen source one row on
        clc                             ; 1EE0  plain add
        adc     screenRowStride         ; 1EE1  +40 or -40
        sta     L_1EB2+1                ; 1EE3  write it back into the screen LDA operand
        sta     L_1EB8+1                ; 1EE6  and into the colour RAM LDA operand
        lda     L_1EB2+2                ; 1EE9  high byte of the screen source
        adc     screenRowStrideHi       ; 1EEC  with the sign of the stride
        sta     L_1EB2+2                ; 1EEE  write it back
        clc                             ; 1EF1  prepare the colour RAM bias
        adc     #$4C                    ; 1EF2  +$4C pages = $D800
        sta     L_1EB8+2                ; 1EF4  write the colour RAM source high byte
        dec     scrollRowCount          ; 1EF7  one character row of the view is done
        bne     L_1E81                  ; 1EF9  8 or 10 rows in all

; ----------------------------------------------------------------------
; The cells that scrolled into sight still hold the old picture: redraw the newly exposed column (5
; cells) and/or the newly exposed row (7 cells) straight from the map.  $54/$55/$56 are the cell byte
; / column / row triple that drawViewportCell expects.
; ----------------------------------------------------------------------
        lda     viewOriginCol           ; 1EFB  left edge of the view
        ldx     scrollDeltaCol          ; 1EFD  0/1/2 = the view moved left / not at all / right
        cpx     #$01                    ; 1EFF  1 = no horizontal movement
        beq     L_1F24                  ; 1F01  nothing new appeared at the sides
        bcc     L_1F08                  ; 1F03  moved left: the freshly exposed column is the left edge itself
        clc                             ; 1F05  plain add
        adc     #$06                    ; 1F06  moved right: it is the 7th (rightmost) column of the view
L_1F08:
        sta     savedRegX               ; 1F08  $55 = column of the cell to draw
        lda     viewOriginRow           ; 1F0A  top row of the view
        sta     savedRegY               ; 1F0C  $56 = row of the cell to draw
        lda     #$05                    ; 1F0E  the view is 5 cells tall
        sta     redrawCellCount         ; 1F10  zp_F7 counts the cells still to draw
L_1F12:
        ldx     savedRegX               ; 1F12  X = map column
        ldy     savedRegY               ; 1F14  Y = map row
        jsr     readMapCell             ; 1F16  read the map byte: terrain code, or $80 | unit index when a unit stands there
        sta     savedRegA               ; 1F19  $54 = the cell byte drawViewportCell expects
        jsr     drawViewportCell        ; 1F1B  render the 16x16 pixel tile into the bitmap
        inc     savedRegY               ; 1F1E  next row down the exposed column
        dec     redrawCellCount         ; 1F20  one cell done
        bne     L_1F12                  ; 1F22  5 cells of the column
L_1F24:
        lda     viewOriginRow           ; 1F24  top row of the view
        ldy     scrollDeltaRow          ; 1F26  0/1/2 = the view moved up / not at all / down
        cpy     #$01                    ; 1F28  1 = no vertical movement
        beq     L_1F4D                  ; 1F2A  nothing new appeared above or below: done
        bcc     L_1F31                  ; 1F2C  moved up: the freshly exposed row is the top edge itself
        clc                             ; 1F2E  plain add
        adc     #$04                    ; 1F2F  moved down: it is the 5th (bottom) row of the view
L_1F31:
        sta     savedRegY               ; 1F31  $56 = row of the cells to draw
        lda     viewOriginCol           ; 1F33  start at the left edge
        sta     savedRegX               ; 1F35  $55 = column of the cell to draw
        lda     #$07                    ; 1F37  the view is 7 cells wide
        sta     redrawCellCount         ; 1F39  zp_F7 counts them down
L_1F3B:
        ldx     savedRegX               ; 1F3B  X = map column
        ldy     savedRegY               ; 1F3D  Y = map row
        jsr     readMapCell             ; 1F3F  read the map byte
        sta     savedRegA               ; 1F42  $54 = cell byte
        jsr     drawViewportCell        ; 1F44  draw the 16x16 tile
        inc     savedRegX               ; 1F47  next column along the exposed row
        dec     redrawCellCount         ; 1F49  one cell done
        bne     L_1F3B                  ; 1F4B  7 cells of the row
L_1F4D:
        rts                             ; 1F4D  the view now matches the map again


; ----------------------------------------------------------------------
; keepGroupBoxInsideMap - Nudges the view origin one cell at a time until the whole group formation
; box (the cursor cell plus the signed edge offsets $922E-$9231 built by buildGroupFormationSprite)
; fits inside the map: 40 columns wide and, vertically, inside the row window set by
; setViewVerticalLimit.  Every nudge restarts the four edge tests; if at least one nudge was needed
; the view is repainted.
; In:  viewOriginCol/Row (zp_A5/zp_A6); groupBoxLeftOffset/RightOffset/TopOffset/BottomOffset
;      ($922F/$922E/$9231/$9230); gamePhase
; Out: viewOriginCol/Row corrected, viewRowLimit set, zp_18/zp_19 scratch; tail-jumps to drawViewport
;      when anything moved
; Called from: buildGroupFormationSprite ($319D), right after the box offsets are computed
; ----------------------------------------------------------------------
keepGroupBoxInsideMap:
        lda     #$01                    ; 1F4E  1 so the first DEC below leaves 0
        sta     scratch19               ; 1F50  zp_19 = 0 means 'the view has not been nudged yet'
L_1F52:
        dec     scratch19               ; 1F52  count this pass; any pass after the first leaves zp_19 non-zero
        ldy     viewOriginRow           ; 1F54  current top row of the view
        jsr     setViewVerticalLimit    ; 1F56  zp_F8 = 40 or 20; Y comes back biased by -20 in the setup phase
        tya                             ; 1F59  top row
        clc                             ; 1F5A  plain add
        adc     #$02                    ; 1F5B  cursor row = top edge + 2
        sta     scratch18               ; 1F5D  zp_18 keeps the cursor row for the second test
        clc                             ; 1F5F  plain add
        adc     groupBoxTopOffset       ; 1F60  $9231 = signed offset to the top edge of the group box
        cmp     viewRowLimit            ; 1F63  still inside the allowed rows?
        bcc     L_1F6C                  ; 1F65  yes: test the bottom edge
        inc     viewOriginRow           ; 1F67  box sticks out above the map: slide the view one row down
        jmp     L_1F52                  ; 1F69  and start the four tests again

L_1F6C:
        lda     scratch18               ; 1F6C  cursor row again
        clc                             ; 1F6E  plain add
        adc     groupBoxBottomOffset    ; 1F6F  $9230 = signed offset to the bottom edge of the box
        cmp     viewRowLimit            ; 1F72  still inside the allowed rows?
        bcc     L_1F7B                  ; 1F74  yes: go on to the columns
        dec     viewOriginRow           ; 1F76  box sticks out below: slide the view one row up
        jmp     L_1F52                  ; 1F78  and re-test

L_1F7B:
        lda     viewOriginCol           ; 1F7B  current left column of the view
        clc                             ; 1F7D  plain add
        adc     #$03                    ; 1F7E  cursor column = left edge + 3
        sta     scratch18               ; 1F80  zp_18 keeps the cursor column
        clc                             ; 1F82  plain add
        adc     groupBoxLeftOffset      ; 1F83  $922F = signed offset to the left edge of the box
        cmp     #$28                    ; 1F86  40 map columns
        bcc     L_1F8F                  ; 1F88  inside: test the right edge
        inc     viewOriginCol           ; 1F8A  box sticks out to the left: slide the view one column right
        jmp     L_1F52                  ; 1F8C  and re-test

L_1F8F:
        lda     scratch18               ; 1F8F  cursor column again
        clc                             ; 1F91  plain add
        adc     groupBoxRightOffset     ; 1F92  $922E = signed offset to the right edge of the box
        cmp     #$28                    ; 1F95  40 map columns
        bcc     L_1F9E                  ; 1F97  the whole box fits: done
        dec     viewOriginCol           ; 1F99  box sticks out to the right: slide the view one column left
        jmp     L_1F52                  ; 1F9B  and re-test

L_1F9E:
        lda     scratch19               ; 1F9E  0 only if the box already fitted on the very first pass
        beq     L_1FA5                  ; 1FA0  nothing moved: leave the screen alone
        jmp     drawViewport            ; 1FA2  the view moved: repaint all 35 cells and return from there

L_1FA5:
        rts                             ; 1FA5  view origin now keeps the whole group box on the map


; joystickDirectionTable - joystick nibble to direction code.  The raster IRQ latches bits 0-4 of CIA1
; port A (joystick 2) into zp_5D, active low, so $0F means centred: bit 0 up, bit 1 down, bit 2 left,
; bit 3 right.  The table maps that nibble to a direction code 0-7 (0 N, 1 S, 2 E, 3 W, 4 NW, 5 NE, 6
; SE, 7 SW) or to $FF for 'no usable direction'.  Logically it is 16 bytes; the last four
; ($FF,$01,$00,$FF for nibbles $0C-$0F) were split off by the disassembler and are the first four
; bytes of the block labelled diagonalDirectionTable.
joystickDirectionTable:
        .byte   $FF,$FF,$FF,$FF,$FF,$06,$05,$02; 1FA6  ........  nibbles $00-$07: only $05 (down+right) -> 6 SE, $06 (up+right) -> 5 NE and $07 (right) -> 2 E are real
        .byte   $FF,$07,$04,$03         ; 1FAE  ....

; $1FB2-$1FB5 are still joystickDirectionTable (nibble $0C impossible, $0D = down -> 1 S, $0E = up ->
; 0 N, $0F = centred -> $FF). diagonalDirectionTable proper is $1FB6-$1FB9: for direction codes 4-7 it
; packs (index of the column delta << 4) | index of the row delta into directionDeltaTable - NW =
; (-1,-1), NE = (+1,-1), SE = (+1,+1), SW = (-1,+1).  scrollViewportByDirection ($2111), the comcen
; screens ($736F, $76CF) and the drone AI ($EF58) all index it with the direction code, so only
; offsets 4-7 are ever read. The eight bytes at $1FBA (01 00 03 02 06 07 04 05) are an
; opposite-direction table - N<->S, E<->W, NW<->SE, NE<->SW - that no unit references; together with
; the $00 pad at $1FC2 they look like leftover data.
diagonalDirectionTable:
        .byte   $FF,$01,$00,$FF,$30,$20,$21,$31; 1FB2  ....0 !1  $1FB2-$1FB5 finish joystickDirectionTable; $1FB6-$1FB9 = diagonals 4-7; $1FBA-$1FC1 unused
        .byte   $01,$00,$03,$02,$06,$07,$04,$05; 1FBA  ........
        .byte   $00                     ; 1FC2  .

; ----------------------------------------------------------------------
; enterMapScreen - Entry point of the battlefield screen (game mode 0).  Restores the viewport origin
; the player left it at and repaints the whole screen - the three frames, the strategic mini-map, the
; tactical view and the status bar - then falls straight into runMapMainLoop.
; In:  savedViewOriginCol/Row ($92C3/$92C4)
; Out: viewOriginCol/Row set, screen redrawn; does not return - falls into runMapMainLoop
; Called from: switchToRequestedScreen ($06F5, by JMP, when F1 selects screen 0)
; ----------------------------------------------------------------------
enterMapScreen:
        lda     savedViewOriginCol      ; 1FC3  $92C3, written every pass of the map main loop
        sta     viewOriginCol           ; 1FC6  zp_A5 = map column of the left edge of the 7x5 view
        lda     savedViewOriginRow      ; 1FC8  $92C4
        sta     viewOriginRow           ; 1FCB  zp_A6 = map row of the top edge of the view
        jsr     redrawGameScreen        ; 1FCD  rebuild frames, mini-map, tactical view and status bar, then fall into the main loop

; ----------------------------------------------------------------------
; runMapMainLoop - The interactive loop of the battlefield screen and of the unit-placement phase. It
; resets the cursor state, draws the view, and then loops: one game frame, then hover handling
; (resting 15 frames on one of your own units pops up the info panel) and click handling (a click
; selects the unit, or destroys it during placement while DESTROY mode is armed), then joystick
; scrolling with auto-repeat.  During the battle (gamePhase = 0) an F1/F3/F5/F7 request is honoured by
; switchToRequestedScreen, which rebuilds the stack and jumps to the new screen instead of returning;
; during unit placement the overlay-A '*' hook is polled every pass and the routine returns when F1 is
; pressed or the phase ends.  Units of the other side are filtered out by two 'cmp #$32' tests whose
; following branch opcode ($206B, $2084) is written at run time from the local player's side.
; In:  gamePhase (zp_B1), joyDirection (zp_5D), inputIdleFrames ($90EE), requestedScreenKey (zp_66),
;      gameEndCountdown ($92C9), revealAllUnits ($92FC), filmPlaybackMode ($0B9B), comcenUnit ($9236),
;      comcenCloakedFlag ($9245), setupToggleFlag ($92F3), the unit arrays and the map
; Out: savedViewOriginCol/Row kept up to date; selectedUnit ($90F8); info panel, cursor sprites and
;      view redrawn; command $91 queued in DESTROY mode; returns only from the placement phase
; Called from: enterMapScreen (fall-through), startGameFromSetup ($0B7A), the screen dispatcher
;      ($075E) and the setup phase of overlay A ($7B4E)
; ----------------------------------------------------------------------
runMapMainLoop:
        lda     #$FF                    ; 1FD0  no key
        sta     lastTypedKey            ; 1FD2  $9248: forget anything typed before the battlefield came up
        sta     infoPanelUnit           ; 1FD5  $91D7 = no unit is being shown in the info panel
        lda     #$00                    ; 1FD8  clear
        sta     pathMarkerState         ; 1FDA  $24E1 = the mini-map path marker has no passes left to run
        sta     cursorTargetMode        ; 1FDD  zp_BB = 0: the cursor is a plain move cursor, not a target picker
        sta     screenBuiltFlag         ; 1FDF  zp_41, a spare slot of the saved text state; nothing ever reads it (dead store)
        lda     savedViewOriginCol      ; 1FE1  return to the part of the map the player was looking at
        sta     viewOriginCol           ; 1FE4  zp_A5
        lda     savedViewOriginRow      ; 1FE6  $92C4
        sta     viewOriginRow           ; 1FE9  zp_A6
        jsr     showCursorBoxSprite     ; 1FEB  sprite 2 = the expanded box that frames the centre cell of the view
        lda     gamePhase               ; 1FEE  0 = the battle proper, non-zero = unit placement
        beq     L_1FFA                  ; 1FF0  battle: no standing message
        lda     #$0A                    ; 1FF2  message 10 = 'SETUP UNITS. PRESS F 1 WHEN DONE.'
        sta     persistentMessageId     ; 1FF4  $92A1 = the message the status line falls back to
        jsr     queueMessage            ; 1FF7  and show it right now as well
L_1FFA:
        lda     #$00                    ; 1FFA  clear
        sta     uiLevel                 ; 1FFC  $9209 = 0: just browsing, no unit or group selected
        sta     activeSpriteIndex       ; 1FFF  $91CC = sprite 0 for positionCursorSprite
        sta     textColour              ; 2002  zp_3D = 0 so the panel is cleared to black
        jsr     clearInfoPanel          ; 2004  blank the info panel window (rows 12-20, columns 25-38)
        lda     #$FF                    ; 2007  no highlight
        sta     highlightedUnit         ; 2009  $9204 = no unit is drawn inverted in the view
        sta     highlightedGroupKey     ; 200C  $9205 = no group is drawn inverted either
        jsr     drawViewport            ; 200F  repaint the 7x5 tactical view from the map
        jsr     positionCursorSprite    ; 2012  put the mini-map cursor sprite on the centre cell
        jsr     setCursorShapeSquare    ; 2015  sprite 0 = the hollow square cursor

; ----------------------------------------------------------------------
; Main loop.  One pass is one game frame plus one look at the joystick.
; ----------------------------------------------------------------------
L_2018:
        lda     viewOriginCol           ; 2018  remember where the player is looking...
        sta     savedViewOriginCol      ; 201A  $92C3, so the console and drone screens can come back to this spot
        lda     viewOriginRow           ; 201D  same for the row
        sta     savedViewOriginRow      ; 201F  $92C4
        lda     gamePhase               ; 2022  which phase?
        beq     L_2033                  ; 2024  0 = the battle proper
        jsr     callSetupOverlayHook    ; 2026  unit placement: let overlay A's '*' handler ($8658) toggle DESTROY mode
        lda     requestedScreenKey      ; 2029  zp_66: 0-3 = F1/F3/F5/F7 pressed, $FF = none
        beq     L_2032                  ; 202B  F1 means 'finished placing units'
        jsr     checkSetupUiContinue    ; 202D  may the placement UI keep running?
        bne     L_2036                  ; 2030  yes: carry on
L_2032:
        rts                             ; 2032  back to the setup code in overlay A

L_2033:
        jsr     switchToRequestedScreen ; 2033  battle: F1/F3/F5/F7 switch screens, and that call never returns
L_2036:
        jsr     updateGameFrame         ; 2036  one game frame: engine step, messages, chat, sound and the link
        lda     joyDirection            ; 2039  joystick nibble latched by the IRQ
        cmp     #$0F                    ; 203B  $0F = centred
        beq     L_2042                  ; 203D  resting on a cell: look at what is under the cursor
        jmp     L_20D7                  ; 203F  stick deflected: straight to the scroll handler


; ----------------------------------------------------------------------
; Hovering: after 15 idle frames on one of your own units the info panel is opened.  Everything below
; runs only while the stick is centred.
; ----------------------------------------------------------------------
L_2042:
        lda     inputIdleFrames         ; 2042  $90EE = frames since the last stick or button activity
        cmp     #$0F                    ; 2045  15 frames, about a quarter of a second
        bcc     L_2076                  ; 2047  not hovering long enough yet
        jsr     readCellUnderCursor     ; 2049  map byte under the cursor cell (viewOriginCol+3, viewOriginRow+2)
        bpl     L_2076                  ; 204C  bit 7 clear = plain terrain, no unit to describe
        ldy     gameEndCountdown        ; 204E  $92C9 counts down while the end-of-game sequence runs
        bne     L_2076                  ; 2051  do not open panels while the game is ending
        and     #$7F                    ; 2053  low 7 bits of an occupied cell = the unit index
        bit     revealAllUnits          ; 2055  $92FC bit 7 = beginner display, every enemy unit visible
        bmi     L_206D                  ; 2058  then any unit may be inspected
        bit     filmPlaybackMode        ; 205A  $0B9B bit 7 = watching a game film
        bmi     L_206D                  ; 205D  a film viewer may inspect both sides
        cmp     comcenUnit              ; 205F  $9236 = your own COMCEN's unit index (49 or 99)
        beq     L_206D                  ; 2062  your comcen can always be inspected
        ldy     comcenCloakedFlag       ; 2064  $9245 bit 7 = your own comcen is cloaked
        bmi     L_2076                  ; 2067  a cloaked comcen is off the air: no unit reports
        cmp     #$32                    ; 2069  50 = the boundary between the two sides' halves of the unit arrays
D_206B:
        bcs     L_2076                  ; 206B  opcode patched by patchOwnSideBranches ($7BE6) to BCC or BCS: skip the opponent's units
L_206D:
        sta     selectedUnit            ; 206D  $90F8 = the unit the panel will describe
        jsr     showUnitInfoPanel       ; 2070  print its stats and hold them while the cursor rests on the unit
        jsr     consumeSpaceKey         ; 2073  if SPACE closed the panel, swallow the key so nothing else acts on it

; ----------------------------------------------------------------------
; Fire button: select one of your own units, or - during unit placement with DESTROY mode armed - send
; the self-destruct command for it.
; ----------------------------------------------------------------------
L_2076:
        jsr     checkFirePressedInteractive; 2076  A = 0 only for a fresh click that counts (not in a film, not after game over)
        bne     L_20D7                  ; 2079  no click: fall through to scrolling
        jsr     readCellUnderCursor     ; 207B  map byte under the cursor
        bpl     L_20D7                  ; 207E  clicked on empty terrain
        and     #$7F                    ; 2080  unit index under the cursor
        cmp     #$32                    ; 2082  50 = the side boundary again
D_2084:
        bcs     L_20D7                  ; 2084  opcode patched by patchOwnSideBranches ($7BE9): ignore clicks on the opponent's units
        sta     selectedUnit            ; 2086  $90F8 = the clicked unit
        tay                             ; 2089  Y = unit index for the array lookups
        lda     unitStunTable,y         ; 208A  $F960: bits 0-2 = stun counter, bits 5-7 = dug-in state
        and     #$07                    ; 208D  keep the stun counter
        bne     L_20D7                  ; 208F  a stunned unit takes no orders
        cpy     comcenUnit              ; 2091  is this the comcen itself?
        beq     L_209B                  ; 2094  the comcen is always reachable
        bit     comcenCloakedFlag       ; 2096  $9245 bit 7 = own comcen cloaked
        bmi     L_20D7                  ; 2099  with the comcen cloaked you cannot give orders
L_209B:
        lda     gamePhase               ; 209B  battle or unit placement?
        beq     L_20CC                  ; 209D  battle: open the unit selection / options flow
        lda     setupToggleFlag         ; 209F  $92F3 bit 7 = DESTROY mode, armed with '*' during placement
        bpl     L_20CC                  ; 20A2  normal placement click: pick the unit up instead
        lda     #$00                    ; 20A4  clear
        sta     setupToggleFlag         ; 20A6  DESTROY is one-shot: disarm it again
        lda     unitDisplayFlagsTable,y ; 20A9  $FB54: bit 6 = the unit is in a group, bits 2-4 = which group
        and     #$40                    ; 20AC  test the 'in a group' bit
        beq     L_20B4                  ; 20AE  a loner: destroy just this unit
        tya                             ; 20B0  unit index
        ora     #$80                    ; 20B1  bit 7 of the argument means 'the whole group this unit belongs to'
        tay                             ; 20B3  back into Y
L_20B4:
        sty     cmdParam0               ; 20B4  zp_73 = the command's argument byte
        lda     #$91                    ; 20B6  command $91 = self destruct
        jsr     queueCmd2               ; 20B8  queue the 2-byte command for the engine and for the opponent
        lda     #$0A                    ; 20BB  back to message 10, 'SETUP UNITS. PRESS F 1 WHEN DONE.'
        sta     persistentMessageId     ; 20BD  $92A1 = fallback message
        sta     shownMessageId          ; 20C0  zp_7B = the message on the status line right now
        lda     #$3C                    ; 20C2  60 frames = one second
        sta     joyRepeatTimer          ; 20C4  zp_6C: no scrolling for a second
        sta     inputLockoutTimer       ; 20C6  $0B7D: the IRQ ignores stick and keys too, so one click destroys exactly one unit
        jmp     L_20D7                  ; 20C9  join the scroll handler

L_20CC:
        jsr     handleOwnUnitClick      ; 20CC  battle: unit or group selection, info panel, destination or options menu
        lda     #$FF                    ; 20CF  no key
        sta     lastTypedKey            ; 20D1  drop whatever key the menus left behind
        jmp     L_1FFA                  ; 20D4  rebuild the browsing state: uiLevel 0, panel cleared, view repainted


; ----------------------------------------------------------------------
; Joystick scrolling with auto-repeat.  Reached from every path above, so a click and a scroll can
; never happen in the same pass.
; ----------------------------------------------------------------------
L_20D7:
        lda     joyRepeatTimer          ; 20D7  zp_6C, the auto-repeat delay counted down by the IRQ
        bne     L_20ED                  ; 20D9  still inside the delay: do not scroll yet
        ldy     joyDirection            ; 20DB  the latched joystick nibble
        lda     #$00                    ; 20DD  clear
        sta     inputLockoutTimer       ; 20DF  $0B7D = 0: stick and keyboard are live again
        lda     joystickDirectionTable,y; 20E2  nibble -> direction code 0-7, or $FF when centred/impossible
        bmi     L_20ED                  ; 20E5  no usable direction
        jsr     setJoystickRepeatDelay  ; 20E7  16 frames before the first repeat, 6 between repeats
        jsr     scrollViewportByDirection; 20EA  move the view one cell in that direction
L_20ED:
        jmp     L_2018                  ; 20ED  next pass of the main loop


; directionDeltaTable - the signed one-cell steps shared by every direction decoder in the game (the
; comcen missile screen and the drone AI read the same four bytes): index 0 = row delta for north, 1 =
; row delta for south, 2 = column delta for east, 3 = column delta for west.
directionDeltaTable:
        .byte   $FF,$01,$01,$FF         ; 20F0  ....  $FF,$01,$01,$FF = -1 north, +1 south, +1 east, -1 west

; ----------------------------------------------------------------------
; scrollViewportByDirection - Turns a direction code 0-7 into a column/row step and scrolls the view
; one cell with scrollViewBy.  Codes 0-3 move on one axis only; codes 4-7 read the packed byte in
; diagonalDirectionTable for both axes and stretch the auto-repeat delay by 1.5 so a diagonal does not
; cover ground faster than a straight move.  In the range-limited target mode (cursorTargetMode
; positive, i.e. aiming a BOOMER) the step is only taken if the new cursor cell is still inside the
; selected unit's firing range.
; In:  A = direction code 0-7; joyRepeatTimer (zp_6C); cursorTargetMode (zp_BB); selectedUnit
;      ($90F8); viewOriginCol/Row; unitTerrainUnderTable ($FAF0)
; Out: view scrolled; joyRepeatTimer stretched for diagonals; fireDebounceTimer (zp_6D) = 10;
;      fireButtonArmed (zp_79) = 0; zp_22/zp_24/zp_27/zp_28 scratch.  Self-modifies the LDX/LDY
;      operands at $2175+1 and $2177+1
; Called from: runMapMainLoop ($20EA), runTargetCursorLoop ($2233) and the console tab loop ($6435)
; ----------------------------------------------------------------------
scrollViewportByDirection:
        ldy     #$00                    ; 20F4  default row step 0
        ldx     #$00                    ; 20F6  default column step 0
        cmp     #$04                    ; 20F8  codes 4-7 are the diagonals
        bcs     L_2110                  ; 20FA  handle them separately
        cmp     #$02                    ; 20FC  codes 2-3 are east and west
        bcs     L_2108                  ; 20FE  those move on the column axis
        tay                             ; 2100  codes 0-1 (north/south) index the delta table directly
        lda     directionDeltaTable,y   ; 2101  -1 for north, +1 for south
        tay                             ; 2104  Y = row step
        jmp     L_212E                  ; 2105  column step stays 0

L_2108:
        tax                             ; 2108  codes 2-3 select entries 2-3
        lda     directionDeltaTable,x   ; 2109  +1 for east, -1 for west
        tax                             ; 210C  X = column step
        jmp     L_212E                  ; 210D  row step stays 0

L_2110:
        tay                             ; 2110  direction code 4-7
        lda     diagonalDirectionTable,y; 2111  $30 NW, $20 NE, $21 SE, $31 SW = (column delta index << 4) | row delta index
        pha                             ; 2114  keep the packed byte
        and     #$0F                    ; 2115  low nibble = index of the row delta
        tay                             ; 2117  into Y
        lda     directionDeltaTable,y   ; 2118  -1 or +1
        tay                             ; 211B  Y = row step
        pla                             ; 211C  packed byte back
        lsr     a                       ; 211D  shift the high nibble down...
        lsr     a                       ; 211E  ...
        lsr     a                       ; 211F  ...
        lsr     a                       ; 2120  = index of the column delta
        tax                             ; 2121  into X
        lda     directionDeltaTable,x   ; 2122  -1 or +1
        tax                             ; 2125  X = column step
        lda     joyRepeatTimer          ; 2126  current auto-repeat delay
        lsr     a                       ; 2128  half of it
        clc                             ; 2129  plain add
        adc     joyRepeatTimer          ; 212A  delay * 1.5
        sta     joyRepeatTimer          ; 212C  a diagonal step covers more ground, so it repeats more slowly

; ----------------------------------------------------------------------
; In BOOMER target mode the cursor may not leave the unit's firing range, so the step is tried out on
; paper first: squared distance from the unit to the cell we would move to, against r*(r+1) for the
; unit's range.
; ----------------------------------------------------------------------
L_212E:
        lda     cursorTargetMode        ; 212E  zp_BB: 0 = move order, >0 = range-limited target, <0 = free cell pick
        beq     L_2179                  ; 2130  move order: scroll freely
        bmi     L_2179                  ; 2132  free cell pick: scroll freely
        stx     L_2175+1                ; 2134  self-modifying: stash the column step in the LDX # operand at $2175+1
        sty     L_2177+1                ; 2137  and the row step in the LDY # operand at $2177+1
        txa                             ; 213A  column step
        clc                             ; 213B  plain add
        adc     viewOriginCol           ; 213C  left edge of the view
        clc                             ; 213E  plain add
        adc     #$03                    ; 213F  +3 = the cursor cell after the move
        sta     cellColA                ; 2141  zp_22 = column of point A for distanceSquared
        tya                             ; 2143  row step
        clc                             ; 2144  plain add
        adc     viewOriginRow           ; 2145  top edge of the view
        clc                             ; 2147  plain add
        adc     #$02                    ; 2148  +2 = the cursor row after the move
        sta     cellRowA                ; 214A  zp_24 = row of point A
        ldy     selectedUnit            ; 214C  the unit that is doing the shooting
        lda     unitColTable,y          ; 214F  $F640+unit
        sta     cellColB                ; 2152  zp_27 = column of point B (the unit)
        lda     unitRowTable,y          ; 2154  $F6A4+unit
        sta     cellRowB                ; 2157  zp_28 = row of point B
        jsr     distanceSquared         ; 2159  A = dx*dx + dy*dy, each delta capped at 15 and the sum at $FF
        sta     cellColA                ; 215C  reuse zp_22 to hold that squared distance
        ldx     selectedUnit            ; 215E  the firing unit again
        ldy     unitTerrainUnderTable,x ; 2161  $FAF0 = the terrain code hidden underneath the unit
        ldx     D_077E,y                ; 2164  $077E indexed by the raw cell value = terrain class 0-8
        lda     terrainSightBonusTable,x; 2167  $35BB = 0,0,0,1,1,2,2,0 extra cells of reach for standing high
        clc                             ; 216A  plain add
        adc     #$06                    ; 216B  a BOOMER shoots 6 cells, plus the terrain bonus
        tax                             ; 216D  range 6-8 as a table index
        lda     rangeDistanceSqTable,x  ; 216E  $35B0 = r*(r+1), the squared distance a target has to stay under
        cmp     cellColA                ; 2171  compare it with the distance to the cell we would move to
        bcc     L_217C                  ; 2173  out of range: leave the cursor where it is
L_2175:
        ldx     #$FF                    ; 2175  column step, operand patched at $2134
L_2177:
        ldy     #$FF                    ; 2177  row step, operand patched at $2137
L_2179:
        jsr     scrollViewBy            ; 2179  scroll the view, and the cursor with it, by one cell
L_217C:
        lda     #$0A                    ; 217C  10 frames
        sta     fireDebounceTimer       ; 217E  zp_6D: ignore the fire button briefly after a scroll
        lda     #$00                    ; 2180  clear
        sta     fireButtonArmed         ; 2182  zp_79: the button must be seen released again before the next click counts
        rts                             ; 2184  the caller loops back and reads the stick again


; ----------------------------------------------------------------------
; runTargetCursorLoop - Modal loop that lets the player drive the cursor to a cell and then acts on
; it.  cursorTargetMode picks the flavour: 0 = choose a destination for the selected unit, or for its
; whole group when uiLevel is 2 (the formation ghost is built into sprite 0); positive = choose a
; target for a BOOMER (cross cursor, movement limited to its range); negative = only report the cell
; the player stops on in pickedCellCol/pickedCellRow.  Each pass runs one game frame, previews the
; route after 90 idle frames, scrolls on joystick, and on a fire click issues the order.  The normal
; exits scroll the view back onto the unit and restore the plain battlefield cursor.
; In:  cursorTargetMode (zp_BB), selectedUnit ($90F8), uiLevel ($9209), gamePhase bit 6,
;      stepDelayTimer ($90FC), inputIdleFrames ($90EE), joystick and fire button
; Out: an order queued by orderSelectedUnitToCursor / sendGroupMoveToCursor / orderAttackUnitAtCursor,
;      or pickedCellCol/Row ($91D8/$91D9) for a free pick; cursorTargetMode = 0, groupBoxActive = 0,
;      sprites 0 and 1 blanked and un-expanded, sprite 2 back to the box cursor
; Called from: the unit destination flow ($4BE1, $5F64, $650A) and overlay A's setup screen ($83FF)
; ----------------------------------------------------------------------
runTargetCursorLoop:
        lda     #$FF                    ; 2185  no unit
        sta     infoPanelUnit           ; 2187  $91D7: nothing is being described while the target cursor is up
        lda     #$00                    ; 218A  sprite 0
        sta     activeSpriteIndex       ; 218C  $91CC = the sprite that carries the cursor shape
        lda     cursorTargetMode        ; 218F  zp_BB selects the flavour of this loop
        beq     L_219B                  ; 2191  0 = choosing a destination
        bmi     L_21A8                  ; 2193  negative = free cell pick: plain block cursor
        jsr     setCursorShapeCross     ; 2195  positive = attack target: thick cross into sprite 0
        jmp     L_21AE                  ; 2198  sprite 2 is left as the caller set it

L_219B:
        lda     uiLevel                 ; 219B  $9209: 0 browsing, 1 single unit, 2 whole group
        cmp     #$02                    ; 219E  2 or more = a group is selected
        bcc     L_21A8                  ; 21A0  single unit: plain block cursor
        jsr     buildGroupFormationSprite; 21A2  group move: sprite 0 shows every member as a dot, and the box offsets are built
        jmp     L_21AB                  ; 21A5  on to the crosshair

L_21A8:
        jsr     setCursorShapeBlock     ; 21A8  small solid block into sprite $91CC
L_21AB:
        jsr     showCrosshairSprite     ; 21AB  sprite 2 = the diamond crosshair over the centre cell

; ----------------------------------------------------------------------
; Cursor loop: one pass per frame until the player clicks, the unit dies or the UI is aborted.
; ----------------------------------------------------------------------
L_21AE:
        bit     gamePhase               ; 21AE  zp_B1 bit 6 marks the phases in which the engine must not run
        bvc     L_21B8                  ; 21B0  normal game: run a whole frame
        jsr     serviceCommandExchange  ; 21B2  setup/film phase: only keep the lock-step link serviced
        jmp     L_21C0                  ; 21B5  skip the frame update

L_21B8:
        lda     stepDelayTimer          ; 21B8  $90FC counts down to the next engine step
        bne     L_21C0                  ; 21BB  one frame update per engine step is enough
        jsr     updateGameFrame         ; 21BD  engine, messages, sound, link
L_21C0:
        lda     inputIdleFrames         ; 21C0  $90EE = frames since the last input
        cmp     #$5A                    ; 21C3  90 frames = one and a half seconds
        bcc     L_21CE                  ; 21C5  player is still busy
        lda     cursorTargetMode        ; 21C7  only the plain move cursor previews a route
        bne     L_21CE                  ; 21C9  target or free-pick cursor: no preview
        jsr     previewPathToCursor     ; 21CB  crawl the marker sprite from the unit to the cursor cell
L_21CE:
        ldy     selectedUnit            ; 21CE  the unit being ordered
        lda     unitTypeTable,y         ; 21D1  $F76C: bit 7 set = the unit has been destroyed
        bpl     L_21D9                  ; 21D4  still alive: carry on
        jmp     L_2239                  ; 21D6  it died while we were aiming: clean up and re-centre

L_21D9:
        jsr     checkUiContinue         ; 21D9  SPACE, a function key or the end of the game abort every modal UI
        bne     L_21E1                  ; 21DC  A = 1: keep going
        jmp     L_223C                  ; 21DE  aborted: clean up without scrolling back

L_21E1:
        lda     joyDirection            ; 21E1  joystick nibble
        cmp     #$0F                    ; 21E3  $0F = centred
        bne     L_2220                  ; 21E5  deflected: scroll the cursor
        jsr     checkFirePressed        ; 21E7  fresh press of the fire button?
        bne     L_2220                  ; 21EA  no click: fall through to the scroll handler
        lda     cursorTargetMode        ; 21EC  which kind of pick is this?
        beq     L_220B                  ; 21EE  0 = a move order
        bmi     L_21F8                  ; 21F0  negative = the caller only wants the cell
        jsr     orderAttackUnitAtCursor ; 21F2  positive = attack: queue command $87 against the unit under the cursor
        jmp     L_2239                  ; 21F5  order sent: clean up

L_21F8:
        lda     viewOriginCol           ; 21F8  left edge of the view
        clc                             ; 21FA  plain add
        adc     #$03                    ; 21FB  +3 = the cursor column
        sta     pickedCellCol           ; 21FD  $91D8 = the cell the caller asked for
        lda     viewOriginRow           ; 2200  top edge of the view
        clc                             ; 2202  plain add
        adc     #$02                    ; 2203  +2 = the cursor row
        sta     pickedCellRow           ; 2205  $91D9
        jmp     L_2263                  ; 2208  return at once and leave the sprites to the caller

L_220B:
        lda     uiLevel                 ; 220B  $9209: single unit or whole group?
        cmp     #$02                    ; 220E  2 = group
        bcs     L_2218                  ; 2210  group move
        jsr     orderSelectedUnitToCursor; 2212  single unit: queue command $80 unit,column,row
        jmp     L_2239                  ; 2215  order sent: clean up

L_2218:
        jsr     sendGroupMoveToCursor   ; 2218  queue command $81, move the whole group to the cursor cell
        bcs     L_2236                  ; 221B  sendGroupMoveToCursor always returns C=0, so this 'stay in the loop' branch is dead code
        jmp     L_2239                  ; 221D  order sent: clean up

L_2220:
        lda     joyRepeatTimer          ; 2220  zp_6C auto-repeat delay
        bne     L_2236                  ; 2222  still inside the delay
        ldy     joyDirection            ; 2224  joystick nibble
        lda     #$00                    ; 2226  clear
        sta     inputLockoutTimer       ; 2228  $0B7D = 0: input is live
        lda     joystickDirectionTable,y; 222B  nibble -> direction code 0-7, $FF when centred
        bmi     L_2236                  ; 222E  no usable direction
        jsr     setJoystickRepeatDelay  ; 2230  16 frames for the first step, 6 for the repeats
        jsr     scrollViewportByDirection; 2233  move the cursor one cell (range-checked in target mode)
L_2236:
        jmp     L_21AE                  ; 2236  next pass of the cursor loop


; ----------------------------------------------------------------------
; Leaving the target cursor: put the sprites and the cursor mode back the way the battlefield loop
; expects them.  $2239 re-centres the view on the unit first; $223C is entered directly when the UI
; was aborted.
; ----------------------------------------------------------------------
L_2239:
        jsr     recenterViewportOnUnit  ; 2239  scroll the view back until the unit is under the cursor again
L_223C:
        lda     #$00                    ; 223C  clear
        sta     groupBoxActive          ; 223E  $920A: the formation box is no longer being dragged
        sta     cursorTargetMode        ; 2241  zp_BB back to the plain move cursor
        lda     #$01                    ; 2243  sprite 1
        jsr     clearSpriteShape        ; 2245  blank its 64-byte shape (the path marker)
        lda     #$00                    ; 2248  sprite 0
        jsr     clearSpriteShape        ; 224A  blank its shape (cursor or formation ghost)
        lda     spriteXExpandShadow     ; 224D  $9023 = shadow of VIC_SPR_EXP_X
        and     #$FC                    ; 2250  clear bits 0-1: sprites 0 and 1 back to normal width
        sta     spriteXExpandShadow     ; 2252  the IRQ copies the shadow to the VIC
        lda     spriteYExpandShadow     ; 2255  $9024 = shadow of VIC_SPR_EXP_Y
        and     #$FC                    ; 2258  sprites 0 and 1 back to normal height
        sta     spriteYExpandShadow     ; 225A  write the shadow back
        jsr     resetPathMarkerToUnit   ; 225D  park the path marker back on the unit, ready for five more passes
        jsr     showCursorBoxSprite     ; 2260  sprite 2 back to the normal centre-cell box
L_2263:
        rts                             ; 2263  back to the menu or setup code that asked for a cell


; ----------------------------------------------------------------------
; previewPathToCursor - Idle-time animation: while the player leaves the controls alone with a move
; cursor up, the marker sprite crawls along the straight line from the selected unit to the cursor
; cell so the intended route can be seen on the strategic mini-map.  The target is re-read every pass
; (the view may still scroll) and the animation stops as soon as checkUserInterrupt reports stick,
; button, key or link activity.
; In:  selectedUnit ($90F8), viewOriginCol/Row, unit arrays $F640/$F6A4/$F76C
; Out: lineEndCol/lineEndRow (zp_95/zp_94), marker state $24E1-$24E3, sprite 1 animated and finally
;      hidden
; Called from: runTargetCursorLoop ($21CB) after 90 idle frames
; ----------------------------------------------------------------------
previewPathToCursor:
        ldy     selectedUnit            ; 2264  the unit being ordered
        lda     viewOriginCol           ; 2267  left edge of the view
        clc                             ; 2269  plain add
        adc     #$03                    ; 226A  +3 = cursor column
        cmp     unitColTable,y          ; 226C  $F640+unit
        bne     L_227B                  ; 226F  different column: there is a route to show
        lda     viewOriginRow           ; 2271  top edge of the view
        clc                             ; 2273  plain add
        adc     #$02                    ; 2274  +2 = cursor row
        cmp     unitRowTable,y          ; 2276  $F6A4+unit
        beq     L_229F                  ; 2279  cursor sits on the unit itself: nothing to preview
L_227B:
        jsr     resetPathMarker         ; 227B  put the marker on the unit and give it five passes
L_227E:
        ldy     selectedUnit            ; 227E  the unit being ordered
        lda     unitTypeTable,y         ; 2281  $F76C bit 7 = destroyed
        bmi     L_229F                  ; 2284  it died: stop the animation
        jsr     updateGameFrame         ; 2286  keep the game and the link running while we animate
        lda     #$03                    ; 2289  3
        clc                             ; 228B  plain add
        adc     viewOriginCol           ; 228C  left edge of the view
        sta     lineEndCol              ; 228E  zp_95 = cursor column, re-read every pass in case the view scrolled
        lda     #$02                    ; 2290  2
        clc                             ; 2292  plain add
        adc     viewOriginRow           ; 2293  top edge of the view
        sta     lineEndRow              ; 2295  zp_94 = cursor row
        jsr     animatePathMarkerStep   ; 2297  advance the marker one cell every 4 frames
        jsr     checkUserInterrupt      ; 229A  C=1 as soon as the player or the link wants attention
        bcc     L_227E                  ; 229D  still idle: keep animating
L_229F:
        jmp     hideSprite1             ; 229F  turn the marker sprite off and return


; ----------------------------------------------------------------------
; showUnitInfoPanel - Fills the info panel under the tactical view (text window rows 12-20, columns
; 25-38) with the description of unit selectedUnit: the type name and unit number, the GROUP / ENERGY
; / WEAPON / ACTION labels, the values printed by printUnitStatusLines and the three sprites that make
; up the unit picture.  It then holds that display while the cursor rests on the unit, reprinting the
; values whenever the unit is being stepped or shot at or a lock-step exchange has been applied, and
; animating the destination marker.  Moving off the unit, an interrupt or the end of the placement
; phase clears the panel and the picture sprites again.
; In:  selectedUnit ($90F8), uiLevel ($9209), the unit arrays, stepDelayTimer ($90FC), currentUnit
;      ($90FA), fireTargetUnit ($91F0), exchangeAppliedFlag ($920E), highlightedGroupKey ($9205),
;      cursorTargetMode, gamePhase
; Out: panel drawn and cleared again; infoPanelUnit ($91D7) = the unit while it is shown and $FF
;      afterwards; textCentreWidth = 0; sprites 5-7 disabled; text window reset to full screen.
;      Self-modifies the LDX # operand at $22EF+1
; Called from: runMapMainLoop ($2070, hovering), handleOwnUnitClick ($5E4C) and the console unit list
;      ($63F5)
; ----------------------------------------------------------------------
showUnitInfoPanel:
        lda     #$00                    ; 22A2  clear
        sta     textCentreWidth         ; 22A4  $90EF = 0: print flush left, no centring
        sta     textFillGlyph           ; 22A7  zp_3E: blanks are glyph 0
        sta     rawGlyphMode            ; 22A9  zp_43: characters go through the normal ASCII translation
        ldy     uiLevel                 ; 22AB  $9209: 0 browsing, 1 unit selected, 2 group selected
        lda     uiLevelTextColourTable,y; 22AE  $5E14 = 9 / 5 / 4: the panel text colour for that level
        sta     textColour              ; 22B1  zp_3D = colour byte written for every character cell
        jsr     clearInfoPanel          ; 22B3  open and blank the info panel window (rows 12-20, columns 25-38)
        lda     #$02                    ; 22B6  column 2
        sta     textCursorCol           ; 22B8  zp_3F: start two characters in from the left edge of the panel
        jsr     updateCursorPointers    ; 22BA  recompute the bitmap and colour pointers for that cell
        ldx     selectedUnit            ; 22BD  the unit on display
        lda     unitTypeTable,x         ; 22C0  $F76C: type in the low bits, bit 7 = dead
        and     #$07                    ; 22C3  0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY, 4 COMCEN
        tax                             ; 22C5  X = table index
        stx     L_22EF+1                ; 22C6  self-modifying: the LDX # at $22EF+1 that passes the type to the picture routine
        lda     #$64                    ; 22C9  low byte of $C964
        ldy     #$C9                    ; 22CB  high byte: the five 8-byte unit type names in the $C000 overlay
        jsr     printTableEntry         ; 22CD  print entry X of that table
        lda     #$A0                    ; 22D0  space
        jsr     printChar               ; 22D2  separate the name from the number
        lda     selectedUnit            ; 22D5  the unit index 0-99
        ldy     #$00                    ; 22D8  middle byte of the 24-bit value = 0 (zp_7E holds the high byte)
        jsr     printDecimal            ; 22DA  print it in decimal without leading zeros
        jsr     printNewline            ; 22DD  next panel row
        lda     #$E9                    ; 22E0  low byte of $CAE9
        ldy     #$CA                    ; 22E2  high byte: the GROUP / ENERGY / WEAPON / ACTION label block
        jsr     printString             ; 22E4  print the four labels
        jsr     printUnitStatusLines    ; 22E7  fill in the value column beside them
        lda     unitPictureHiTable      ; 22EA  $CD00 = first high byte of the unit picture address table
        bpl     L_22F4                  ; 22ED  not a high address: the $C000 overlay holds no pictures, skip them
L_22EF:
        ldx     #$FF                    ; 22EF  unit type, operand patched at $22C6
        jsr     showUnitPictureSprites  ; 22F1  unpack the picture and show it as sprites 5, 6 and 7
L_22F4:
        jsr     hideSprite1             ; 22F4  the path marker must not sit over the panel
        ldx     selectedUnit            ; 22F7  the unit on display
        lda     unitDestColTable,x      ; 22FA  $F7D0: bit 7 set = the unit has no destination
        bmi     L_2330                  ; 22FD  nowhere to go: skip the marker set-up
        jsr     resetPathMarker         ; 22FF  park the marker on the unit, ready to walk toward the destination

; ----------------------------------------------------------------------
; Panel loop: hold the display while the cursor stays on this unit, reprinting the values only when
; something can actually have changed.
; ----------------------------------------------------------------------
L_2302:
        lda     stepDelayTimer          ; 2302  $90FC: is an engine step due?
        bne     L_2333                  ; 2305  no: only animate the marker and test for input
        lda     selectedUnit            ; 2307  the unit on display
        sta     infoPanelUnit           ; 230A  $91D7 tells the rest of the game which unit the panel is showing
        jsr     updateGameFrame         ; 230D  one full game frame
        jsr     readCellUnderCursor     ; 2310  map byte under the cursor
        bpl     L_234D                  ; 2313  cursor moved onto empty terrain: close the panel
        and     #$7F                    ; 2315  unit index under the cursor
        cmp     selectedUnit            ; 2317  still the same unit?
        bne     L_234D                  ; 231A  a different unit: close the panel
        cmp     currentUnit             ; 231C  $90FA = the unit the engine is stepping right now
        beq     L_2330                  ; 231F  it is moving: reprint its status
        cmp     fireTargetUnit          ; 2321  $91F0 = the unit currently being shot at
        beq     L_2330                  ; 2324  it is under fire: reprint its status
        lda     exchangeAppliedFlag     ; 2326  $920E bit 7 = a lock-step exchange was applied (one turn tick)
        bpl     L_2333                  ; 2329  nothing has changed since the last print
        lda     #$00                    ; 232B  clear
        sta     exchangeAppliedFlag     ; 232D  consume the tick
L_2330:
        jsr     printUnitStatusLines    ; 2330  reprint group, energy, weapon and action
L_2333:
        lda     cursorTargetMode        ; 2333  zp_BB < 0 = free cell pick
        bmi     L_233C                  ; 2335  then always show where the unit is heading
        lda     highlightedGroupKey     ; 2337  $9205 = $FF when no group is highlighted
        bpl     L_233F                  ; 233A  a group is highlighted: no individual destination marker
L_233C:
        jsr     animatePathMarkerToDestination; 233C  crawl the marker from the unit toward its destination
L_233F:
        jsr     checkUserInterrupt      ; 233F  C=1 on stick, button, key or link activity
        bcs     L_234D                  ; 2342  player wants something: close the panel
        lda     gamePhase               ; 2344  battle or unit placement?
        beq     L_2302                  ; 2346  battle: keep the panel up
        jsr     callSetupOverlayHook    ; 2348  placement: let overlay A see the '*' key
        bcc     L_2302                  ; 234B  C=0, nothing happened: keep the panel up

; ----------------------------------------------------------------------
; Leaving the panel: wipe it, drop the marker and the three unit-picture sprites.
; ----------------------------------------------------------------------
L_234D:
        lda     #$FF                    ; 234D  no unit
        sta     infoPanelUnit           ; 234F  $91D7 = the panel is empty again
        lda     #$00                    ; 2352  colour 0
        sta     textColour              ; 2354  zp_3D: clear the panel to black
        jsr     clearInfoPanel          ; 2356  wipe the panel window
        jsr     hideSprite1             ; 2359  path marker sprite off
        lda     spriteEnableShadow      ; 235C  $9022 = shadow of VIC_SPR_ENA
        and     #$1F                    ; 235F  clear bits 5-7: the three unit-picture sprites go away
        sta     spriteEnableShadow      ; 2361  the IRQ writes the shadow to the VIC
        jmp     setFullScreenWindow     ; 2364  text window back to the whole screen, and return through it


; ----------------------------------------------------------------------
; checkUserInterrupt - The 'may this passive display keep running?' test used by the idle loops of the
; info panel and the path preview. It returns C=0 only while the text engine agrees the UI may
; continue (checkUiContinue $C451), no function key is waiting to switch screens, the joystick is
; centred and the fire button is not held. Anything else returns C=1 and freezes stick and keyboard
; for 30 frames so the same push is not consumed twice.
; In:  requestedScreenKey zp_66 ($FF = no function key), joyDirection zp_5D ($0F = centred), joyFireUp
;     zp_5F / fireButtonArmed zp_79 through isFireHeld, plus everything checkUiContinue looks at
;     (filmPlaybackMode, gameEndCountdown, pauseState, gamePhase, lastTypedKey)
; Out: C=0 keep going / C=1 stop; on C=1 inputLockoutTimer $0B7D = $1E (30 frames)
; Called from: the path-preview loop at $229A and the info-panel idle loop at $233F
; ----------------------------------------------------------------------
checkUserInterrupt:
        jsr     checkUiContinue         ; 2367  ask the text engine whether a modal display may still run (A=1 = yes)
        beq     L_237D                  ; 236A  branch if it says stop: game ending, paused, SPACE typed or a screen change pending
        lda     requestedScreenKey      ; 236C  function key latched by the IRQ: 0-3 = F1/F3/F5/F7, $FF = none pending
        bpl     L_237D                  ; 236E  branch if a function key is waiting to switch screens
        lda     joyDirection            ; 2370  joystick 2 direction nibble latched by the IRQ (active low)
        cmp     #$0F                    ; 2372  $0F = no direction pulled low = stick centred
        bne     L_237D                  ; 2374  branch if the player has pushed the stick
        jsr     isFireHeld              ; 2376  level test of the fire button: A=0 while it is held down
        beq     L_237D                  ; 2379  branch if the button is down
        clc                             ; 237B  nothing interrupted: C=0 tells the caller to carry on
        rts                             ; 237C  back to the idle loop


; ----------------------------------------------------------------------
; Something wants attention: stop the display and swallow the input for half a second so the menu the
; caller returns to does not immediately act on the same stick push or key.
; ----------------------------------------------------------------------
L_237D:
        lda     #$1E                    ; 237D  30 frames = about half a second
        sta     inputLockoutTimer       ; 237F  the IRQ ignores stick and keyboard while this counts down
        sec                             ; 2382  C=1 = the caller must tear its display down
        rts                             ; 2383  return to the caller's loop, which exits on C=1


; ----------------------------------------------------------------------
; printUnitStatusLines - Fills in the value column of the unit info panel for selectedUnit ($90F8):
; the group name on row 13, the energy percentage on row 17, the weapon state on row 18, the action on
; row 19 and one flag word (BLITZ / CLOAKED / DUG IN) on row 20, then adds up to two '*' zone markers
; in front of the unit name on row 12. The labels GROUP:/ENERGY:/WEAPON:/ACTION: themselves are
; printed once by showUnitInfoPanel from the string at $CAE9.
; In:  selectedUnit $90F8; the unit arrays unitDisplayFlagsTable $FB54 (bit 0 fired, bit 1 pinned,
;     bits 2-4 group id, bit 6 grouped), unitEnergyTable $F9C4, unitTypeTable $F76C, unitStunTable
;     $F960, unitPathColTable $F898 (waypoint column + cloak bits), unitPathRowTable $F8FC (waypoint
;     row + blitz bits), unitDestColTable $F7D0, unitDestRowTable $F834, unitMovePointsTable $FA28;
;     currentUnit $90FA, gameClock $91CB, markerBlinkTimer zp_6F
; Out: text drawn into the bitmap; textWindowLeft zp_3B / textWindowWidth zp_3C / textCursorCol zp_3F
;     / textCursorRow zp_40 left pointing into the panel, markerBlinkTimer = 20 when FIRED starts,
;     scratch18/scratch19 clobbered
; Called from: showUnitInfoPanel, once when the panel is opened ($22E7) and again on every refresh
;     while the unit is in combat ($2330)
; Set up the narrow value column of the info panel. The panel window itself is columns 25-38; the
; values are printed in columns 31/32 so they line up behind the labels.
; ----------------------------------------------------------------------
printUnitStatusLines:
        lda     #$20                    ; 2384  column 32 = the value column of the panel
        sta     textWindowLeft          ; 2386  text engine window left edge
        lda     #$09                    ; 2388  9 characters wide, i.e. out to the right frame
        sta     textWindowWidth         ; 238A  text engine window width / wrap point
        dec     textWindowLeft          ; 238C  the group name starts one column further left (column 31)
        lda     #$00                    ; 238E  first character of the window
        sta     textCursorCol           ; 2390  cursor column, relative to textWindowLeft
        lda     #$0D                    ; 2392  screen row 13 = the GROUP: line
        jsr     setCursorRow            ; 2394  move the text cursor there (recomputes the bitmap pointers)

; ----------------------------------------------------------------------
; GROUP: line - the group name, or ' - ' when the unit is not in a group.
; ----------------------------------------------------------------------
        ldx     #$00                    ; 2397  string index 0 = the ' - ' filler at $C9A4
        ldy     selectedUnit            ; 2399  the unit the info panel is showing
        lda     unitDisplayFlagsTable,y ; 239C  its group/status flags ($FB54)
        and     #$40                    ; 239F  bit 6 = the unit belongs to a group
        bne     L_23A9                  ; 23A1  branch if it is a group member
        lda     #$A4                    ; 23A3  low byte of the ' - ' string table entry at $C9A4
        ldy     #$C9                    ; 23A5  high byte; entry 0 of that 8-byte-per-entry table is ' - '
        bne     L_23B5                  ; 23A7  always taken
L_23A9:
        lda     unitDisplayFlagsTable,y ; 23A9  re-read the group flags
        and     #$1C                    ; 23AC  bits 2-4 hold the group id
        lsr     a                       ; 23AE  shift the id down...
        lsr     a                       ; 23AF  ...to 0-6 = ALPHA, BAKER, CHARLIE, DELTA, EASY, FOXTROT, GROVER
        tax                             ; 23B0  index into the name table
        lda     #$2C                    ; 23B1  low byte of groupNameTable $C92C (7 names of 8 bytes)
        ldy     #$C9                    ; 23B3  high byte
L_23B5:
        jsr     printTableEntry         ; 23B5  print entry X of the 8-byte-per-entry string table at A/Y

; ----------------------------------------------------------------------
; ENERGY: line - the energy byte is 0-50 and is shown as a percentage (x2).
; ----------------------------------------------------------------------
        inc     textWindowLeft          ; 23B8  value column back to 32 for all the remaining lines
        inc     textCursorRow           ; 23BA  the GROUP: label is followed by three blank rows...
        inc     textCursorRow           ; 23BC  ...row 15...
        inc     textCursorRow           ; 23BE  ...row 16
        jsr     printNewline            ; 23C0  newline lands on row 17 = the ENERGY: line, cursor at column 0
        ldx     selectedUnit            ; 23C3  the selected unit again
        lda     unitEnergyTable,x       ; 23C6  energy byte ($F9C4)
        and     #$3F                    ; 23C9  bits 0-5 = energy 0-50; bits 6-7 are the 'spotted by the enemy' flags
        asl     a                       ; 23CB  x2 gives 0-100 percent
        ldy     #$00                    ; 23CC  high byte of the number to print = 0
        jsr     printDecimal            ; 23CE  print it as decimal digits
        lda     #$A5                    ; 23D1  '%'
        jsr     printChar               ; 23D3  print it
        lda     #$A0                    ; 23D6  trailing space, wipes the third digit of a previous 100%
        jsr     printChar               ; 23D8  print it

; ----------------------------------------------------------------------
; WEAPON: line - READY / RELOAD / FIRED, or ' - ' for a spy, a comcen or a cloaked unit, or STUN'D
; while the unit is stunned. Entries are 8 bytes apart in the table at $C98C: 0 READY, 1 RELOAD, 2
; FIRED, 3 ' - ', 4 REPAIR, 5 MOVE, 6 PINNED, 7 STUN'D.
; ----------------------------------------------------------------------
        jsr     printNewline            ; 23DB  newline to row 18 = the WEAPON: line
        ldx     selectedUnit            ; 23DE  selected unit
        lda     unitPathColTable,x      ; 23E1  waypoint column + cloak bits ($F898; bit 7 = cloaked)
        sta     scratch18               ; 23E4  keep the cloak byte for the test below
        lda     unitTypeTable,x         ; 23E6  type byte ($F76C)
        sta     scratch19               ; 23E9  keep it as well
        lda     unitStunTable,x         ; 23EB  stun / dig-in byte ($F960)
        and     #$0F                    ; 23EE  low nybble = clicks of stun left
        beq     L_23F6                  ; 23F0  branch if the unit is not stunned
        ldx     #$07                    ; 23F2  table entry 7 = STUN'D
        bne     L_2428                  ; 23F4  always taken
L_23F6:
        ldx     #$03                    ; 23F6  table entry 3 = ' - ' (no weapon state to show)
        lda     scratch19               ; 23F8  unit type byte
        and     #$07                    ; 23FA  bits 0-2 = 0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY, 4 COMCEN
        cmp     #$03                    ; 23FC  SPY and COMCEN are types 3 and 4...
        bcs     L_2428                  ; 23FE  ...and carry no weapon, so leave ' - '
        lda     markerBlinkTimer        ; 2400  the 20-frame hold started when FIRED was printed (shared blink timer zp_6F)
        bne     L_2441                  ; 2402  while it runs skip the field entirely so FIRED stays on screen
        lda     scratch18               ; 2404  the cloak byte saved above
        bmi     L_2428                  ; 2406  a cloaked unit cannot fire: show ' - '
        ldx     #$00                    ; 2408  table entry 0 = READY
        ldy     selectedUnit            ; 240A  selected unit
        lda     unitDisplayFlagsTable,y ; 240D  status flags
        and     #$01                    ; 2410  bit 0 = the unit has fired and is reloading
        beq     L_2428                  ; 2412  not fired -> READY
        inx                             ; 2414  table entry 1 = RELOAD
        cpy     currentUnit             ; 2415  is this the unit the simulation is stepping right now?
        bne     L_2428                  ; 2418  no -> just RELOAD
        tya                             ; 241A  unit index
        eor     gameClock               ; 241B  mix it with the game clock...
        and     #$01                    ; 241E  ...and keep bit 0, so only every other click flashes
        bne     L_2428                  ; 2420  off click -> RELOAD
        lda     #$14                    ; 2422  20 frames
        sta     markerBlinkTimer        ; 2424  hold the field so FIRED stays readable
        ldx     #$02                    ; 2426  table entry 2 = FIRED
L_2428:
        lda     #$8C                    ; 2428  low byte of the weapon-word table $C98C
        ldy     #$C9                    ; 242A  high byte
        jsr     printTableEntry         ; 242C  print entry X

; ----------------------------------------------------------------------
; A boomer with a locked target gets a '*' after the weapon word.
; ----------------------------------------------------------------------
        ldx     selectedUnit            ; 242F  selected unit
        jsr     getBoomerTarget         ; 2432  boomers return their target byte, other types a small positive value
        tay                             ; 2435  into Y for the test
        lda     #$A0                    ; 2436  space = nothing to mark
        cpy     #$80                    ; 2438  bit 7 of the target byte = a target is locked
        bcc     L_243E                  ; 243A  no target -> print the space
        lda     #$AA                    ; 243C  '*' = this boomer has a target locked
L_243E:
        jsr     printChar               ; 243E  print the marker

; ----------------------------------------------------------------------
; ACTION: line - ' - ' / REPAIR / MOVE / PINNED / STUN'D / DIGGIN, entries 0-5 of the 8-byte-per-entry
; table at $C9A4.
; ----------------------------------------------------------------------
L_2441:
        jsr     printNewline            ; 2441  newline to row 19 = the ACTION: line
        ldx     #$02                    ; 2444  table entry 2 = MOVE
        ldy     selectedUnit            ; 2446  selected unit
        lda     unitDestColTable,y      ; 2449  ordered destination column ($F7D0); bit 7 = no destination
        bmi     L_2464                  ; 244C  branch if the unit has nowhere to go
        lda     unitDisplayFlagsTable,y ; 244E  status flags
        and     #$02                    ; 2451  bit 1 = the unit was hit and is pinned down
        beq     L_245C                  ; 2453  not pinned -> just check the movement points
        lda     unitMovePointsTable,y   ; 2455  movement points ($FA28)
        cmp     #$0A                    ; 2458  fewer than 10 points left?
        bcc     L_2461                  ; 245A  yes -> PINNED rather than MOVE
L_245C:
        lda     unitMovePointsTable,y   ; 245C  movement points again
        bpl     L_246C                  ; 245F  still positive -> MOVE
L_2461:
        inx                             ; 2461  table entry 3 = PINNED (out of movement)
        bne     L_246C                  ; 2462  always taken
L_2464:
        ldx     #$00                    ; 2464  table entry 0 = ' - ' (nothing to do)
        lda     unitDestRowTable,y      ; 2466  destination row ($F834); bit 7 = the unit is regaining energy
        bpl     L_246C                  ; 2469  not repairing -> leave ' - '
        inx                             ; 246B  table entry 1 = REPAIR
L_246C:
        lda     unitStunTable,y         ; 246C  stun / dig-in byte
        and     #$0F                    ; 246F  low nybble = stun clicks left
        beq     L_2477                  ; 2471  not stunned
        ldx     #$04                    ; 2473  table entry 4 = STUN'D
        bne     L_2482                  ; 2475  always taken
L_2477:
        lda     unitStunTable,y         ; 2477  stun / dig-in byte again
        bmi     L_2482                  ; 247A  bit 7 = already dug in, that is a flag word not an action
        and     #$E0                    ; 247C  bits 5-7 also count the dig-in progress
        beq     L_2482                  ; 247E  not digging in
        ldx     #$05                    ; 2480  table entry 5 = DIGGIN
L_2482:
        lda     #$A4                    ; 2482  low byte of the action-word table $C9A4
        ldy     #$C9                    ; 2484  high byte
        jsr     printTableEntry         ; 2486  print entry X

; ----------------------------------------------------------------------
; Flag line - at most one of BLITZ, CLOAKED or DUG IN, printed further left (column 28) because the
; words are longer than the value column.
; ----------------------------------------------------------------------
        lda     #$1C                    ; 2489  column 28 for the wider flag word
        sta     textWindowLeft          ; 248B  text window left edge
        jsr     printNewline            ; 248D  newline to row 20
        ldx     selectedUnit            ; 2490  selected unit
        lda     unitPathRowTable,x      ; 2493  waypoint row + blitz bits ($F8FC)
        bpl     L_249E                  ; 2496  bit 7 clear = blitz is off
        lda     #$82                    ; 2498  low byte of 'BLITZ' at $CB82
        ldy     #$CB                    ; 249A  high byte
        bne     L_24B2                  ; 249C  always taken
L_249E:
        lda     unitPathColTable,x      ; 249E  waypoint column + cloak bits ($F898)
        bpl     L_24A9                  ; 24A1  bit 7 clear = not cloaked
        lda     #$7B                    ; 24A3  low byte of 'CLOAKED' at $CB7B
        ldy     #$CB                    ; 24A5  high byte
        bne     L_24B2                  ; 24A7  always taken
L_24A9:
        lda     unitStunTable,x         ; 24A9  stun / dig-in byte
        bpl     L_24B5                  ; 24AC  bit 7 clear = not dug in: print no flag word at all
        lda     #$87                    ; 24AE  low byte of 'DUG IN' at $CB87
        ldy     #$CB                    ; 24B0  high byte
L_24B2:
        jsr     printString             ; 24B2  print the bit-7 terminated string at A/Y
L_24B5:
        jsr     bootWaitClkAck          ; 24B5  wipe whatever the previously shown unit left below this line  (also textEngineC000:clearToWindowBottom)

; ----------------------------------------------------------------------
; Zone markers - unitTypeTable bits 3-4 record which half of the enemy ground this unit has already
; scored (bit 3 = the near zone, columns 10-19; bit 4 = the far end zone, columns 0-9). One '*' is
; printed in front of the unit name for either, a second one for the far zone.
; ----------------------------------------------------------------------
        lda     #$20                    ; 24B8  value column back to 32
        sta     textWindowLeft          ; 24BA  text window left edge
        ldx     selectedUnit            ; 24BC  selected unit
        lda     unitTypeTable,x         ; 24BF  type byte
        and     #$18                    ; 24C2  bits 3-4 = zones this unit has already scored
        beq     L_24E0                  ; 24C4  no marker to print
        pha                             ; 24C6  keep the two bits over the window change
        jsr     setInfoPanelWindow      ; 24C7  back to the full panel window (columns 25-38); leaves A = $0C
        ldy     #$00                    ; 24CA  column 0 of the window = screen column 25
        sty     textCursorCol           ; 24CC  cursor column
        jsr     setCursorRow            ; 24CE  A is still the $0C left by setInfoPanelWindow, so this lands on row 12 = the unit name line
        lda     #$AA                    ; 24D1  '*'
        jsr     printChar               ; 24D3  print it in front of the name
        pla                             ; 24D6  get the zone bits back
        and     #$10                    ; 24D7  bit 4 = the far, double-value end zone
        beq     L_24E0                  ; 24D9  near zone only -> one marker
        lda     #$AA                    ; 24DB  a second '*'
        jsr     printChar               ; 24DD  print it
L_24E0:
        rts                             ; 24E0  done: the caller redraws the sprites


; pathMarkerState - three RAM bytes living inside the code, the whole state of the animated path
; marker (sprite 1) that walks from the selected unit to its ordered destination on the mini-map:
; passes still to run, and the map cell the marker currently occupies.
pathMarkerState:
        .byte   $05                     ; 24E1  .  passes still to walk before the marker jumps back to the unit (reloaded with 5)
pathMarkerCol:
        .byte   $00                     ; 24E2  map column of the marker
pathMarkerRow:
        .byte   $00                     ; 24E3  map row of the marker

; ----------------------------------------------------------------------
; animatePathMarkerToDestination - Sets the line-stepper target to the ordered destination of
; selectedUnit and falls into animatePathMarkerStep, so the mini-map marker crawls from the unit
; towards where it has been sent. A unit with no destination just gets sprite 1 switched off.
; In:  selectedUnit $90F8, unitDestColTable $F7D0 (bit 7 set = no destination), unitDestRowTable $F834
; Out: lineEndCol zp_95 / lineEndRow zp_94 = the destination cell, then everything
;     animatePathMarkerStep does; sprite 1 hidden when there is no destination
; Called from: the info-panel idle loop ($233C) and the unit menu loops at $6065 and $6214
; ----------------------------------------------------------------------
animatePathMarkerToDestination:
        ldx     selectedUnit            ; 24E4  the unit the panel is showing
        lda     unitDestColTable,x      ; 24E7  its ordered destination column; bit 7 = no destination
        bmi     L_253C                  ; 24EA  nothing ordered -> just hide the marker
        sta     lineEndCol              ; 24EC  line stepper target column
        lda     unitDestRowTable,x      ; 24EE  destination row
        sta     lineEndRow              ; 24F1  line stepper target row

; ----------------------------------------------------------------------
; animatePathMarkerStep - Advances the mini-map path marker by one cell every 4 frames, but only after
; the player has been idle for 90 frames. Each step walks one cell of the Bresenham line from
; pathMarkerCol/Row towards lineEndCol/Row and moves sprite 1 there; at the end of the line the pass
; counter is decremented and the sprite is blinked, and when the counter runs past zero
; resetPathMarker puts the marker back on the unit.
; In:  inputIdleFrames $90EE, joyRepeatTimer zp_6C (used here as the step delay), pathMarkerState
;     $24E1, pathMarkerCol/Row $24E2/$24E3, target lineEndCol zp_95 / lineEndRow zp_94
; Out: pathMarkerCol/Row advanced, sprite 1 position shadows $9011/$9019 and enable bit 1 of $9022,
;     joyRepeatTimer = 4, line stepper state zp_8C-zp_97
; Called from: the path-preview loop at $2297, and by fall-through from animatePathMarkerToDestination
; ----------------------------------------------------------------------
animatePathMarkerStep:
        lda     inputIdleFrames         ; 24F3  frames since the player last moved the stick or pressed a key
        cmp     #$5A                    ; 24F6  90 frames = 1.5 seconds of doing nothing
        bcs     L_24FD                  ; 24F8  idle long enough: run the animation
        jmp     L_253C                  ; 24FA  player is active -> no marker on the map

L_24FD:
        lda     joyRepeatTimer          ; 24FD  shared frame countdown, used here as the delay between marker steps
        beq     L_2504                  ; 24FF  expired: time for the next cell
        jmp     L_253F                  ; 2501  still counting: leave the marker where it stands

L_2504:
        lda     #$04                    ; 2504  4 frames per cell
        sta     joyRepeatTimer          ; 2506  reload the step delay
        lda     pathMarkerCol           ; 2508  marker column
        sta     lineCurCol              ; 250B  line stepper current column = where the marker stands
        lda     pathMarkerRow           ; 250D  marker row
        sta     lineCurRow              ; 2510  line stepper current row
        jsr     initLineStepper         ; 2512  set up the walk to the target cell; A = number of steps
        beq     L_252B                  ; 2515  zero steps (marker already on the target) -> count this as a finished pass
        jsr     stepLine                ; 2517  advance one cell along the line
        ldy     lineCurRow              ; 251A  new row
        sty     pathMarkerRow           ; 251C  remember it
        ldx     lineCurCol              ; 251F  new column
        stx     pathMarkerCol           ; 2521  remember it
        jsr     positionMarkerSprite    ; 2524  put sprite 1 on that mini-map cell
        lda     lineStepsLeft           ; 2527  steps still to walk
        bne     L_2537                  ; 2529  more to come -> keep the marker visible

; ----------------------------------------------------------------------
; End of one pass over the line: blink the marker by toggling sprite 1, and after six passes (the
; counter starts at 5 and is reset by resetPathMarker) start over at the unit.
; ----------------------------------------------------------------------
L_252B:
        dec     pathMarkerState         ; 252B  one pass of the line finished
        bmi     resetPathMarker         ; 252E  counter ran past zero -> reload the marker onto the unit
        lda     spriteEnableShadow      ; 2530  sprite enable shadow
        and     #$02                    ; 2533  bit 1 = sprite 1, the marker
        bne     L_253C                  ; 2535  currently visible -> hide it, which makes the line end blink
L_2537:
        lda     #$02                    ; 2537  sprite 1 bit
        jmp     enableSprites           ; 2539  show the marker (and return)

L_253C:
        jmp     hideSprite1             ; 253C  no marker wanted: switch sprite 1 off

L_253F:
        rts                             ; 253F  nothing to do this frame


; ----------------------------------------------------------------------
; resetPathMarker - Puts the path marker back at the start: sprite 1 off, marker cell and pass counter
; back to the selected unit, and sprite 1's shape reloaded. Normally that is the small grey block;
; when the mini-map is showing the zoomed group box ($920A bit 7) the marker instead copies sprite 0's
; cursor box so it matches the enlarged cells.
; In:  selectedUnit $90F8, groupBoxActive $920A bit 7, sprite 0 shape at $8A00
; Out: sprite 1 shape at $8A40 and colour shadow $9009, activeSpriteIndex $91CC left 0,
;     pathMarkerState/Col/Row reloaded, sprite 1 enabled in the zoomed case
; Called from: the info-panel entry and idle loops ($227B, $22FF), the unit menu at $5F18, and from
;     animatePathMarkerStep when the pass counter runs out ($252E)
; ----------------------------------------------------------------------
resetPathMarker:
        jsr     hideSprite1             ; 2540  marker off while its shape is swapped
        jsr     resetPathMarkerToUnit   ; 2543  pass counter back to 5 and the marker back onto the unit
        lda     groupBoxActive          ; 2546  bit 7 set = the mini-map is zoomed onto the group box
        bmi     L_2559                  ; 2549  zoomed: use a copy of the cursor box instead
        lda     #$01                    ; 254B  sprite 1
        sta     activeSpriteIndex       ; 254D  tell setCursorShapeBlock which sprite to load
        jsr     setCursorShapeBlock     ; 2550  load the small solid block shape ($9E92) and colour it grey
        lda     #$00                    ; 2553  sprite 0
        sta     activeSpriteIndex       ; 2555  back to the default sprite for the cursor routines
        rts                             ; 2558  done


; ----------------------------------------------------------------------
; Zoomed mini-map: the marker has to be as large as the cursor box, so sprite 1 simply gets a copy of
; sprite 0's current shape.
; ----------------------------------------------------------------------
L_2559:
        ldy     #$3F                    ; 2559  64 shape bytes, last one first
L_255B:
        lda     sprite0Shape,y          ; 255B  byte of the sprite 0 cursor box ($8A00)
        sta     sprite1Shape,y          ; 255E  into the sprite 1 shape ($8A40)
        dey                             ; 2561  next byte down
        bpl     L_255B                  ; 2562  until all 64 are copied
        lda     #$02                    ; 2564  sprite 1 bit
        jsr     enableSprites           ; 2566  make the marker visible
        lda     #$0C                    ; 2569  grey
        sta     sprite1ColourShadow     ; 256B  sprite 1 colour shadow, copied to $D028 by the IRQ
        rts                             ; 256E  done


; ----------------------------------------------------------------------
; resetPathMarkerToUnit - Reloads the marker's pass counter with 5 and moves the marker cell onto the
; selected unit's map position, then falls into positionMarkerSprite to place sprite 1 there.
; In:  selectedUnit $90F8, unitColTable $F640, unitRowTable $F6A4
; Out: pathMarkerState $24E1 = 5, pathMarkerCol/Row $24E2/$24E3, sprite 1 position shadows; X =
;     column, Y = row
; Called from: the path-preview entry at $225D and resetPathMarker ($2543)
; ----------------------------------------------------------------------
resetPathMarkerToUnit:
        lda     #$05                    ; 256F  five more passes over the line
        sta     pathMarkerState         ; 2571  reload the pass counter
        ldy     selectedUnit            ; 2574  the selected unit
        lda     unitColTable,y          ; 2577  its map column
        sta     pathMarkerCol           ; 257A  marker column
        tax                             ; 257D  X = column for positionMarkerSprite
        lda     unitRowTable,y          ; 257E  its map row
        sta     pathMarkerRow           ; 2581  marker row
        tay                             ; 2584  Y = row for positionMarkerSprite

; ----------------------------------------------------------------------
; positionMarkerSprite - Converts a map cell to mini-map sprite coordinates (column*4+$1F, row*4+$39,
; or the group-box mapping when the mini-map is zoomed) and stores them in sprite 1's position
; shadows, which the raster IRQ copies to the VIC.
; In:  X = map column, Y = map row
; Out: sprite1XShadow $9011, sprite1YShadow $9019
; Called from: animatePathMarkerStep ($2524) and by fall-through from resetPathMarkerToUnit
; ----------------------------------------------------------------------
positionMarkerSprite:
        jsr     mapCellToSpriteXY       ; 2585  cell -> sprite coordinates on the 40x40 mini-map (4 pixels per cell)
        stx     sprite1XShadow          ; 2588  sprite 1 X shadow
        sty     sprite1YShadow          ; 258B  sprite 1 Y shadow
        rts                             ; 258E  done


; ----------------------------------------------------------------------
; recenterViewportOnUnit - Makes sure the selected unit sits under the battlefield cursor. The cursor
; is always the centre cell of the 7x5 view (viewOriginCol+3, viewOriginRow+2); if the unit is
; somewhere else the view is scrolled to it one cell per frame by scrollViewToUnit.
; In:  selectedUnit $90F8, viewOriginCol zp_A5 / viewOriginRow zp_A6, unitColTable $F640 /
;     unitRowTable $F6A4
; Out: lineCurCol zp_93 / lineCurRow zp_92 = the cursor cell (the scroll starts there); the view
;     scrolled and redrawn
; Called from: the info-panel entry at $2239 and the unit selection code at $4BF6 and $4C4C
; ----------------------------------------------------------------------
recenterViewportOnUnit:
        ldx     selectedUnit            ; 258F  the unit that must end up under the cursor
        lda     #$03                    ; 2592  the cursor sits 3 cells right of the view origin
        clc                             ; 2594  plain add
        adc     viewOriginCol           ; 2595  map column of the left edge of the view
        sta     lineCurCol              ; 2597  cursor column, also the line stepper start
        lda     #$02                    ; 2599  and 2 cells below the top edge
        clc                             ; 259B  plain add
        adc     viewOriginRow           ; 259C  map row of the top edge of the view
        sta     lineCurRow              ; 259E  cursor row
        lda     lineCurCol              ; 25A0  cursor column
        cmp     unitColTable,x          ; 25A2  same column as the unit?
        bne     L_25AE                  ; 25A5  no -> scroll
        lda     lineCurRow              ; 25A7  cursor row
        cmp     unitRowTable,x          ; 25A9  same row as the unit?
        beq     L_25B6                  ; 25AC  the unit is already under the cursor: nothing to do
L_25AE:
        lda     #$01                    ; 25AE  1 frame per scroll step, i.e. animated
        ldx     selectedUnit            ; 25B0  the unit to scroll to
        jsr     scrollViewToUnit        ; 25B3  walk the view over to it cell by cell
L_25B6:
        rts                             ; 25B6  done


; ----------------------------------------------------------------------
; consumeSpaceKey - Eats a pending SPACE. The IRQ leaves the last printable key in lastTypedKey
; ($9248) and several loops close on SPACE, so a screen that was itself closed with SPACE clears the
; key before returning, otherwise the next loop would close immediately as well.
; In:  lastTypedKey $9248
; Out: lastTypedKey = $FF (no key) when it held $A0 (space)
; Called from: $1863 and $2073
; ----------------------------------------------------------------------
consumeSpaceKey:
        lda     lastTypedKey            ; 25B7  last printable key latched by the IRQ
        cmp     #$A0                    ; 25BA  $A0 = space (PETSCII with bit 7 set)
        bne     L_25C3                  ; 25BC  some other key: leave it for the caller
        lda     #$FF                    ; 25BE  'no key pending'
        sta     lastTypedKey            ; 25C0  swallow the space so the next loop does not act on it
L_25C3:
        rts                             ; 25C3  done


; ----------------------------------------------------------------------
; callSetupOverlayHook - Trampoline into overlay variant A at $8658 (handleStarKeyToggle), the
; per-frame '*' key handler of the non-battle phases: it toggles setupToggleFlag $92F3 bit 7 (DESTROY
; mode during unit placement, CUSTOM rules on the setup screen) and reports C=1 when it consumed the
; key. It may only be called while gamePhase zp_B1 is non-zero, because in overlay variant B address
; $8658 is in the middle of an instruction.
; In:  gamePhase zp_B1 must be non-zero (overlay A resident); the hook itself reads chatState $929C
;     and lastTypedKey $9248
; Out: C = 1 when '*' was pressed and the toggle changed, C = 0 otherwise
; Called from: the map main loop at $2026 and the info-panel loop at $2348
; ----------------------------------------------------------------------
callSetupOverlayHook:
        jmp     handleStarKeyToggle     ; 25C4  into overlay A: '*' toggles DESTROY / CUSTOM, C=1 when it acted


; ----------------------------------------------------------------------
; getViewportOriginPointer - Builds a map pointer for the top-left cell of the 7x5 battlefield view.
; The view origin may be off the map (it is clamped only for the cursor cell), so a negative row is
; handled by taking row 0 and stepping the pointer back 40 bytes per row, and a negative column by the
; sign-extended add in getViewportRowPointer.
; In:  viewOriginCol zp_A5, viewOriginRow zp_A6, the map row address tables $BFB0/$BFD8
; Out: mapCellPtr zp_48/zp_49 -> the origin cell (may point in front of $F000 when the view hangs off
;     the map), A = that cell's byte only when the origin is really on the map, Y = 0
; Called from: drawViewport ($26D1) and the $6F00 overlays ($71D4, $71F9, $740D, $7430, $7BCC)
; ----------------------------------------------------------------------
getViewportOriginPointer:
        ldy     viewOriginRow           ; 25C7  map row of the top edge of the view
        bpl     getViewportRowPointer   ; 25C9  row is on the map: ordinary row lookup
        ldy     #$00                    ; 25CB  the view hangs off the top of the map: start from row 0
        jsr     getViewportRowPointer   ; 25CD  pointer to (viewOriginCol, row 0)
        ldy     viewOriginRow           ; 25D0  the negative row count, e.g. $FE = two rows above the map

; ----------------------------------------------------------------------
; Step the pointer back one map row (40 cells) per row above the map, counting Y up to 0.
; ----------------------------------------------------------------------
L_25D2:
        lda     mapCellPtr              ; 25D2  pointer low byte
        sec                             ; 25D4  no borrow
        sbc     #$28                    ; 25D5  one map row = 40 cells
        sta     mapCellPtr              ; 25D7  store it back
        lda     mapCellPtrHi            ; 25D9  pointer high byte
        sbc     #$00                    ; 25DB  propagate the borrow
        sta     mapCellPtrHi            ; 25DD  store it back
        iny                             ; 25DF  one row less to go
        bne     L_25D2                  ; 25E0  until the negative row count reaches 0
        rts                             ; 25E2  done


; ----------------------------------------------------------------------
; getViewportRowPointer - Map pointer for the cell (viewOriginCol, Y). A negative view column is
; handled by reading column 0 and then adding the signed offset to the pointer, so the caller gets a
; pointer that is correct even when the view sticks out over the left edge of the map.
; In:  Y = map row 0-39, viewOriginCol zp_A5
; Out: mapCellPtr zp_48/zp_49, A = the cell byte when the column is on the map, Y = 0
; Called from: getViewportOriginPointer ($25CD and the branch at $25C9)
; ----------------------------------------------------------------------
getViewportRowPointer:
        ldx     viewOriginCol           ; 25E3  map column of the left edge of the view
        bpl     readMapCell             ; 25E5  column is on the map: plain lookup, and its result is our result
        ldx     #$00                    ; 25E7  the view hangs off the left edge: read column 0 instead
        jsr     readMapCell             ; 25E9  pointer to (column 0, row Y)
        lda     mapCellPtr              ; 25EC  pointer low byte
        clc                             ; 25EE  plain add
        adc     viewOriginCol           ; 25EF  add the negative column offset...
        sta     mapCellPtr              ; 25F1  store it back
        lda     mapCellPtrHi            ; 25F3  pointer high byte
        adc     #$FF                    ; 25F5  ...sign extended: -1 in the high byte
        sta     mapCellPtrHi            ; 25F7  store it back
        rts                             ; 25F9  done


; ----------------------------------------------------------------------
; readMapCell - The map accessor of the whole game: forms mapCellPtr = rowAddress[Y] + X from the
; 40-entry row address tables at $BFB0/$BFD8 (built by the start-up code at $0339 as $F000 + 40*row)
; and returns the byte of cell (column X, row Y) of the 40x40 battlefield.
; In:  X = map column 0-39, Y = map row 0-39; row address tables $BFB0 (lo) / $BFD8 (hi)
; Out: A = the map byte (bit 7 set: bits 0-6 are the index of the unit standing there and the covered
;     terrain is in $FAF0+unit; otherwise a terrain code, $40 = open ground), mapCellPtr zp_48/zp_49
;     -> that cell, Y = 0
; Called from: about 30 places - the main program ($1F16, $1F3F, $25E9, $36FB, $37D7, $3C2C, $3E06,
;     $3E38, $3ECD, $3F3C, $42E7, $4571, $476A, $5400, $2619), both $6F00 overlays and the solo
;     trainer ($E97B, $E9B2)
; ----------------------------------------------------------------------
readMapCell:
        txa                             ; 25FA  map column
        clc                             ; 25FB  no carry into the add
        adc     mapRowAddrLoTable,y     ; 25FC  + low byte of the address of map row Y ($F000 + 40*row)
        sta     mapCellPtr              ; 25FF  map pointer low byte
        lda     #$00                    ; 2601  only the carry goes into the high byte
        adc     mapRowAddrHiTable,y     ; 2603  + high byte of the row address
        sta     mapCellPtrHi            ; 2606  map pointer high byte
        ldy     #$00                    ; 2608  index 0 for the indirect read
        lda     (mapCellPtr),y          ; 260A  the cell byte: bit 7 = a unit stands here, else a terrain code
        rts                             ; 260C  done


; ----------------------------------------------------------------------
; readCellUnderCursor - Reads the map cell under the battlefield cursor. The cursor never moves: it is
; always the centre cell of the 7x5 view, i.e. (viewOriginCol+3, viewOriginRow+2); the joystick
; scrolls the view instead.
; In:  viewOriginCol zp_A5, viewOriginRow zp_A6
; Out: A = the map byte, mapCellPtr zp_48/zp_49 -> that cell, X = cursor column, Y = 0
; Called from: the map main loop ($2049, $207B), the info-panel loop ($2310) and the console at $63D9
; ----------------------------------------------------------------------
readCellUnderCursor:
        lda     viewOriginCol           ; 260D  map column of the left edge of the view
        clc                             ; 260F  plain add
        adc     #$03                    ; 2610  the cursor is the 4th of the 7 visible columns
        tax                             ; 2612  X = cursor column
        lda     viewOriginRow           ; 2613  map row of the top edge of the view
        clc                             ; 2615  plain add
        adc     #$02                    ; 2616  the cursor is the 3rd of the 5 visible rows
        tay                             ; 2618  Y = cursor row
        jmp     readMapCell             ; 2619  read that cell and return its byte


; ----------------------------------------------------------------------
; clearInfoPanel - Selects the unit info panel as the text window and blanks it with the text engine's
; window clear, using the current textColour zp_3D and fill glyph zp_3E.
; In:  textColour zp_3D, textFillGlyph zp_3E
; Out: the text window set to rows 12-20 / columns 25-38 and cleared; text cursor zp_3F/zp_40 homed
; Called from: 20 places - the manual check ($09EC/$0A44), the map loop ($2004, $22B3, $2356),
;     redrawGameScreen ($267C), the screen switcher ($2DEE), the unit and console menus ($4BBF, $4C3D,
;     $5E49, $5E9F, $6151, $6201, $6245, $6374, $6441, $64EE, $65FF) and overlay A
; ----------------------------------------------------------------------
clearInfoPanel:
        jsr     setInfoPanelWindow      ; 261C  rows 12-20, columns 25-38
        jmp     bootDriveDataBit        ; 261F  fill the window with textFillGlyph and return  (also textEngineC000:clearTextWindow)


; ----------------------------------------------------------------------
; setInfoPanelWindow - Defines the text window that covers the unit info panel: columns 25-38, rows 12
; to 20 (row 21 is the exclusive limit). It resets the window to full screen first so that leftovers
; of a narrower window do not survive. It exits with A = $0C, which printUnitStatusLines relies on to
; reach row 12.
; In:  -
; Out: textWindowTop zp_39 = $0C, textWindowBottom zp_3A = $15, textWindowLeft zp_3B = $19,
;     textWindowWidth zp_3C = $0E; A = $0C
; Called from: clearInfoPanel ($261C) and printUnitStatusLines ($24C7)
; ----------------------------------------------------------------------
setInfoPanelWindow:
        jsr     setFullScreenWindow     ; 2622  start from a full-screen window
        lda     #$19                    ; 2625  screen column 25
        sta     textWindowLeft          ; 2627  window left edge
        lda     #$0E                    ; 2629  14 columns -> columns 25-38
        sta     textWindowWidth         ; 262B  window width / wrap point
        lda     #$15                    ; 262D  row 21 = the exclusive bottom limit
        sta     textWindowBottom        ; 262F  window bottom
        lda     #$0C                    ; 2631  top row 12
        sta     textWindowTop           ; 2633  window top; A stays $0C for the caller
        rts                             ; 2635  done


; ----------------------------------------------------------------------
; clearScreenAndRedrawGameScreen - Rebuilds the battlefield display from scratch: sets the VIC up for
; the multicolour bitmap and wipes it grey, then falls into redrawGameScreen.
; In:  -
; Out: the whole screen, see initBitmapScreen and redrawGameScreen
; Called from: start-up ($0A95) and overlay A ($7D59, $8232, $8559)
; ----------------------------------------------------------------------
clearScreenAndRedrawGameScreen:
        jsr     initBitmapScreen        ; 2636  VIC into multicolour bitmap mode, bitmap and colour RAM cleared to grey

; ----------------------------------------------------------------------
; redrawGameScreen - Draws the whole battlefield screen: the three panel frames (mini-map, tactical
; view, info panel), the bottom status bar, the 40x40 overview map, the 7x5 tactical view and finally
; the current status message. gamePhase zp_B1 gates the map: bit 7 (menu phases) leaves the whole map
; area empty, bit 6 (unit setup) draws the overview but not the tactical view.
; In:  gamePhase zp_B1, currentScreen $90FB (via drawMainWindowFrame), the map at $F000 and the unit
;     arrays, shownMessageId zp_7B
; Out: vicCtrl2Shadow $9001 = $D8 (multicolour), vicBg0Shadow $9004 = 7, cellBackgroundColour $9226 =
;     0, the glyph plotter parameters $2A29-$2A2C = 0, textColour zp_3D, the whole screen redrawn
; Called from: $1FCD (the battlefield screen entry), and by fall-through from
;     clearScreenAndRedrawGameScreen
; ----------------------------------------------------------------------
redrawGameScreen:
        jsr     patchUnitSideBranches   ; 2639  re-patch the own/enemy branch in renderMapTile for the current side and replay viewpoint
        lda     #$D8                    ; 263C  $D016: multicolour on (bit 4), 40 columns (bit 3), no fine scroll
        sta     vicCtrl2Shadow          ; 263E  the raster IRQ copies this shadow to $D016
        lda     #$0C                    ; 2641  grey
        sta     textColour              ; 2643  text colour used for the frames
        jsr     setFullScreenWindow     ; 2645  text window = the whole screen
        jsr     bootDriveDataBit        ; 2648  blank the entire bitmap through the text engine  (also textEngineC000:clearTextWindow)
        lda     #$00                    ; 264B  reset the glyph plotter parameter block...
        sta     useDrawColourFlag       ; 264D  ...no forced colour...
        sta     drawColourByte          ; 2650  ...colour byte 0...
        sta     blockFillFlag           ; 2653  ...no block fill...
        sta     blockFillChar           ; 2656  ...and fill character 0
        lda     #$07                    ; 2659  yellow
        sta     vicBg0Shadow            ; 265B  $D021 background = the colour of the %00 pixels of the map tiles
        lda     #$00                    ; 265E  black
        sta     cellBackgroundColour    ; 2660  background colour used behind drawn character cells
        lda     #$00                    ; 2663  redundant: the block-fill flag was already cleared at $2653
        sta     blockFillFlag           ; 2665  (leftover store, no effect)
        jsr     drawMiniMapFrame        ; 2668  22x22 frame at column 0 around the strategic map
        jsr     drawViewportFrame       ; 266B  16x12 frame at column 24 around the tactical view; also clears its interior
        jsr     drawInfoPanelFrame      ; 266E  16x11 frame at column 24 row 11 around the unit info panel
        jsr     drawMainWindowFrame     ; 2671  the 38x3 status bar across the bottom
        lda     gamePhase               ; 2674  0 during the battle, non-zero during setup and in the menus
        bpl     L_2682                  ; 2676  bit 7 clear -> the battlefield may be drawn
        lda     #$00                    ; 2678  colour 0
        sta     textColour              ; 267A  text colour for the clear
        jsr     clearInfoPanel          ; 267C  menu phases: leave the info panel blank
        jmp     L_268C                  ; 267F  and skip both map drawings

L_2682:
        jsr     drawOverviewMap         ; 2682  the whole 40x40 map at 4x4 pixels per cell
        bit     gamePhase               ; 2685  test gamePhase again
        bvs     L_268C                  ; 2687  bit 6 set (unit placement) -> no tactical view
        jsr     drawViewport            ; 2689  the 7x5 cell tactical view
L_268C:
        jmp     redrawStatusMessage     ; 268C  put the current status message back on the bottom bar


; ----------------------------------------------------------------------
; drawMiniMapFrame - Draws window 0: the 22x22 character frame at column 0, row 0 that surrounds the
; 20x20 strategic mini-map.
; In:  -
; Out: textColour zp_3D = $0C, the frame on screen, the window geometry left in
;     $2A2D/$2A2E/$9221/$9222 by drawWindowFrame
; Called from: redrawGameScreen ($2668)
; ----------------------------------------------------------------------
drawMiniMapFrame:
        lda     #$0C                    ; 268F  grey
        sta     textColour              ; 2691  colour of the frame characters
        ldx     #$00                    ; 2693  window 0 = column 0, row 0, 22x22
        jmp     drawWindowFrame         ; 2695  draw it and return


; ----------------------------------------------------------------------
; drawViewportFrame - Draws window 1: the 16x12 character frame at column 24, row 0 around the
; tactical view, then falls into clearViewportTextArea to blank the interior.
; In:  -
; Out: textColour zp_3D, the frame drawn and the 14x10 interior cleared
; Called from: redrawGameScreen ($266B)
; ----------------------------------------------------------------------
drawViewportFrame:
        lda     #$0C                    ; 2698  grey
        sta     textColour              ; 269A  colour of the frame characters
        ldx     #$01                    ; 269C  window 1 = column 24, row 0, 16x12
        jsr     drawWindowFrame         ; 269E  draw the frame

; ----------------------------------------------------------------------
; clearViewportTextArea - Declares the interior of the tactical view (rows 1-10, columns 25-38, i.e.
; the 7x5 map cells at 2x2 characters each) as the text window and clears it in colour 0.
; In:  -
; Out: textWindowTop zp_39 = 1, textWindowBottom zp_3A = $0B, textWindowLeft zp_3B = $19,
;     textWindowWidth zp_3C = $0E, textColour zp_3D = 0; the area blanked
; Called from: the screen-switch clear at $2DF4, overlay A ($8425), and by fall-through from
;     drawViewportFrame
; ----------------------------------------------------------------------
clearViewportTextArea:
        lda     #$01                    ; 26A1  first character row of the view
        sta     textWindowTop           ; 26A3  window top
        lda     #$19                    ; 26A5  screen column 25
        sta     textWindowLeft          ; 26A7  window left edge
        lda     #$0E                    ; 26A9  14 columns = 7 map cells
        sta     textWindowWidth         ; 26AB  window width
        lda     #$0B                    ; 26AD  row 11 = exclusive limit, so rows 1-10 = 5 map rows
        sta     textWindowBottom        ; 26AF  window bottom
        lda     #$00                    ; 26B1  colour 0
        sta     textColour              ; 26B3  text colour for the clear
        jmp     bootDriveDataBit        ; 26B5  blank the area and return  (also textEngineC000:clearTextWindow)


; ----------------------------------------------------------------------
; drawInfoPanelFrame - Draws window 2: the 16x11 character frame at column 24, row 11 around the unit
; info panel.
; In:  -
; Out: textColour zp_3D = $0C, the frame on screen
; Called from: redrawGameScreen ($266E)
; ----------------------------------------------------------------------
drawInfoPanelFrame:
        lda     #$0C                    ; 26B8  grey
        sta     textColour              ; 26BA  colour of the frame characters
        ldx     #$02                    ; 26BC  window 2 = column 24, row 11, 16x11
        jmp     drawWindowFrame         ; 26BE  draw it and return


; ----------------------------------------------------------------------
; drawViewport - Redraws the tactical view: 5 rows of 7 map cells, each cell a 16x16 pixel multicolour
; tile occupying 2x2 character cells, into character rows 1-10 and columns 25-38. It walks the map
; from the view origin, has renderMapTile build each tile in the buffer at $04C8-$04E7 and then copies
; the 32 bytes into the bitmap - 16 bytes to the upper character pair and 16 to the pair one character
; row (320 bytes) lower, through a store whose address is self-modified. The view origin and the text
; plotter's scratch bytes are saved and restored, because the loop walks the origin to tell
; renderMapTile which map cell it is drawing.
; In:  viewOriginCol zp_A5 / viewOriginRow zp_A6, the map at $F000 and the unit arrays, gamePhase
;     zp_B1 bit 6 (suppresses units)
; Out: bitmap, screen RAM and colour RAM of the 14x10 character area; scanResultCount zp_2F and
;     magnifySrcCol zp_31 used as row/column counters, mapCellPtr zp_48/zp_49, bitmapPtr
;     zp_B4-screenPtrHi zp_B7, $2A2D/$2A2E, drawCellSaveCol/Row $90F6/$90F7; self-modifies the store
;     at tileCopyStore ($2703)
; Called from: everywhere the battlefield changes - the map main loop ($1FA2, $200F), redrawGameScreen
;     ($2689), the tick loop ($4023, $413A, $4152), the unit menus ($4BDE, $5E38, $5E7F, $605F, $61F8,
;     $63A4, $636B, $64BB, $64CE), overlay A ($83F5), the screen switcher ($075B) and the text engine
;     ($C797)
; ----------------------------------------------------------------------
drawViewport:
        lda     scratch18               ; 26C1  the text plotter's row byte...
        pha                             ; 26C3  ...is borrowed as the character row counter, so save it
        lda     scratch19               ; 26C4  the text plotter's column byte...
        pha                             ; 26C6  ...is borrowed as the character column counter
        lda     viewOriginCol           ; 26C7  map column of the view origin
        sta     drawCellSaveCol         ; 26C9  saved: the loop walks the origin cell by cell
        lda     viewOriginRow           ; 26CC  map row of the view origin
        sta     drawCellSaveRow         ; 26CE  saved as well
        jsr     getViewportOriginPointer; 26D1  mapCellPtr -> the map cell at the top-left corner of the view
        lda     #$01                    ; 26D4  character row 1 = the first row inside the frame
        sta     windowOriginRow         ; 26D6  window origin row used by the colour writes
        sta     scratch18               ; 26D9  row for calcCellPointers
        lda     #$00                    ; 26DB  start at the top
        sta     scanResultCount         ; 26DD  viewport row counter 0-4

; ----------------------------------------------------------------------
; Outer loop: one row of 7 map cells = two character rows.
; ----------------------------------------------------------------------
L_26DF:
        lda     #$19                    ; 26DF  character column 25 = the first column inside the frame
        sta     windowOriginCol         ; 26E1  window origin column
        sta     scratch19               ; 26E4  column for calcCellPointers
        lda     #$00                    ; 26E6  start at the left
        sta     magnifySrcCol           ; 26E8  viewport column counter 0-6

; ----------------------------------------------------------------------
; Inner loop: one map cell = one 16x16 tile = 2x2 characters.
; ----------------------------------------------------------------------
L_26EA:
        jsr     calcCellPointers        ; 26EA  bitmapPtr zp_B4/B5 and screenPtr zp_B6/B7 for character (row,column)
        ldy     magnifySrcCol           ; 26ED  offset of this cell inside the map row
        lda     (mapCellPtr),y          ; 26EF  the map byte of the cell
        jsr     renderMapTile           ; 26F1  build its 32-byte tile in $04C8 and write the cell colours
        lda     bitmapPtr               ; 26F4  bitmap address of the upper-left character
        sta     tileCopyStore+1         ; 26F6  patch the destination of the copy store...
        lda     bitmapPtrHi             ; 26F9  ...
        sta     tileCopyStore+2         ; 26FB  ...so it writes into this cell
        ldy     #$00                    ; 26FE  start at the first tile byte

; ----------------------------------------------------------------------
; Copy the tile buffer into the bitmap: bytes $00-$0F are the two upper characters (which are adjacent
; in a C64 bitmap), bytes $10-$1F the two characters one character row lower.
; ----------------------------------------------------------------------
L_2700:
        lda     play4Row12,y            ; 2700  byte of the tile buffer
tileCopyStore:
        sta     irqVectorHi,y           ; 2703  into the bitmap; the absolute address is patched at $26F6/$26FB and $2714/$271B (the unpatched operand in the file happens to read $FFFF)
        iny                             ; 2706  next byte
        cpy     #$20                    ; 2707  32 bytes = four characters
        bcs     L_2720                  ; 2709  tile finished
        cpy     #$10                    ; 270B  the first 16 bytes are the upper character pair
        bne     L_2700                  ; 270D  not there yet
        clc                             ; 270F  no carry into the add
        lda     bitmapPtr               ; 2710  bitmap address of the cell again
        adc     #$30                    ; 2712  +$130, and Y has already reached $10, so the store lands at +$140 = 320 = one character row down
        sta     tileCopyStore+1         ; 2714  re-patch the store...
        lda     bitmapPtrHi             ; 2717  ...
        adc     #$01                    ; 2719  ...
        sta     tileCopyStore+2         ; 271B  ...to the lower character pair
        bne     L_2700                  ; 271E  always taken (the bitmap lives at $A000)

; ----------------------------------------------------------------------
; Cell done: step one map cell to the right = two character columns.
; ----------------------------------------------------------------------
L_2720:
        inc     scratch19               ; 2720  one map cell is two characters wide...
        inc     scratch19               ; 2722  ...so advance the character column by 2
        inc     viewOriginCol           ; 2724  renderMapTile reads the view origin as the coordinates of the cell it draws
        inc     magnifySrcCol           ; 2726  next cell of this row
        ldy     magnifySrcCol           ; 2728  the column counter
        cpy     #$07                    ; 272A  7 cells across the view
        bcc     L_26EA                  ; 272C  keep going along the row
        inc     scratch18               ; 272E  row done: one map cell is two characters high...
        inc     scratch18               ; 2730  ...so advance the character row by 2
        inc     viewOriginRow           ; 2732  and the map row the next line draws
        lda     drawCellSaveCol         ; 2734  the saved view origin column
        sta     viewOriginCol           ; 2737  back to the left edge of the view
        inc     scanResultCount         ; 2739  one more viewport row done
        ldy     scanResultCount         ; 273B  the row counter
        cpy     #$05                    ; 273D  5 cell rows in the view
        bcs     L_2751                  ; 273F  all rows drawn
        lda     mapCellPtr              ; 2741  map pointer low byte
        clc                             ; 2743  plain add
        adc     #$28                    ; 2744  one map row = 40 cells
        sta     mapCellPtr              ; 2746  store it back
        lda     mapCellPtrHi            ; 2748  map pointer high byte
        adc     #$00                    ; 274A  propagate the carry
        sta     mapCellPtrHi            ; 274C  store it back
        jmp     L_26DF                  ; 274E  draw the next row of cells


; ----------------------------------------------------------------------
; Restore everything the loop borrowed: the view origin and the text plotter's scratch bytes.
; ----------------------------------------------------------------------
L_2751:
        lda     drawCellSaveCol         ; 2751  saved origin column
        sta     viewOriginCol           ; 2754  restore it (the loop walked it across the view)
        lda     drawCellSaveRow         ; 2756  saved origin row
        sta     viewOriginRow           ; 2759  restore it
        pla                             ; 275B  saved text column
        sta     scratch19               ; 275C  give the text plotter its scratch byte back
        pla                             ; 275E  saved text row
        sta     scratch18               ; 275F  and the other one
        rts                             ; 2761  done


; nybbleToMulticolourTable - expands the four pixels of one nybble of a terrain shape into one
; multicolour bitmap byte: every set bit becomes the pixel pair %10, i.e. bit 0 -> $02, bit 1 ->
; $08, bit 2 -> $20, bit 3 -> $80. %10 pixels take their colour from the low nybble of the
; screen RAM byte, which renderMapTile fills with the terrain colour; %00 pixels show the
; $D021 background (yellow) and the %11 pixels of the unit glyphs come from colour RAM.
nybbleToMulticolourTable:
        .byte   $00,$02,$08,$0A,$20,$22,$28,$2A; 2762  .... "(*  nybbles $0-$7: bit 0 -> $02, bit 1 -> $08, bit 2 -> $20
        .byte   $80                     ; 276A  nybbles $8-$F: bit 3 -> $80; $AA = all four pixels set
        .byte   $82,$88,$8A,$A0,$A2,$A8,$AA; 276B  .......

; ----------------------------------------------------------------------
; renderMapTile - Builds the 16x16 pixel multicolour tile of one map cell in the 32-byte buffer at
; $04C8-$04E7 and writes the cell's four screen-RAM colour bytes. The cell byte in A is either a
; terrain code (the 8x8 one-bit shape at $9C6E + (code-$40)*8, doubled in both directions through
; nybbleToMulticolourTable) or, with bit 7 set, a unit index, in which case the terrain hidden
; underneath is drawn first and the 32-byte unit glyph is ORed on top and the unit colour written to
; colour RAM. Cells outside the 40x40 map use the four border shapes $68-$6B. The caller copies the
; buffer into the bitmap.
; In:  A = the map cell byte; viewOriginCol zp_A5 / viewOriginRow zp_A6 = that cell's map coordinates;
;     screenPtr zp_B6/zp_B7 -> its screen RAM cell; gamePhase zp_B1 bit 6 suppresses units;
;     ownUnitColour zp_77 / enemyUnitColour zp_78; filmPlaybackMode $0B9B, revealAllUnits $92FC,
;     highlightedUnit $9204, highlightedGroupKey $9205, uiLevel $9209; the branch opcodes at
;     ownSideBranchOpcode ($2840) and unitVisibleBranchOpcode ($2846) decide which half of the unit
;     array is 'ours'
; Out: the 32-byte tile buffer $04C8-$04E7; the terrain colour in the four screen RAM bytes at
;     (zp_B6)+0/+1/+40/+41 and, when a unit is drawn, the unit colour in the matching colour RAM bytes
;     (zp_B7 is advanced by $4C, so screenPtr points into $D800 on exit); tileColour zp_32, glyphPtr
;     zp_B2/B3; self-modifies mapCellByteImm+1 ($2808), terrainShapeRowImm+1 ($27D4),
;     terrainShapeRowCounter+1 ($27F2), unitGlyphEorMask+1 ($28B8) and cloakSkipFlagImm+1 ($28C0)
; Called from: drawViewport ($26F1) and the single-cell redraw at $5D45
; Off-map check: the view may hang over the edge of the battlefield. Both coordinates are compared
; unsigned against 40, which also catches the negative (>= $80) values.
; ----------------------------------------------------------------------
renderMapTile:
        sta     mapCellByteImm+1        ; 2772  keep the cell byte in the LDA # at mapCellByteImm for the unit pass below
        tay                             ; 2775  Y = cell byte
        lda     viewOriginCol           ; 2776  map column of this cell
        cmp     #$28                    ; 2778  40 = past the right edge (negative columns are >= 40 unsigned too)
        bcs     L_2782                  ; 277A  off the map
        lda     viewOriginRow           ; 277C  map row of this cell
        cmp     #$28                    ; 277E  40 = past the bottom edge
        bcc     L_2790                  ; 2780  the cell is on the battlefield
L_2782:
        lda     viewOriginRow           ; 2782  off-map: pick one of four border patterns from the coordinates...
        clc                             ; 2784  plain add
        adc     viewOriginCol           ; 2785  ...so the border is dithered rather than uniform
        and     #$03                    ; 2787  four patterns
        clc                             ; 2789  no carry
        adc     #$68                    ; 278A  shapes $68-$6B tile everything outside the battlefield
        tay                             ; 278C  Y = border shape code
        sta     mapCellByteImm+1        ; 278D  and make the unit pass see plain terrain, not a unit index

; ----------------------------------------------------------------------
; Terrain selection: when a unit stands on the cell the map byte holds its index, and the terrain it
; covers is kept in unitTerrainUnder ($FAF0 + unit). unitTerrainUnderAlias is that table biased by
; -$80 so it can be indexed with the raw $80|unit byte.
; ----------------------------------------------------------------------
L_2790:
        tya                             ; 2790  the cell byte
        bpl     L_2797                  ; 2791  bit 7 clear = plain terrain, use it directly
        lda     unitTerrainUnderAlias,y ; 2793  a unit stands here: fetch the terrain it is hiding
        tay                             ; 2796  Y = terrain code
L_2797:
        cmp     #$40                    ; 2797  $40 = open ground, which has no shape at all
        bne     L_27A8                  ; 2799  real terrain: draw its shape
        lda     #$00                    ; 279B  blank tile
        ldy     #$1F                    ; 279D  32 buffer bytes
L_279F:
        sta     play4Row12,y            ; 279F  clear one
        dey                             ; 27A2  next
        bpl     L_279F                  ; 27A3  until the buffer is empty
        jmp     mapCellByteImm          ; 27A5  straight on to the unit pass; open ground keeps the colours already on screen


; ----------------------------------------------------------------------
; Terrain colour and shape: terrainClassTable ($077E indexed by the raw cell value) maps the cell code
; to a terrain class 0-8, terrainColourTable gives the colour of that class (7 plain, 3 river, 5
; forest, 8 contours/hills/summit, 0 for the off-map classes).
; ----------------------------------------------------------------------
L_27A8:
        ldx     D_077E,y                ; 27A8  terrain class 0-8 of this cell code
        lda     terrainColourTable,x    ; 27AB  colour nibble of that class
        sta     tileColour              ; 27AE  remember it for the screen RAM writes
        tya                             ; 27B0  the cell code
        sec                             ; 27B1  no borrow
        sbc     #$40                    ; 27B2  shape index = code - $40 for the ordinary terrain codes...
        bcs     L_27B8                  ; 27B4  ...
        adc     #$20                    ; 27B6  ...and code - $20 for the contour codes below $40
L_27B8:
        jsr     setTerrainTilePtr       ; 27B8  glyphPtr zp_B2/B3 -> the 8-byte shape at $9C6E + index*8

; ----------------------------------------------------------------------
; Expansion loop: eight shape rows, each one byte of 8 one-bit pixels. The low nybble (right half)
; becomes the right-hand character, the high nybble the left-hand one, and every row is written twice
; so the shape is doubled vertically as well as horizontally. Buffer layout:
; $04C8 upper-left character, $04D0 upper-right, $04D8 lower-left, $04E0 lower-right.
; ----------------------------------------------------------------------
        ldx     #$00                    ; 27BB  buffer offset, two bytes per shape row
        ldy     #$00                    ; 27BD  shape row 0
        sty     terrainShapeRowCounter+1; 27BF  the loop counter lives in the LDY # at terrainShapeRowCounter
L_27C2:
        lda     (glyphPtr),y            ; 27C2  one row of the 8x8 terrain shape
        sta     terrainShapeRowImm+1    ; 27C4  keep it in the LDA # at terrainShapeRowImm for the high nybble
        and     #$0F                    ; 27C7  the right four pixels
        tay                             ; 27C9  index into the expansion table
        lda     nybbleToMulticolourTable,y; 27CA  each set bit becomes a %10 pixel pair
        sta     play4Row20,x            ; 27CD  right-hand character, first scanline
        sta     play4Row21,x            ; 27D0  and again: the shape is doubled vertically
terrainShapeRowImm:
        lda     #$FF                    ; 27D3  the shape row saved at $27C4
        lsr     a                       ; 27D5  shift the left four pixels...
        lsr     a                       ; 27D6  ...down...
        lsr     a                       ; 27D7  ...into...
        lsr     a                       ; 27D8  ...bits 0-3
        tay                             ; 27D9  index into the expansion table
        lda     nybbleToMulticolourTable,y; 27DA  expand them the same way
        sta     play4Row12,x            ; 27DD  left-hand character, first scanline
        sta     play4Row13,x            ; 27E0  doubled
        inx                             ; 27E3  two scanlines...
        inx                             ; 27E4  ...per shape row
        cpx     #$08                    ; 27E5  after four rows the upper character pair is full
        bne     L_27EE                  ; 27E7  still in the upper half
        txa                             ; 27E9  buffer offset
        clc                             ; 27EA  no carry
        adc     #$08                    ; 27EB  skip the 8 bytes of the upper-right character to reach $04D8
        tax                             ; 27ED  continue in the lower character pair
L_27EE:
        inc     terrainShapeRowCounter+1; 27EE  next shape row
terrainShapeRowCounter:
        ldy     #$FF                    ; 27F1  the row counter (operand patched at $27BF/$27EE)
        cpy     #$08                    ; 27F3  eight rows in a shape
        bcc     L_27C2                  ; 27F5  keep expanding

; ----------------------------------------------------------------------
; Terrain colour into screen RAM: in multicolour mode the low nybble of the screen RAM byte is the
; colour of the %10 pixels the terrain shape is made of, the high nybble that of the %01 pixels (0 =
; black here). All four characters of the tile get the same byte.
; ----------------------------------------------------------------------
        lda     tileColour              ; 27F7  the terrain colour
        ldy     #$00                    ; 27F9  first character of the tile
        sta     (screenPtr),y           ; 27FB  upper-left
        iny                             ; 27FD  next column
        sta     (screenPtr),y           ; 27FE  upper-right
        ldy     #$28                    ; 2800  one screen row = 40 bytes
        sta     (screenPtr),y           ; 2802  lower-left
        iny                             ; 2804  next column
        sta     (screenPtr),y           ; 2805  lower-right

; ----------------------------------------------------------------------
; Unit pass. mapCellByteImm+1 holds the map cell byte stored at $2772 (or the border code at
; $278D); bit 7 set means the low 7 bits are the index of the unit standing here.
; ----------------------------------------------------------------------
mapCellByteImm:
        lda     #$FF                    ; 2807  the map cell byte patched in at $2772/$278D
        bpl     L_2859                  ; 2809  no unit on this cell: the tile is finished
        bit     gamePhase               ; 280B  gamePhase bit 6 = draw terrain only (unit placement phase)
        bvs     L_2859                  ; 280D  skip the unit
        and     #$7F                    ; 280F  unit index 0-99
        ldy     #$00                    ; 2811  default values...
        sty     unitGlyphEorMask+1      ; 2813  ...glyph EOR mask 0 = draw the glyph as it is...
        sty     cloakSkipFlagImm+1      ; 2816  ...and scanline-skip flag 0 = draw every line
        ldy     ownUnitColour           ; 2819  6 = blue, the local player's colour
        sty     tileColour              ; 281B  assume the unit is ours
        tay                             ; 281D  Y = unit index
        lda     unitTypeTable,y         ; 281E  type byte of the unit
        bpl     L_2831                  ; 2821  bit 7 clear = the unit is alive
        and     #$07                    ; 2823  dead: bits 0-2 still give the type for the glyph
        tax                             ; 2825  glyph selector
        lda     #$01                    ; 2826  white
        sta     tileColour              ; 2828  wrecks are drawn white...
        lda     #$FF                    ; 282A  ...
        sta     unitGlyphEorMask+1      ; 282C  ...and inverted
        bne     L_2878                  ; 282F  always taken: a destroyed unit is visible to both players
L_2831:
        and     #$07                    ; 2831  alive: type 0-4
        tax                             ; 2833  glyph selector
        lda     unitPathColTable,y      ; 2834  waypoint column + cloak bits ($F898)
        bpl     L_283E                  ; 2837  bit 7 clear = not cloaked
        lda     #$FF                    ; 2839  cloaked...
        sta     cloakSkipFlagImm+1      ; 283B  ...so only every other scanline of the glyph is drawn (a see-through ghost)
L_283E:
        cpy     #$32                    ; 283E  50 = the first unit of side 1; units 0-49 are side 0
ownSideBranchOpcode:
        bcc     unitVisibleBranchOpcode ; 2840  opcode patched from the local side by $7BD2 in the overlay ($90 BCC when we are side 0, $B0 BCS when side 1): branch when this is one of our units
        lda     enemyUnitColour         ; 2842  2 = red
        sta     tileColour              ; 2844  enemy units are drawn red
unitVisibleBranchOpcode:
        bcc     L_285C                  ; 2846  same test, opcode patched by patchUnitSideBranches (inverted for the THEM replay viewpoint): our own units are always visible
        lda     filmPlaybackMode        ; 2848  film playback state
        cmp     #$82                    ; 284B  $82 = playing back with viewpoint 2 = BOTH, which sees everything
        bcs     L_285C                  ; 284D  omniscient: draw the enemy unit
        lda     revealAllUnits          ; 284F  $92FC bit 7 = reveal every unit (set when the game is over)
        bmi     L_285C                  ; 2852  draw it
        lda     unitEnergyTable,y       ; 2854  energy byte of the enemy unit
        bmi     L_285C                  ; 2857  bit 7 = this unit has been spotted by us
L_2859:
        jmp     L_28E0                  ; 2859  unspotted enemy: leave the terrain tile as it is


; ----------------------------------------------------------------------
; The unit is going to be drawn. First the two overlays that change the tile rather than the glyph:
; the dug-in bars and the white hit flash.
; ----------------------------------------------------------------------
L_285C:
        lda     unitStunTable,y         ; 285C  stun / dig-in byte
        bpl     L_286F                  ; 285F  bit 7 clear = not dug in
        lda     #$FF                    ; 2861  a solid row of %11 pixels
        sta     play4Row12              ; 2863  top scanline of the upper-left character...
        sta     play4Row35              ; 2866  ...bottom scanline of the lower-left one...
        sta     play4Row20              ; 2869  ...top scanline of the upper-right...
        sta     play4Row43              ; 286C  ...and bottom of the lower-right: a bar above and below the dug-in unit
L_286F:
        lda     unitHitFlashTable,y     ; 286F  $FA8C bit 7 = the unit has just been hit
        bpl     L_2878                  ; 2872  no flash
        lda     #$01                    ; 2874  white
        sta     tileColour              ; 2876  flash the unit white this frame

; ----------------------------------------------------------------------
; Highlight: the unit the player has selected, or any member of the selected group while the console
; is at group level (uiLevel 2), is drawn in reverse video.
; ----------------------------------------------------------------------
L_2878:
        cpy     highlightedUnit         ; 2878  the unit the player currently has selected
        beq     L_2895                  ; 287B  this is it -> invert it
        lda     uiLevel                 ; 287D  which selection level the console is in
        cmp     #$02                    ; 2880  2 = a whole group is selected
        bne     L_289A                  ; 2882  single-unit level: no group highlight
        lda     unitDisplayFlagsTable,y ; 2884  group/status flags
        and     #$40                    ; 2887  bit 6 = the unit is in a group
        beq     L_289A                  ; 2889  not grouped
        lda     unitDisplayFlagsTable,y ; 288B  flags again
        and     #$3C                    ; 288E  bits 2-5 = group id plus the side bit
        cmp     highlightedGroupKey     ; 2890  the selected group's key
        bne     L_289A                  ; 2893  different group: no highlight
L_2895:
        lda     #$FF                    ; 2895  invert every glyph byte...
        sta     unitGlyphEorMask+1      ; 2897  ...by patching the EOR mask at unitGlyphEorMask

; ----------------------------------------------------------------------
; Fetch the unit glyph: 20 shapes of 32 bytes, indexed by unit type * 4 + facing through the word
; table unitGlyphPointerTable ($6C35). unitGlyphBaseTable gives $2C, $30, $34, $38, $3C for GRUNT,
; RIDER, BOOMER, SPY, COMCEN; the same numbers are the overview-map symbols, so
; $2C is subtracted again here.
; ----------------------------------------------------------------------
L_289A:
        lda     unitFlagsTable,y        ; 289A  flags byte of the unit
        and     #$03                    ; 289D  bits 0-1 = facing 0-3
        clc                             ; 289F  no carry
        adc     unitGlyphBaseTable,x    ; 28A0  + the base glyph number of the unit type
        sec                             ; 28A3  no borrow
        sbc     #$2C                    ; 28A4  back to 0-19 = type*4 + facing
        asl     a                       ; 28A6  two bytes per pointer
        tay                             ; 28A7  table index
        lda     unitGlyphPointerTable,y ; 28A8  low byte of the glyph address
        sta     glyphPtr                ; 28AB  glyph pointer
        iny                             ; 28AD  next table byte
        lda     unitGlyphPointerTable,y ; 28AE  high byte of the glyph address
        sta     glyphPtrHi              ; 28B1  glyph pointer high
        ldy     #$1F                    ; 28B3  32 glyph bytes, last first
L_28B5:
        lda     (glyphPtr),y            ; 28B5  one byte of the unit glyph
unitGlyphEorMask:
        eor     #$FF                    ; 28B7  invert mask: $00 (as-is) or $FF (reverse video), patched at $2813/$282C/$2897
        ora     play4Row12,y            ; 28B9  OR the unit on top of the terrain tile...
        sta     play4Row12,y            ; 28BC  ...in the tile buffer
cloakSkipFlagImm:
        lda     #$FF                    ; 28BF  cloak flag: $00 or $FF, patched at $2816/$283B
        bpl     L_28C4                  ; 28C1  not cloaked: one step per byte
        dey                             ; 28C3  cloaked: skip a scanline so the unit shows as a half-drawn ghost
L_28C4:
        dey                             ; 28C4  next glyph byte
        bpl     L_28B5                  ; 28C5  until all 32 are merged

; ----------------------------------------------------------------------
; Unit colour into colour RAM: the %11 pixels of a unit glyph take their colour from $D800, which is
; $4C00 above the screen RAM at $8C00.
; ----------------------------------------------------------------------
        lda     screenPtrHi             ; 28C7  screen RAM pointer high byte ($8Cxx)
        clc                             ; 28C9  no carry
        adc     #$4C                    ; 28CA  +$4C00 turns it into a colour RAM pointer ($D8xx)
        sta     screenPtrHi             ; 28CC  keep the colour RAM pointer
        ldy     #$00                    ; 28CE  redundant: Y is loaded again at $28D2
        lda     tileColour              ; 28D0  blue for our units, red for the enemy, white when hit or destroyed
        ldy     #$00                    ; 28D2  first character of the tile
        sta     (screenPtr),y           ; 28D4  upper-left
        iny                             ; 28D6  next column
        sta     (screenPtr),y           ; 28D7  upper-right
        ldy     #$28                    ; 28D9  one screen row = 40 bytes
        sta     (screenPtr),y           ; 28DB  lower-left
        iny                             ; 28DD  next column
        sta     (screenPtr),y           ; 28DE  lower-right
L_28E0:
        rts                             ; 28E0  tile finished


; ----------------------------------------------------------------------
; drawMainWindowFrame - Draws the frame of the bottom bar: window 3 on the battlefield screen, window
; 4 on every other screen. Both are the same 38x3 box at column 1, row 22; they differ only in the
; 9-tile frame set used, so the status bar looks different in the console and menu screens.
; In:  currentScreen $90FB (0 = battlefield)
; Out: textColour zp_3D = $0C, the frame drawn, window geometry left in $2A2D/$2A2E/$9221/$9222
; Called from: redrawGameScreen ($2671) and both $6F00 overlays ($712E, $7A65)
; ----------------------------------------------------------------------
drawMainWindowFrame:
        lda     #$0C                    ; 28E1  grey
        sta     textColour              ; 28E3  colour of the frame characters
        ldx     #$03                    ; 28E5  window 3 = the battlefield status bar (column 1, row 22, 38x3)
        ldy     currentScreen           ; 28E7  which screen is up
        beq     L_28EE                  ; 28EA  0 = battlefield: use window 3
        ldx     #$04                    ; 28EC  window 4 = the same box with the other frame style
L_28EE:
        jmp     drawWindowFrame         ; 28EE  draw it and return


; ----------------------------------------------------------------------
; computeGameResult - Turns the state at the end of a game into the two final point totals and the
; result message. The score pair $92A2/$92A4 and $92A3/$92A5 is indexed by the side that was scored
; AGAINST, so the larger entry belongs to the losing side. On points: equal totals give 250 each and
; message $13 'IT WAS A DRAW.', otherwise the winner gets 300 + min(difference/2, 150) and the loser
; 200, with message $11 'WE LOST ON POINTS!' or $12 'WE WON ON POINTS!'. On a knockout (gameEndReason
; bit 7): both comcens down is again a draw, otherwise the winner gets 500, the loser 150 and message
; $14 'WE WERE KNOCKED OUT!' or $15 'WE WON BY A KNOCKOUT!'. The chosen message is queued at the end
; and the status line is released. Y is preserved through a self-modified LDY # at $29B0.
; In:  Y (returned unchanged), gameEndReason $0BA8 ($08 = clock ran out, $A0/$C0 = the comcen of side
;     0/1 was knocked out), playerSide $0B9F, the running scores
;     sideScoreLo/side1ScoreLo/sideScoreHi/side1ScoreHi $92A2-$92A5
; Out: $92A2-$92A5 rewritten with the final points, gameOverFlags $923F = $C0, persistentMessageId
;     $92A1 = $9E and chatMessageLine $0548 = $9E (status line free), chatState $929C = 0, messages
;     $16 and the result message queued; scratch18/scratch19 clobbered; self-modifies $29B0+1
; Called from: endGame ($4032)
; ----------------------------------------------------------------------
computeGameResult:
        sty     L_29B0+1                ; 28F1  save Y in the LDY # at $29B0 so the caller gets it back
        jsr     resetMessageQueue       ; 28F4  throw away anything still queued for the status line
        lda     #$C0                    ; 28F7  bits 6-7 = the game is over and player input is locked out
        sta     gameOverFlags           ; 28F9  game-over flags
        lda     gameEndReason           ; 28FC  why the game ended
        bmi     L_2976                  ; 28FF  bit 7 set = a comcen was knocked out
        lda     #$16                    ; 2901  message $16 'GAME OVER.'
        jsr     queueMessage            ; 2903  queue it

; ----------------------------------------------------------------------
; Decide the result on points. The 16-bit totals are indexed by the side that was scored against, so
; subtracting them tells which side lost and by how much.
; ----------------------------------------------------------------------
        lda     sideScoreLo             ; 2906  points scored against side 0, low byte
        sec                             ; 2909  no borrow
        sbc     side1ScoreLo            ; 290A  minus the points scored against side 1
        sta     scratch18               ; 290D  difference low byte
        lda     sideScoreHi             ; 290F  high bytes...
        sbc     side1ScoreHi            ; 2912  ...with the borrow
        sta     scratch19               ; 2915  difference high byte
        ora     scratch18               ; 2917  is the difference zero?
        bne     L_292F                  ; 2919  no: somebody won on points

; ----------------------------------------------------------------------
; A tie: both sides get 250 points. setOtherPlayerPoints flips X each time, so the two calls write
; slot 1 and then slot 0.
; ----------------------------------------------------------------------
L_291B:
        ldx     #$00                    ; 291B  start from side 0
        ldy     #$FA                    ; 291D  250 points
        jsr     setOtherPlayerPoints    ; 291F  writes the total of the other side (1) and leaves X = 1
        ldy     #$FA                    ; 2922  250 points again
        jsr     setOtherPlayerPoints    ; 2924  writes the total of side 0 and leaves X = 0
        lda     #$13                    ; 2927  message $13 'IT WAS A DRAW.'
        sta     persistentMessageId     ; 2929  remember it as the result message
        jmp     L_299D                  ; 292C  queue it and finish


; ----------------------------------------------------------------------
; Somebody won: make the difference positive and remember which side lost (X). The winner's points go
; into the loser's score slot, because that slot counts what was scored against the loser.
; ----------------------------------------------------------------------
L_292F:
        ldx     #$00                    ; 292F  assume side 0 was outscored
        bcs     L_2946                  ; 2931  no borrow: more was scored against side 0, so side 0 lost
        lda     scratch18               ; 2933  otherwise negate the difference...
        eor     #$FF                    ; 2935  ...
        clc                             ; 2937  ...
        adc     #$01                    ; 2938  ...
        sta     scratch18               ; 293A  ...low byte
        lda     scratch19               ; 293C  ...
        eor     #$FF                    ; 293E  ...
        adc     #$00                    ; 2940  ...
        sta     scratch19               ; 2942  ...high byte
        ldx     #$01                    ; 2944  side 1 is the loser
L_2946:
        lsr     scratch19               ; 2946  half the point difference...
        ror     scratch18               ; 2948  ...16 bits
        lda     scratch19               ; 294A  high byte of half the difference
        bne     L_2954                  ; 294C  over 255: clamp
        lda     scratch18               ; 294E  low byte
        cmp     #$96                    ; 2950  150 = the cap on the winning margin
        bcc     L_2956                  ; 2952  below the cap: use it
L_2954:
        lda     #$96                    ; 2954  clamp to 150
L_2956:
        clc                             ; 2956  no carry
        adc     #$2C                    ; 2957  $012C = 300 base points for the winner
        sta     sideScoreLo,x           ; 2959  into the loser's slot, which holds the winner's total
        lda     #$00                    ; 295C  high byte...
        adc     #$01                    ; 295E  ...= 1 plus any carry from the 300 + margin add
        sta     sideScoreHi,x           ; 2960  winner's total, high byte
        ldy     #$11                    ; 2963  message $11 'WE LOST ON POINTS!'
        cpx     playerSide              ; 2965  is the losing side ours?
        beq     L_296B                  ; 2968  yes: keep message $11
        iny                             ; 296A  no: message $12 'WE WON ON POINTS!'
L_296B:
        sty     persistentMessageId     ; 296B  remember the result message
        ldy     #$C8                    ; 296E  200 consolation points
        jsr     setOtherPlayerPoints    ; 2970  into the winner's slot, i.e. the loser's total
        jmp     L_299D                  ; 2973  queue the message, free the status line and restore Y


; ----------------------------------------------------------------------
; Knockout ending of computeGameResult (reached from $28FF when gameEndReason $0BA8 bit 7 is set).
; Bits 5-6 of gameEndReason were built at $3BBC-$3BC9 from the destroyed comcen's side bit and ORed
; into the old value, so $20 = side 0's comcen died, $40 = side 1's, $60 = both died in one exchange.
; X ends up holding the LOSING side.  Both score slots are indexed by the OPPONENT of the side they
; belong to: hits are added at $3B88 with X = the side of the unit that was hit, and
; drawStatusScoreLine prints slot (playerSide EOR 1) as 'US'.  Storing into slot X therefore credits
; the winner, and setOtherPlayerPoints fills in the loser's slot.
; ----------------------------------------------------------------------
L_2976:
        ldx     #$00                    ; 2976  assume side 0 is the loser (X = index of the knocked-out side)
        and     #$60                    ; 2978  keep only the two knockout bits of gameEndReason (bit 5 = side 0 lost its comcen, bit 6 = side 1)
        cmp     #$60                    ; 297A  both comcens destroyed in the same exchange?
        beq     L_291B                  ; 297C  yes - reuse the draw path at $291B: 250 points each and message $13 'IT WAS A DRAW.'
        cmp     #$40                    ; 297E  otherwise test the side-1 bit ($40) on its own
        bcc     L_2983                  ; 2980  only bit 5 set - side 0 was knocked out, leave X = 0
        inx                             ; 2982  bit 6 set - side 1 was knocked out, so the loser is side 1

; ----------------------------------------------------------------------
; Award the knockout scores: 500 to the winner, 150 to the loser.
; ----------------------------------------------------------------------
L_2983:
        lda     #$F4                    ; 2983  low byte of the winner's final total, $01F4 = 500 points
        sta     sideScoreLo,x           ; 2985  slot X is the loser's opponent, i.e. the winner
        lda     #$01                    ; 2988  high byte of 500
        sta     sideScoreHi,x           ; 298A  store the winner's 16-bit total
        ldy     #$14                    ; 298D  message $14 = 'WE WERE KNOCKED OUT!' (string $87BA)
        cpx     playerSide              ; 298F  was the knocked-out side the side this machine plays?
        beq     L_2995                  ; 2992  yes - keep the 'we were knocked out' text
        iny                             ; 2994  no - message $15 = 'WE WON BY A KNOCKOUT!' (string $87CE)
L_2995:
        sty     persistentMessageId     ; 2995  remember the result message id so the status line can reshow it
        ldy     #$96                    ; 2998  the knocked-out side keeps only $96 = 150 points
        jsr     setOtherPlayerPoints    ; 299A  write 150 into the other slot (X EOR 1), which belongs to the loser

; ----------------------------------------------------------------------
; Common tail of every ending (draw, points and knockout): show the result once, then park the status
; line and leave chat entry.
; ----------------------------------------------------------------------
L_299D:
        lda     persistentMessageId     ; 299D  the result message id chosen above ($11-$15)
        jsr     queueMessage            ; 29A0  push it onto the status-line message queue ($C566)
        lda     #$9E                    ; 29A3  $9E = the 'nothing pending' marker used by the message system
        sta     persistentMessageId     ; 29A5  stop the status line from repeating the result message
        sta     chatMessageLine         ; 29A8  and blank the chat text line slot at $0548 the same way
        lda     #$00                    ; 29AB  leave chat/typing mode
        sta     chatState               ; 29AD  clear the chat state machine at $929C
L_29B0:
        ldy     #$FF                    ; 29B0  operand patched at $28F1 with the caller's Y, so Y is returned unchanged
        rts                             ; 29B2  back to endGame ($4032)


; ----------------------------------------------------------------------
; setOtherPlayerPoints - Writes Y as the 16-bit point total of the side OTHER than X and returns with
; X already flipped.  The two totals live in the parallel slots sideScoreLo/sideScoreHi ($92A2/$92A3
; and
; $92A4/$92A5) which are indexed by the opponent of the side that owns them, so computeGameResult uses
; this to fill in the second player after storing the first with sideScoreLo,x.
; In:  X = side index 0/1, Y = the point value (0-255)
; Out: sideScoreLo[X EOR 1] = Y, sideScoreHi[X EOR 1] = 0, X = X EOR 1, A = 0
; Called from: computeGameResult ($291F, $2924 draw path; $2970 points path; $299A knockout path)
; ----------------------------------------------------------------------
setOtherPlayerPoints:
        txa                             ; 29B3  take the side index that was just written
        eor     #$01                    ; 29B4  flip it: 0 <-> 1
        tax                             ; 29B6  index the other side's score slot
        tya                             ; 29B7  the value the caller passed in Y
        sta     sideScoreLo,x           ; 29B8  low byte of that side's 16-bit total
        lda     #$00                    ; 29BB  result totals are always below 256 ...
        sta     sideScoreHi,x           ; 29BD  ... so clear the high byte
        rts                             ; 29C0  X is left flipped, which is what the caller relies on


; ----------------------------------------------------------------------
; shakeScreenScroll - One frame of the screen-shake effect used when a comcen is hit.  Takes one byte
; from the game PRNG and offers bits 0-2 as a new $D011 fine Y scroll and bits 3-5 as a new $D016 fine
; X scroll; each is used only if it is non-zero and smaller than the remaining shake amount
; screenShakeTimer, otherwise the resting values ($0B for $D011, $08 for $D016) are put back.  The
; chosen low nibbles are merged into the shadow registers vicCtrl1Shadow/vicCtrl2Shadow (the raster
; IRQ copies those to the VIC) and kept in spriteScrollFineY/spriteScrollFineX so the IRQ can shift
; the sprites by the same amount at $1174-$1186.  Because the low nibble is replaced wholesale, an
; accepted shake value also clears bit 3 (RSEL/CSEL), so a shaking frame drops to 24 rows / 38 columns
; - part of the jolt.
; In:  screenShakeTimer ($92C8), vicCtrl1Shadow ($9000), vicCtrl2Shadow ($9001), PRNG state zp_57-59
; Out: vicCtrl1Shadow, vicCtrl2Shadow, spriteScrollFineY ($92F5), spriteScrollFineX ($92F4);
;      self-modified operands at $29D6, $29E6, $29F3, $2A03
; Called from: the battle main loop at $41C7, once per frame while screenShakeTimer counts down
; ----------------------------------------------------------------------
shakeScreenScroll:
        jsr     nextGameRandom          ; 29C1  one byte from the 24-bit game PRNG at $FD5D (both machines step it in lock step)
        sta     L_29D5+1                ; 29C4  patch the LDA #imm at $29D5 with the raw random byte (vertical candidate)
        lsr     a                       ; 29C7  shift the byte down three places ...
        lsr     a                       ; 29C8  ... so that bits 3-5 become bits 0-2 ...
        lsr     a                       ; 29C9  ... giving an independent horizontal candidate
        sta     L_29F2+1                ; 29CA  patch the LDA #imm at $29F2 with it
        lda     vicCtrl1Shadow          ; 29CD  current $D011 shadow
        and     #$F0                    ; 29D0  keep bits 4-7 (DEN, BMM, ECM, RST8) and drop the RSEL/Y-scroll nibble
        sta     L_29E5+1                ; 29D2  patch the ORA #imm at $29E5 with the preserved high nibble

; ----------------------------------------------------------------------
; Vertical jitter: pick a new $D011 low nibble.
; ----------------------------------------------------------------------
L_29D5:
        lda     #$FF                    ; 29D5  operand patched at $29C4 = the random byte
        and     #$07                    ; 29D7  reduce it to a 0-7 fine Y scroll
        beq     L_29E0                  ; 29D9  0 would not move anything - use the resting value instead
        cmp     screenShakeTimer        ; 29DB  only accept a jitter smaller than the shake energy still left
        bcc     L_29E2                  ; 29DE  accepted: this frame is displaced by A raster lines
L_29E0:
        lda     #$0B                    ; 29E0  resting $D011 low nibble: RSEL 25 rows (bit 3) + Y-scroll 3
L_29E2:
        sta     spriteScrollFineY       ; 29E2  remember it so the IRQ can offset the sprites by the same amount
L_29E5:
        ora     #$FF                    ; 29E5  operand patched at $29D2 = the preserved bits 4-7 of $D011
        sta     vicCtrl1Shadow          ; 29E7  the raster IRQ writes this shadow to $D011

; ----------------------------------------------------------------------
; Horizontal jitter: the same again for the $D016 low nibble.
; ----------------------------------------------------------------------
        lda     vicCtrl2Shadow          ; 29EA  current $D016 shadow
        and     #$F0                    ; 29ED  keep bits 4-7 (MCM and the unused high bits) and drop the CSEL/X-scroll nibble
        sta     L_2A02+1                ; 29EF  patch the ORA #imm at $2A02 with it
L_29F2:
        lda     #$FF                    ; 29F2  operand patched at $29CA = the random byte shifted down 3
        and     #$07                    ; 29F4  reduce it to a 0-7 fine X scroll
        beq     L_29FD                  ; 29F6  0 would not move anything
        cmp     screenShakeTimer        ; 29F8  again, only a jitter below the remaining shake energy
        bcc     L_29FF                  ; 29FB  accepted: this frame is displaced by A pixels sideways
L_29FD:
        lda     #$08                    ; 29FD  resting $D016 low nibble: CSEL 40 columns (bit 3) + X-scroll 0
L_29FF:
        sta     spriteScrollFineX       ; 29FF  remember it for the sprite offset at $117F
L_2A02:
        ora     #$FF                    ; 2A02  operand patched at $29EF = the preserved bits 4-7 of $D016
        sta     vicCtrl2Shadow          ; 2A04  the raster IRQ writes this shadow to $D016
        rts                             ; 2A07  the caller decrements screenShakeTimer afterwards


; ----------------------------------------------------------------------
; patchUnitSideBranches - Re-points the two 'is this unit mine?' branches at the viewpoint currently
; being drawn.  Overlay A stores $90 (BCC) or $B0 (BCS) at $2840 from playerSide (see $7BC6), which
; makes the 'cpy #$32' unit-index test at $283E select the local side.  This routine copies that
; opcode byte into the following visibility test at $2846 (renderMapTile) and into its twin at $5BFE
; (the overview map renderer), inverting BCC <-> BCS with EOR $20 when filmPlaybackMode bit 0 is set,
; i.e. when a game film is being replayed from the opponent's point of view (viewpoint 1 = THEM).
; Viewpoint 2 = BOTH has bit 0 clear and is handled separately by the 'cmp #$82' tests at $284B/$5C03,
; which reveal everything.
; In:  filmPlaybackMode ($0B9B) bit 0, the opcode byte at $2840
; Out: the opcode bytes at $2846 and $5BFE
; Called from: start-up ($0AD9), the battlefield screen rebuild ($2639) and the replay viewpoint key
;     ($4134)
; ----------------------------------------------------------------------
patchUnitSideBranches:
        lda     filmPlaybackMode        ; 2A08  film playback flags: bit 7 = replaying, bits 0-1 = viewpoint 0 US / 1 THEM / 2 BOTH
        lsr     a                       ; 2A0B  shift viewpoint bit 0 into the carry
        lda     ownSideBranchOpcode     ; 2A0C  the side-select branch opcode overlay A wrote: $90 = BCC (side 0), $B0 = BCS (side 1)
        bcc     L_2A13                  ; 2A0F  viewpoint bit 0 clear (US or BOTH) - use the opcode unchanged
        eor     #$20                    ; 2A11  viewpoint THEM - flip BCC <-> BCS so the enemy's units count as 'ours'
L_2A13:
        sta     unitVisibleBranchOpcode ; 2A13  patch the opcode of the branch at $2846 inside renderMapTile
        sta     D_5BFE                  ; 2A16  and the identical branch at $5BFE in the overview-map renderer
        rts                             ; 2A19  the two renderers now test 'is this unit on the viewed side?' the right way round


; ----------------------------------------------------------------------
; printOptionWord - Prints one of the three 4-byte DAMAGE option words held at $0652 in the message
; block - 'FULL' ($0652), 'HALF' ($0656), 'NONE' ($065A), each bit-7 terminated - through the text
; engine's string printer $C133.  The overlays call it with the per-side damage setting recyclerMode
; ($92A6,side) to fill the XXX field of the rules summary line at $0636.
; In:  A = word index 0 FULL / 1 HALF / 2 NONE
; Out: the word is printed at the current text cursor; A/Y = the string address handed to printString
; Called from: the setup screens of both $6F00 overlays ($843B, $85D8, $85E9)
; ----------------------------------------------------------------------
printOptionWord:
        asl     a                       ; 2A1A  index * 2 ...
        asl     a                       ; 2A1B  ... * 4: the words are 4 bytes apart
        clc                             ; 2A1C  prepare the 16-bit add
        adc     #$52                    ; 2A1D  low byte of the 'FULL' entry, $0652
        pha                             ; 2A1F  keep the low byte while the high byte is built
        lda     #$00                    ; 2A20  start the high byte at 0 ...
        adc     #$06                    ; 2A22  ... plus $06 and any carry, giving $06xx
        tay                             ; 2A24  printString wants the high byte in Y
        pla                             ; 2A25  and the low byte in A
        jmp     printString             ; 2A26  tail-call the text engine's string printer


; Parameter block shared by every glyph plotter in this file and by both $6F00 overlays (all seven
; bytes just happen to be initialised to $01).  Every screen builder sets them before calling
; drawWindowFrame, drawCharBlock, fillScreenColourRect or drawCharAt:
;   +0 useDrawColourFlag  non-zero = ignore the per-glyph colour table and colour with drawColourByte
;   +1 drawColourByte     the screen-RAM colour byte (high nibble foreground, low nibble background)
;   +2 blockFillFlag      non-zero = drawCharBlock ignores its character data and fills with
; blockFillChar
;   +3 blockFillChar      the character used for that fill
;   +4 windowOriginRow    top screen row (0-24) of the window the plotters draw into
;   +5 windowOriginCol    left screen column (0-39) of that window
;   +6 (spare)            never read by any traced code
useDrawColourFlag:
        .byte   $01                     ; 2A29  .  useDrawColourFlag: read by plotGlyph at $2AD1; 0 = take the colour from fontColorTable
drawColourByte:
        .byte   $01                     ; 2A2A  drawColourByte: written by fillScreenColourRect and used by plotGlyph and $2A85
blockFillFlag:
        .byte   $01                     ; 2A2B  blockFillFlag: tested by drawCharBlock at $2B54
blockFillChar:
        .byte   $01                     ; 2A2C  blockFillChar: the glyph drawCharBlock repeats when the flag is set
windowOriginRow:
        .byte   $01                     ; 2A2D  windowOriginRow: top row used by fillScreenColourRect, drawCharBlock, drawFrameBox, magnifyWindow2x
windowOriginCol:
        .byte   $01                     ; 2A2E  windowOriginCol: left column for the same routines; the 7th byte is a spare nobody reads
        .byte   $01                     ; 2A2F  .

; The two opcode tables that drive the four draw modes.  Mode 0 replace, 1 OR, 2 EOR, 3 keep existing
; (the CMP just throws the new value away, leaving the byte that was read first).
bitmapDrawOpcodes:
        .byte   $B1,$11,$51,$D1         ; 2A30  ..Q.  (zp),Y opcodes for the bitmap combine at $2AF3/$C22B: $B1 LDA, $11 ORA, $51 EOR, $D1 CMP

; colourDrawOpcodes: byteTable, 4 bytes. opcodes LDA/ORA/EOR/CMP abs ($AD,$0D,$4D,$CD) for the colour
; draw modes 0-3
colourDrawOpcodes:
        .byte   $AD,$0D,$4D,$CD         ; 2A34  ..M.  absolute opcodes for the colour combine at $2B00/$2A85: $AD LDA, $0D ORA, $4D EOR, $CD CMP

; ----------------------------------------------------------------------
; setBitmapDrawMode - Chooses how a glyph's eight bitmap bytes are combined with what is already in
; the
; $A000 bitmap.  It overwrites the opcode of the second LDA of the read-modify-write pair inside
; plotGlyph
; ($2AF3) and of the identical pair in the text engine's own glyph plotter ($C22B), so mode 0
; replaces, 1 ORs the glyph in, 2 EORs it (used for highlighting) and 3 leaves the bitmap untouched.
; A is preserved through the self-modified operand at $2A45.
; In:  Y = draw mode 0-3, A = value to preserve
; Out: the opcode bytes at $2AF3 and $C22B; A unchanged
; Called from: resetDrawModes ($2A5B) and the setup/menu screens of both overlays ($701E, $7AA1)
; ----------------------------------------------------------------------
setBitmapDrawMode:
        sta     L_2A44+1                ; 2A38  stash A in the operand of the LDA #imm at $2A44 so it survives
        lda     bitmapDrawOpcodes,y     ; 2A3B  pick the (zp),Y opcode for this mode
        sta     glyphBitmapOpcode       ; 2A3E  patch the bitmap combine instruction inside plotGlyph
        sta     D_C22B                  ; 2A41  and the same instruction in the $C000 text engine's plotter
L_2A44:
        lda     #$FF                    ; 2A44  operand patched at $2A38 - restores the caller's A
        rts                             ; 2A46  back to the caller with A intact


; ----------------------------------------------------------------------
; setColourDrawMode - The colour-plane twin of setBitmapDrawMode: chooses how drawColourByte is
; combined with the colour byte already in screen RAM by patching the absolute LDA at $2B00 (inside
; plotGlyph) and at $2A85 (inside fillScreenColourRect).  Mode 0 replaces, 1 ORs, 2 EORs
; (xorColourBlock2x2 uses this to flash a tile), 3 keeps the existing colour.  A is preserved through
; the operand at $2A54.
; In:  Y = draw mode 0-3, A = value to preserve
; Out: the opcode bytes at $2B00 and $2A85; A unchanged
; Called from: resetDrawModes ($2A5E), xorColourBlock2x2 ($2BD0) and overlay screen code ($7B5B)
; ----------------------------------------------------------------------
setColourDrawMode:
        sta     L_2A53+1                ; 2A47  stash A in the operand of the LDA #imm at $2A53
        lda     colourDrawOpcodes,y     ; 2A4A  pick the absolute opcode for this mode
        sta     glyphColourOpcode       ; 2A4D  patch the colour combine inside plotGlyph
        sta     colourFillOpcode        ; 2A50  and the one inside fillScreenColourRect
L_2A53:
        lda     #$FF                    ; 2A53  operand patched at $2A47 - restores the caller's A
        rts                             ; 2A55  back to the caller with A intact


; ----------------------------------------------------------------------
; resetDrawModes - Puts both planes back to mode 0 (plain replace) after a highlight or overlay
; effect. Y is preserved through the self-modified operand at $2A62.
; In:  Y = value to preserve
; Out: the opcodes at $2AF3, $C22B, $2B00 and $2A85 are all LDA again; Y unchanged
; Called from: $1689 and xorColourBlock2x2 ($2BDA) here, and from the overlays ($7033, $70F2, $7AB6,
;      $7B40, $7B86, $810E)
; ----------------------------------------------------------------------
resetDrawModes:
        sty     L_2A61+1                ; 2A56  stash Y in the operand of the LDY #imm at $2A61
        ldy     #$00                    ; 2A59  mode 0 = replace
        jsr     setBitmapDrawMode       ; 2A5B  patch the two bitmap combine instructions
        jsr     setColourDrawMode       ; 2A5E  patch the two colour combine instructions
L_2A61:
        ldy     #$FF                    ; 2A61  operand patched at $2A56 - restores the caller's Y
        rts                             ; 2A63  back to the caller with Y intact


; ----------------------------------------------------------------------
; fillScreenColourRect - Paints an X columns by Y rows rectangle of the multicolour bitmap's colour
; cells (screen RAM at $8C00, 40 bytes per row) starting at the window origin
; windowOriginRow/windowOriginCol with the colour byte A, combined according to the current colour
; draw mode.  Only the colour plane is touched - the bitmap itself keeps whatever it held, which is
; how panels are recoloured without redrawing.
; In:  A = screen-RAM colour byte, X = width in cells (1-40), Y = height in rows,
;      windowOriginRow/windowOriginCol, the mode opcode at $2A85
; Out: the rectangle recoloured; drawColourByte = A; zp_B6/B7, zp_1B (rows left) and zp_1C (width-1)
;      clobbered
; Called from: everywhere - $1782, $18DC, $18EC, $18FC, $1909, $2BD7 and jumps at $16B4, $1916, $192A
;      here, plus a dozen sites in each $6F00 overlay
; ----------------------------------------------------------------------
fillScreenColourRect:
        sta     drawColourByte          ; 2A64  remember the colour byte where the patched instruction at $2A85 can read it
        sty     scratch1B               ; 2A67  zp_1B counts the rows still to fill
        ldy     windowOriginRow         ; 2A69  the window's top screen row
        lda     screenRowHiTable,y      ; 2A6C  high byte of $8C00 + row*40 from the screen row table
        sta     screenPtrHi             ; 2A6F  high byte of the first cell of the top row
        lda     screenRowLoTable,y      ; 2A71  low byte of $8C00 + row*40
        clc                             ; 2A74  clear the carry before adding the column offset
        adc     windowOriginCol         ; 2A75  step right to the window's left column
        sta     screenPtr               ; 2A78  zp_B6/B7 now points at the first cell of the top row
        bcc     L_2A7E                  ; 2A7A  no carry out of the column add
        inc     screenPtrHi             ; 2A7C  carry - bump the high byte
L_2A7E:
        dex                             ; 2A7E  width-1 = the highest column offset ...
        stx     scratch1C               ; 2A7F  ... kept in zp_1C as the per-row start index

; ----------------------------------------------------------------------
; Outer loop: one screen row per pass.  Inner loop walks the row right to left.
; ----------------------------------------------------------------------
L_2A81:
        ldy     scratch1C               ; 2A81  start each row at its rightmost cell
L_2A83:
        lda     (screenPtr),y           ; 2A83  read the cell's current colour (needed by the OR/EOR/keep modes)
colourFillOpcode:
        lda     drawColourByte          ; 2A85  opcode patched by setColourDrawMode: LDA / ORA / EOR / CMP drawColourByte
        sta     (screenPtr),y           ; 2A88  write the combined colour back
        dey                             ; 2A8A  next cell to the left
        bpl     L_2A83                  ; 2A8B  until offset 0 has been done
        dec     scratch1B               ; 2A8D  one row finished
        beq     L_2A9E                  ; 2A8F  all rows done
        clc                             ; 2A91  clear the carry before the row step
        lda     screenPtr               ; 2A92  advance the pointer ...
        adc     #$28                    ; 2A94  ... by one screen row (40 bytes)
        sta     screenPtr               ; 2A96  pointer now addresses the same column one row down
        bcc     L_2A81                  ; 2A98  no page crossed
        inc     screenPtrHi             ; 2A9A  carry into the high byte
        bne     L_2A81                  ; 2A9C  always taken - back for the next row
L_2A9E:
        rts                             ; 2A9E  the rectangle is filled


; ----------------------------------------------------------------------
; calcCellPointers - Loads the character cell coordinates from scratch18 (row) and scratch19 (column)
; and falls into calcCellPointersXY.  This is the entry every routine in this file uses.
; In:  scratch18 = screen row 0-24, scratch19 = screen column 0-39
; Out: as calcCellPointersXY; Y = row, X = column
; Called from: $26EA, drawCharAt ($2AC4), drawCharBlock ($2B4E), setMagnifyCellPointers ($2F44 and the
;      tail jump at $2F67) and $5B15
; ----------------------------------------------------------------------
calcCellPointers:
        ldy     scratch18               ; 2A9F  Y = character row
        ldx     scratch19               ; 2AA1  X = character column

; ----------------------------------------------------------------------
; calcCellPointersXY - Turns a character cell (Y = row, X = column) into the two pointers every
; plotter needs: bitmapPtr = $A000 + row*320 + column*8 (the eight bytes of that cell in the
; multicolour bitmap) and screenPtr = $8C00 + row*40 + column (its colour byte).  Both are looked up
; in the six address tables at $9300 rather than multiplied: bitmap row lo/hi at $9300/$9319 (25
; entries), column*8 lo/hi at
; $9332/$935A (40 entries) and screen row lo/hi at $9382/$939B (25 entries).
; In:  Y = screen row 0-24, X = screen column 0-39
; Out: bitmapPtr/bitmapPtrHi (zp_B4/B5), screenPtr/screenPtrHi (zp_B6/B7); X and Y unchanged
; Called from: $5CB6, $5D3E here, the text engine's cell setter ($C0A7) and the overlays ($864B)
; ----------------------------------------------------------------------
calcCellPointersXY:
        lda     bitmapRowLoTable,y      ; 2AA3  low byte of $A000 + row*320
        clc                             ; 2AA6  clear the carry for the 16-bit add
        adc     bitmapColLoTable,x      ; 2AA7  plus column*8 (low)
        sta     bitmapPtr               ; 2AAA  low byte of the cell's bitmap address
        lda     bitmapRowHiTable,y      ; 2AAC  high byte of $A000 + row*320
        adc     bitmapColHiTable,x      ; 2AAF  plus column*8 (high: 0 for columns 0-31, 1 for 32-39)
        sta     bitmapPtrHi             ; 2AB2  zp_B4/B5 = address of the cell's 8 bitmap bytes
        txa                             ; 2AB4  the column is also the byte offset within a screen row
        clc                             ; 2AB5  clear the carry for the screen-RAM add
        adc     screenRowLoTable,y      ; 2AB6  plus the low byte of $8C00 + row*40
        sta     screenPtr               ; 2AB9  low byte of the cell's screen-RAM address
        lda     screenRowHiTable,y      ; 2ABB  high byte of $8C00 + row*40 ($8C-$8F)
        adc     #$00                    ; 2ABE  add only the carry - the screen fits in four pages
        sta     screenPtrHi             ; 2AC0  zp_B6/B7 = address of the cell's colour byte
        rts                             ; 2AC2  both pointers are ready for plotGlyph


; ----------------------------------------------------------------------
; drawCharAt - Draws character A at screen row scratch18, column scratch19: computes the cell pointers
; and falls straight into plotGlyph.
; In:  A = character code 0-113, scratch18 = row, scratch19 = column
; Out: see plotGlyph
; Called from: drawWindowInnerCorners, drawTallGlyph, drawFrameBox, drawFrameRow/Column here ($2B95,
;      $2B9E, $2BA7, $2BB3, $2BBD, $2BC8, $2CC5, $2CD0, $2CDB, $2CE7, $2D23, $2D35) and from the
; overlays
;      ($6F6F, $6F76, $6F7D)
; ----------------------------------------------------------------------
drawCharAt:
        pha                             ; 2AC3  the cell pointer helper clobbers A
        jsr     calcCellPointers        ; 2AC4  work out where row/column lands in the bitmap and in screen RAM
        pla                             ; 2AC7  get the character code back

; ----------------------------------------------------------------------
; plotGlyph - Draws one 8x8 glyph of the game's own font (114 glyphs at $9426, 8 bytes each) into the
; bitmap cell at bitmapPtr, combining the bytes according to the bitmap draw mode, then sets the
; cell's colour at screenPtr.  The colour is the glyph's own default from fontColorTable ($93B4) when
; useDrawColourFlag is 0 and that entry is non-zero; otherwise it is drawColourByte combined according
; to the colour draw mode.  When the display is in multicolour mode ($D016 bit 4 set in
; vicCtrl2Shadow) the routine also writes textColour into the matching colour RAM cell, reached by
; adding $4C to the high byte of screenPtr ($8C00 + $4C00 = $D800).
; In:  A = character code 0-113; bitmapPtr/screenPtr from calcCellPointers; useDrawColourFlag,
;      drawColourByte, textColour (zp_3D), vicCtrl2Shadow; the mode opcodes at $2AF3 and $2B00
; Out: 8 bitmap bytes, 1 screen-RAM colour byte and optionally 1 colour RAM byte written; glyphPtr
;      (zp_B2/B3) = the glyph's font address; screenPtrHi is left pointing into colour RAM in
;      multicolour mode; A/X/Y clobbered
; Called from: drawCharBlock ($2B5F) and by falling through from drawCharAt
; ----------------------------------------------------------------------
plotGlyph:
        tay                             ; 2AC8  keep the character code in Y for the font-colour lookup
        lda     #$00                    ; 2AC9  clear the glyph pointer before building it
        sta     glyphPtr                ; 2ACB  dead store - glyphPtr is overwritten at $2AE7 before it is used
        sta     glyphPtrHi              ; 2ACD  clear the high byte so the three ROLs below can shift into it
        lda     #$00                    ; 2ACF  default colour value 0 = 'this glyph has no colour of its own'
        ldx     useDrawColourFlag       ; 2AD1  does the caller insist on drawColourByte for every glyph?
        bne     L_2AD9                  ; 2AD4  yes - keep the 0 so the fill colour is used
        lda     fontColorTable,y        ; 2AD6  no - take this glyph's default colour byte from the 114-entry table at $93B4
L_2AD9:
        tax                             ; 2AD9  park it in X across the glyph copy
        tya                             ; 2ADA  character code back into A
        asl     a                       ; 2ADB  code * 2 ...
        rol     glyphPtrHi              ; 2ADC  carry the overflow into the high byte
        asl     a                       ; 2ADE  ... * 4 ...
        rol     glyphPtrHi              ; 2ADF  carry again
        asl     a                       ; 2AE1  ... * 8: each glyph is 8 bytes
        rol     glyphPtrHi              ; 2AE2  and again - glyphPtrHi now holds bits 8-10 of code*8
        clc                             ; 2AE4  clear the carry for the base-address add
        adc     #$26                    ; 2AE5  add the low byte of the font base $9426
        sta     glyphPtr                ; 2AE7  low byte of $9426 + code*8
        lda     glyphPtrHi              ; 2AE9  bits 8-10 of code*8
        adc     #$94                    ; 2AEB  and its high byte
        sta     glyphPtrHi              ; 2AED  zp_B2/B3 now points at the glyph's 8 rows

; ----------------------------------------------------------------------
; Copy the glyph into the bitmap cell, bottom row first.
; ----------------------------------------------------------------------
        ldy     #$07                    ; 2AEF  8 pixel rows, counted 7 down to 0
L_2AF1:
        lda     (bitmapPtr),y           ; 2AF1  read the byte already in the bitmap (only the OR/EOR/keep modes need it)
glyphBitmapOpcode:
        lda     (glyphPtr),y            ; 2AF3  opcode patched by setBitmapDrawMode: LDA / ORA / EOR / CMP (glyphPtr),y
        sta     (bitmapPtr),y           ; 2AF5  store the combined pixel row
        dey                             ; 2AF7  next pixel row up
        bpl     L_2AF1                  ; 2AF8  until row 0 has been written

; ----------------------------------------------------------------------
; Now the colour plane: one screen-RAM byte, plus a colour RAM byte in multicolour mode.
; ----------------------------------------------------------------------
        iny                             ; 2AFA  Y = 0, the single colour byte of this cell
        txa                             ; 2AFB  the glyph's default colour saved in X
        bne     L_2B03                  ; 2AFC  non-zero - write it straight in, ignoring the draw mode
        lda     (screenPtr),y           ; 2AFE  otherwise read the colour that is already there
glyphColourOpcode:
        lda     drawColourByte          ; 2B00  opcode patched by setColourDrawMode: LDA / ORA / EOR / CMP drawColourByte
L_2B03:
        sta     (screenPtr),y           ; 2B03  store the cell colour into screen RAM at $8C00+
        lda     vicCtrl2Shadow          ; 2B05  shadow of $D016
        and     #$10                    ; 2B08  bit 4 = MCM; the game runs hires ($C8) for text and multicolour ($D8) for the map
        beq     L_2B17                  ; 2B0A  hires - one colour byte is enough
        clc                             ; 2B0C  clear the carry for the pointer bump
        lda     screenPtrHi             ; 2B0D  high byte of the screen-RAM cell address
        adc     #$4C                    ; 2B0F  $8C00 + $4C00 = $D800: the same cell in colour RAM
        sta     screenPtrHi             ; 2B11  zp_B6/B7 now addresses the same cell in colour RAM
        lda     textColour              ; 2B13  the text engine's current colour supplies the third multicolour pair
        sta     (screenPtr),y           ; 2B15  third colour of the multicolour pixel pair
L_2B17:
        rts                             ; 2B17  one cell drawn


; ----------------------------------------------------------------------
; drawCharBlock - Draws a rectangular block of characters at windowOriginRow/windowOriginCol.  A/Y
; point at the block: byte 0 = width in cells, byte 1 = height, then width*height character codes
; stored row by row.  When blockFillFlag is non-zero the character data is ignored and every cell is
; drawn with blockFillChar instead (the 2-byte header is still read), which is how the screen builders
; erase a panel. The header values are also left in frameWidth/frameHeight for drawWindowInnerCorners.
; In:  A/Y = lo/hi address of the block; windowOriginRow, windowOriginCol, blockFillFlag,
;     blockFillChar
; Out: frameWidth ($9221) = width, frameHeight ($9222) = height; blockPtr (zp_B8/B9) = the character
;     data;
;      scratch18/19 = last cell, scratch1B = row, scratch1C = column, scratch1D = data index
; Called from: the main menu builder ($16A9) and the panel builders of both overlays ($6F13, $6F24,
;     $6F53,
;      $70A3, $70D6, $7B1B and the jump at $726E)
; ----------------------------------------------------------------------
drawCharBlock:
        sta     blockPtr                ; 2B18  keep the block address in zp_B8/B9
        sty     blockPtrHi              ; 2B1A  high byte of the block address
        ldy     #$00                    ; 2B1C  read the 2-byte header through (blockPtr),y
        lda     (blockPtr),y            ; 2B1E  header byte 0 = width in character cells
        sta     frameWidth              ; 2B20  also published for drawWindowInnerCorners
        inc     blockPtr                ; 2B23  step past the width byte
        bne     L_2B29                  ; 2B25  no page crossing
        inc     blockPtrHi              ; 2B27  carry into the high byte
L_2B29:
        lda     (blockPtr),y            ; 2B29  header byte 1 = height in character rows
        sta     frameHeight             ; 2B2B  also published for drawWindowInnerCorners
        inc     blockPtr                ; 2B2E  step past the height byte - zp_B8/B9 now addresses the character data
        bne     L_2B34                  ; 2B30  no page crossing
        inc     blockPtrHi              ; 2B32  carry into the high byte
L_2B34:
        lda     #$00                    ; 2B34  start at the top row of the block
        sta     scratch1B               ; 2B36  row counter = 0
        sta     scratch1D               ; 2B38  index into the character data = 0

; ----------------------------------------------------------------------
; Row loop: scratch1B counts rows, scratch1C counts columns, scratch1D walks the data linearly.
; ----------------------------------------------------------------------
L_2B3A:
        lda     #$00                    ; 2B3A  start at the left column of this row
        sta     scratch1C               ; 2B3C  column counter back to 0 for this row
        lda     windowOriginRow         ; 2B3E  the window's top row ...
        clc                             ; 2B41  clear the carry for the row add
        adc     scratch1B               ; 2B42  ... plus the row we are on
        sta     scratch18               ; 2B44  target screen row for drawCharAt

; ----------------------------------------------------------------------
; Column loop: one character per cell.
; ----------------------------------------------------------------------
L_2B46:
        lda     windowOriginCol         ; 2B46  the window's left column ...
        clc                             ; 2B49  clear the carry for the column add
        adc     scratch1C               ; 2B4A  ... plus the column we are on
        sta     scratch19               ; 2B4C  target screen column
        jsr     calcCellPointers        ; 2B4E  point zp_B4-zp_B7 at that cell
        lda     blockFillChar           ; 2B51  assume the fill character
        ldx     blockFillFlag           ; 2B54  is this a plain fill rather than a character block?
        bne     L_2B5F                  ; 2B57  yes - draw blockFillChar and leave the data untouched
        ldy     scratch1D               ; 2B59  no - index the next byte of the block data
        lda     (blockPtr),y            ; 2B5B  fetch the character for this cell
        inc     scratch1D               ; 2B5D  and advance the data index
L_2B5F:
        jsr     plotGlyph               ; 2B5F  draw it into the cell the pointers already address
        inc     scratch1C               ; 2B62  next column
        lda     scratch1C               ; 2B64  column just drawn
        cmp     frameWidth              ; 2B66  have we reached the block's width?
        bcc     L_2B46                  ; 2B69  keep going until the whole row is drawn
        inc     scratch1B               ; 2B6B  next row
        lda     scratch1B               ; 2B6D  row just finished
        cmp     frameHeight             ; 2B6F  have we reached the block's height?
        bcc     L_2B3A                  ; 2B72  keep going until every row is drawn
        rts                             ; 2B74  the whole block is on screen


; ----------------------------------------------------------------------
; drawWindowInnerCorners - Draws the small hollow-ring glyph $38 (font bytes at $95E6) in the four
; cells just inside the corners of the current window: rows windowOriginRow+1 and
; windowOriginRow+frameHeight-1, columns windowOriginCol+1 and windowOriginCol+frameWidth-1.  It is
; always called straight after drawWindowFrame or drawCharBlock, so frameWidth/frameHeight are the
; extents those routines left behind. Only the two $6F00 overlays use it, to stud the corners of their
; menu and setup panels.
; In:  windowOriginRow, windowOriginCol, frameWidth ($9221), frameHeight ($9222)
; Out: four characters drawn; scratch18/19 (cell), scratch1A (right column), scratch1B (bottom row)
; Called from: overlay panel builders only ($6FFD, $7092, $7A80, $7B0A, $7B4C)
; ----------------------------------------------------------------------
drawWindowInnerCorners:
        lda     windowOriginRow         ; 2B75  window top row
        sta     scratch18               ; 2B78  scratch18 = the row of the top studs (bumped below)
        clc                             ; 2B7A  clear the carry for the height add
        adc     frameHeight             ; 2B7B  plus the height ...
        sta     scratch1B               ; 2B7E  ... = the bottom row of the frame
        lda     windowOriginCol         ; 2B80  window left column
        sta     scratch19               ; 2B83  scratch19 = the column of the left studs (bumped below)
        clc                             ; 2B85  clear the carry for the width add
        adc     frameWidth              ; 2B86  plus the width ...
        sta     scratch1A               ; 2B89  ... = the right column of the frame
        inc     scratch18               ; 2B8B  move one row down, inside the frame
        inc     scratch19               ; 2B8D  and one column right
        dec     scratch1A               ; 2B8F  one column left of the right edge
        dec     scratch1B               ; 2B91  one row up from the bottom edge
        lda     #$38                    ; 2B93  glyph $38 = the small hollow ring at $95E6
        jsr     drawCharAt              ; 2B95  top-left stud
        lda     scratch1A               ; 2B98  the column just inside the right edge
        sta     scratch19               ; 2B9A  move to the right-hand column
        lda     #$38                    ; 2B9C  the same ring glyph again
        jsr     drawCharAt              ; 2B9E  top-right stud
        lda     scratch1B               ; 2BA1  the row just inside the bottom edge
        sta     scratch18               ; 2BA3  move down to the bottom row
        lda     #$38                    ; 2BA5  the same ring glyph again
        jsr     drawCharAt              ; 2BA7  bottom-right stud
        lda     windowOriginCol         ; 2BAA  back to the window's left column ...
        sta     scratch19               ; 2BAD  back to the window's left column
        inc     scratch19               ; 2BAF  ... plus one, inside the frame
        lda     #$38                    ; 2BB1  the fourth ring glyph
        jsr     drawCharAt              ; 2BB3  bottom-left stud
        rts                             ; 2BB6  all four studs drawn


; ----------------------------------------------------------------------
; drawTallGlyph - Draws one of the ten two-cell-tall symbols whose character-code pairs live at $9810
; (20 bytes: top code at $9810+A*2, bottom code at $9810+A*2+1).  The top half goes at
; scratch18/scratch19 and the bottom half one row lower; scratch18 is restored on the way out.  Used
; by the overlays for the double-height icons on the comcen and setup screens.
; In:  A = symbol index 0-9, scratch18 = top row, scratch19 = column
; Out: two characters drawn; scratch18/scratch19 unchanged
; Called from: overlay screens only ($6FD9, $6FE1, $6FE9, $7055, $7AD8, $7AE0)
; ----------------------------------------------------------------------
drawTallGlyph:
        asl     a                       ; 2BB7  two character codes per symbol
        pha                             ; 2BB8  keep the doubled index across the first draw
        tax                             ; 2BB9  index the top half of the pair
        lda     titleColorRow00Cell16,x ; 2BBA  top-half character code  (also graphicsData9300:tallDigitGlyphPairTable)
        jsr     drawCharAt              ; 2BBD  draw the top cell
        inc     scratch18               ; 2BC0  the bottom half sits one row lower
        pla                             ; 2BC2  get the doubled index back
        tax                             ; 2BC3  index it again for the bottom half
        inx                             ; 2BC4  the odd entry of the pair
        lda     titleColorRow00Cell16,x ; 2BC5  bottom-half character code  (also graphicsData9300:tallDigitGlyphPairTable)
        jsr     drawCharAt              ; 2BC8  draw the bottom cell
        dec     scratch18               ; 2BCB  put the caller's row back
        rts                             ; 2BCD  both halves of the symbol are drawn


; ----------------------------------------------------------------------
; xorColourBlock2x2 - Flashes one battlefield tile by EORing the colour byte A into the 2x2 screen-RAM
; colour cells at windowOriginRow/windowOriginCol (a map cell is 16x16 pixels = 2x2 character cells in
; the magnified view), then puts both draw modes back to plain replace.  Calling it twice with the
; same value restores the original colours.
; In:  A = colour byte to toggle, windowOriginRow/windowOriginCol
; Out: four screen-RAM colour bytes toggled; drawColourByte = A; both draw modes reset to 0
; Called from: the overlays only ($75DA, $807F, $814D)
; ----------------------------------------------------------------------
xorColourBlock2x2:
        ldy     #$02                    ; 2BCE  colour draw mode 2 = EOR
        jsr     setColourDrawMode       ; 2BD0  patch the two colour combine instructions
        ldx     #$02                    ; 2BD3  2 cells wide ...
        ldy     #$02                    ; 2BD5  ... and 2 cells tall = one map tile
        jsr     fillScreenColourRect    ; 2BD7  toggle the four colour bytes
        jmp     resetDrawModes          ; 2BDA  restore replace mode and return through it


; The 27 predefined screen windows, held as four parallel 27-byte tables plus a table of frame
; tile-set pointers, all indexed by the window number passed to drawWindowFrame in X.  The ones used
; by this file are 0 = the 22x22 overview panel at (0,0), 1 = the 16x12 battlefield view frame at
; column 24, 2 = the 16x11 console panel below it, 3 and 4 = the 38x3 message frame at row 22 (3 in
; battle, 4 elsewhere),
; $18/$19 = the full-width main-menu frames.  Windows 5-$16 belong to the $6F00 overlays' setup, stats
; and
; comcen screens.
windowLeftTable:
        .byte   $00,$18,$18,$01,$01,$01,$02,$1B; 2BDD  ........  left screen column of windows 0-7: overview 0, view 24, console 24, message 1, message 1, ...
        .byte   $1F                     ; 2BE5  windows 8-15
        .byte   $1D,$1C,$1D,$1E,$01,$01,$0B; 2BE6  .......
        .byte   $0C                     ; 2BED  windows $10-$17
        .byte   $1D,$20,$1E,$0D,$12,$0D,$0D; 2BEE  . .....
        .byte   $00                     ; 2BF5  windows $18-$1A: the two main-menu frames at columns 0 and 1, and a 30-wide box at column 5
        .byte   $01,$05                 ; 2BF6  ..

; windowTopTable: byteTable, 27 bytes. Top screen row of windows 0-26
windowTopTable:
        .byte   $00,$00,$0B,$16,$16,$01,$02,$0F; 2BF8  ........  top screen row of windows 0-7: overview 0, view 0, console 11, message 22, message 22, ...
        .byte   $10                     ; 2C00  windows 8-15
        .byte   $08,$00,$01,$02,$03,$0C,$04; 2C01  .......
        .byte   $05                     ; 2C08  windows $10-$17
        .byte   $03,$04,$0A,$06,$0A,$06,$04; 2C09  .......
        .byte   $05                     ; 2C10  windows $18-$1A
        .byte   $06,$00                 ; 2C11  ..

; windowWidthTable: byteTable, 27 bytes. Width in cells (including the frame) of windows 0-26; e.g.
; window 0 = 22x22 around the 20x20 overview map, window 1 = 16x12 around the 7x5 cell (14x10 char)
; battlefield view
windowWidthTable:
        .byte   $16,$10,$10,$26,$26,$19,$17,$0C; 2C13  ...&&...  width in cells (frame included) of windows 0-7: 22, 16, 16, 38, 38, ...
        .byte   $04                     ; 2C1B  windows 8-15
        .byte   $08,$0B,$09,$07,$09,$09,$12; 2C1C  .......
        .byte   $10                     ; 2C23  windows $10-$17
        .byte   $0B,$05,$0A,$0E,$04,$0E,$0E; 2C24  .......
        .byte   $28                     ; 2C2B  windows $18-$1A: 40, 38 and 30 cells wide
        .byte   $26,$1E                 ; 2C2C  &.

; windowHeightTable: byteTable, 27 bytes. Height in cells of windows 0-26
windowHeightTable:
        .byte   $16,$0C,$0B,$03,$03,$15,$13,$07; 2C2E  ........  height in cells (frame included) of windows 0-7: 22, 12, 11, 3, 3, ...
        .byte   $04                     ; 2C36  windows 8-15
        .byte   $07,$08,$06,$04,$09,$09,$10; 2C37  .......
        .byte   $0E                     ; 2C3E  windows $10-$17
        .byte   $07,$04,$0B,$0C,$04,$0C,$0C; 2C3F  .......
        .byte   $14                     ; 2C46  windows $18-$1A: 20, 18 and 7 rows tall
        .byte   $12,$07                 ; 2C47  ..

; One little-endian pointer per window to its 9-byte frame tile set in $9824-$987D.  A set is laid out
; top-left, top edge, top-right, left edge, centre (never used), right edge, bottom-left, bottom edge,
; bottom-right.  $9824, $982D, $9836 and $983F are four different line styles, $9848 is all glyph $03
; (a solid block) and $9851 is all glyph $00 (blank, i.e. the frame erases itself), while $985A,
; $9863,
; $986C and $9875 are the four heavy panel styles used by windows 0-3.
windowFramePtrTable:
        .addr   windowFrameTileSetDither; 2C49  window 0 - overview panel
        .byte   $63                     ; 2C4B  window 1 - battlefield view
        .byte   $98                     ; 2C4C  .
        .byte   $6C                     ; 2C4D  window 2 - console panel
        .byte   $98                     ; 2C4E  .
        .byte   $75                     ; 2C4F  window 3 - message frame during the battle
        .byte   $98                     ; 2C50  .
        .byte   $24                     ; 2C51  window 4 - message frame outside the battle
        .byte   $98                     ; 2C52  .
        .byte   $36                     ; 2C53  window 5
        .byte   $98                     ; 2C54  .
        .byte   $3F                     ; 2C55  window 6
        .byte   $98                     ; 2C56  .
        .byte   $2D                     ; 2C57  window 7
        .byte   $98                     ; 2C58  .
        .byte   $24                     ; 2C59  window 8
        .byte   $98                     ; 2C5A  .
        .byte   $2D                     ; 2C5B  window 9
        .byte   $98                     ; 2C5C  .
        .byte   $2D                     ; 2C5D  window $0A
        .byte   $98                     ; 2C5E  .
        .byte   $24                     ; 2C5F  window $0B
        .byte   $98                     ; 2C60  .
        .byte   $48                     ; 2C61  window $0C - solid block frame
        .byte   $98                     ; 2C62  .
        .byte   $36                     ; 2C63  window $0D
        .byte   $98                     ; 2C64  .
        .byte   $36                     ; 2C65  window $0E
        .byte   $98                     ; 2C66  .
        .byte   $36                     ; 2C67  window $0F
        .byte   $98                     ; 2C68  .
        .byte   $3F                     ; 2C69  window $10
        .byte   $98                     ; 2C6A  .
        .byte   $2D                     ; 2C6B  window $11
        .byte   $98                     ; 2C6C  .
        .byte   $24                     ; 2C6D  window $12
        .byte   $98                     ; 2C6E  .
        .byte   $2D                     ; 2C6F  window $13
        .byte   $98                     ; 2C70  .
        .byte   $51                     ; 2C71  window $14 - blank tiles, so drawing this window erases the frame
        .byte   $98                     ; 2C72  .
        .byte   $51                     ; 2C73  window $15 - blank tiles as well
        .byte   $98                     ; 2C74  .
        .byte   $48                     ; 2C75  window $16 - solid block frame
        .byte   $98                     ; 2C76  .
        .byte   $48                     ; 2C77  window $17 - solid block frame
        .byte   $98                     ; 2C78  .
        .byte   $36                     ; 2C79  window $18 - main menu outer frame
        .byte   $98                     ; 2C7A  .
        .byte   $3F                     ; 2C7B  window $19 - main menu inner frame
        .byte   $98                     ; 2C7C  .
        .byte   $2D                     ; 2C7D  window $1A
        .byte   $98                     ; 2C7E  .

; ----------------------------------------------------------------------
; drawWindowFrame - Draws one of the 27 predefined windows selected by X.  Copies its left column, top
; row, width and height from the four parallel tables into windowOriginCol/windowOriginRow/frameWidth/
; frameHeight, fetches its 9-byte frame tile set from windowFramePtrTable and falls into drawFrameBox.
; In:  X = window number 0-26
; Out: windowOriginRow/Col and frameWidth/frameHeight set (the latter two are then decremented by
;      drawFrameBox, so they end up as the right/bottom extents); the frame is drawn; A/X/Y clobbered
; Called from: $1567, $156C, $269E and the jumps at $2695, $26BE, $28EE here, and from sixteen sites
;     in
;      each $6F00 overlay
; ----------------------------------------------------------------------
drawWindowFrame:
        lda     windowLeftTable,x       ; 2C7F  left screen column of this window
        sta     windowOriginCol         ; 2C82  left column of the window the plotters will use
        lda     windowTopTable,x        ; 2C85  top screen row
        sta     windowOriginRow         ; 2C88  top row of that window
        lda     windowWidthTable,x      ; 2C8B  width in cells
        sta     frameWidth              ; 2C8E  width in cells, before drawFrameBox decrements it
        lda     windowHeightTable,x     ; 2C91  height in cells
        sta     frameHeight             ; 2C94  height in cells, before drawFrameBox decrements it
        txa                             ; 2C97  the pointer table has two bytes per window ...
        asl     a                       ; 2C98  ... so double the index
        tax                             ; 2C99  index the pointer table
        lda     windowFramePtrTable,x   ; 2C9A  low byte of this window's tile set
        inx                             ; 2C9D  step to the high byte of the pointer
        ldy     windowFramePtrTable,x   ; 2C9E  high byte - drawFrameBox expects A/Y

; ----------------------------------------------------------------------
; drawFrameBox - Draws a rectangular frame from a 9-byte tile set at windowOriginRow/windowOriginCol,
; frameWidth by frameHeight cells.  The tile set is indexed 0 top-left, 1 top edge, 2 top-right, 3
; left edge, 4 centre (never read), 5 right edge, 6 bottom-left, 7 bottom edge, 8 bottom-right.  The
; four corners are drawn first, then the bottom, top, left and right runs through
; drawFrameRow/drawFrameColumn, each of which stops before the corner it would overwrite.  Width and
; height are decremented on entry so they become the right/bottom offsets, and are left that way for
; drawWindowInnerCorners.
; In:  A/Y = lo/hi address of the 9-byte tile set; windowOriginRow, windowOriginCol, frameWidth,
;      frameHeight; the current colour state (useDrawColourFlag / drawColourByte / fontColorTable)
; Out: the frame is drawn into the bitmap and screen RAM; frameWidth and frameHeight are each 1
;     smaller;
;      scratch1A = right column, scratch1B = bottom row, blockPtr (zp_B8/B9) = the tile set
; Called from: drawWindowFrame by fall-through, and directly by overlay B ($724A) with its own tile
;     set
; ----------------------------------------------------------------------
drawFrameBox:
        sta     blockPtr                ; 2CA1  keep the tile set address in zp_B8/B9
        sty     blockPtrHi              ; 2CA3  zp_B8/B9 = the 9-byte tile set
        dec     frameWidth              ; 2CA5  width in cells becomes the offset of the rightmost column
        dec     frameHeight             ; 2CA8  height in cells becomes the offset of the bottom row
        lda     windowOriginRow         ; 2CAB  window top row ...
        sta     scratch18               ; 2CAE  ... is the row of the top corners
        clc                             ; 2CB0  clear the carry for the height add
        adc     frameHeight             ; 2CB1  top row + height-1 ...
        sta     scratch1B               ; 2CB4  ... = the bottom row, kept in scratch1B as the column loops' stop value
        lda     windowOriginCol         ; 2CB6  window left column ...
        sta     scratch19               ; 2CB9  ... is the column of the left corners
        clc                             ; 2CBB  clear the carry for the width add
        adc     frameWidth              ; 2CBC  left column + width-1 ...
        sta     scratch1A               ; 2CBF  ... = the right column, kept in scratch1A as the row loops' stop value

; ----------------------------------------------------------------------
; The four corners first, so the edge runs can stop one cell short of each.
; ----------------------------------------------------------------------
        ldy     #$00                    ; 2CC1  tile 0 = top-left corner
        lda     (blockPtr),y            ; 2CC3  tile 0 of the set
        jsr     drawCharAt              ; 2CC5  draw it at (top, left)
        lda     scratch1A               ; 2CC8  move to the right column
        sta     scratch19               ; 2CCA  move to the right-hand column
        ldy     #$02                    ; 2CCC  tile 2 = top-right corner
        lda     (blockPtr),y            ; 2CCE  tile 2 of the set
        jsr     drawCharAt              ; 2CD0  draw it at (top, right)
        lda     scratch1B               ; 2CD3  move down to the bottom row
        sta     scratch18               ; 2CD5  move down to the bottom row
        ldy     #$08                    ; 2CD7  tile 8 = bottom-right corner
        lda     (blockPtr),y            ; 2CD9  tile 8 of the set
        jsr     drawCharAt              ; 2CDB  draw it at (bottom, right)
        lda     windowOriginCol         ; 2CDE  back to the left column
        sta     scratch19               ; 2CE1  back to the left-hand column
        ldy     #$06                    ; 2CE3  tile 6 = bottom-left corner
        lda     (blockPtr),y            ; 2CE5  tile 6 of the set
        jsr     drawCharAt              ; 2CE7  draw it at (bottom, left)

; ----------------------------------------------------------------------
; Now the four edges.  Row and column are still at the bottom-left corner.
; ----------------------------------------------------------------------
        inc     scratch19               ; 2CEA  start one column right of the bottom-left corner
        ldy     #$07                    ; 2CEC  tile 7 = bottom edge
        jsr     drawFrameRow            ; 2CEE  run it along the bottom row up to (not including) the right corner
        ldy     windowOriginRow         ; 2CF1  back to the top row
        sty     scratch18               ; 2CF4  row of the top edge
        ldy     windowOriginCol         ; 2CF6  and one column right of the left corner
        iny                             ; 2CF9  one column right of the top-left corner
        sty     scratch19               ; 2CFA  first column of the top edge run
        ldy     #$01                    ; 2CFC  tile 1 = top edge
        jsr     drawFrameRow            ; 2CFE  run it along the top row
        ldy     windowOriginRow         ; 2D01  one row below the top-left corner
        iny                             ; 2D04  one row below the top-left corner
        sty     scratch18               ; 2D05  first row of the left edge run
        ldy     windowOriginCol         ; 2D07  in the window's left column
        sty     scratch19               ; 2D0A  the window's left column
        ldy     #$03                    ; 2D0C  tile 3 = left edge
        jsr     drawFrameColumn         ; 2D0E  run it down the left side up to (not including) the bottom corner
        ldy     windowOriginRow         ; 2D11  one row below the top-right corner
        iny                             ; 2D14  one row below the top-right corner
        sty     scratch18               ; 2D15  first row of the right edge run
        lda     scratch1A               ; 2D17  in the right-hand column
        sta     scratch19               ; 2D19  the window's right column
        ldy     #$05                    ; 2D1B  tile 5 = right edge - and fall into drawFrameColumn to draw it

; ----------------------------------------------------------------------
; drawFrameColumn - Repeats the tile at offset Y of the current tile set down column scratch19,
; starting at row scratch18 and stopping when the row reaches scratch1B (exclusive), so the corner
; already drawn there is not overwritten.  Used for the left and right frame edges.
; In:  Y = tile offset in the set, blockPtr = the tile set, scratch18 = first row, scratch19 = column,
;      scratch1B = stop row
; Out: scratch18 = scratch1B, scratch1C = the tile's character code, the cells drawn
; Called from: drawFrameBox ($2D0E) and by fall-through from $2D1B
; ----------------------------------------------------------------------
drawFrameColumn:
        lda     (blockPtr),y            ; 2D1D  fetch the edge tile's character code once
        sta     scratch1C               ; 2D1F  keep it in zp_1C - drawCharAt clobbers A

; ----------------------------------------------------------------------
; One cell per row until the bottom corner is reached.
; ----------------------------------------------------------------------
L_2D21:
        lda     scratch1C               ; 2D21  the edge tile
        jsr     drawCharAt              ; 2D23  draw it at the current row/column
        inc     scratch18               ; 2D26  step one row down
        ldy     scratch18               ; 2D28  the row we have just moved to
        cpy     scratch1B               ; 2D2A  reached the row holding the corner?
        bne     L_2D21                  ; 2D2C  not yet - keep going
        rts                             ; 2D2E  the column is finished one cell above the corner


; ----------------------------------------------------------------------
; drawFrameRow - Repeats the tile at offset Y of the current tile set along row scratch18, starting at
; column scratch19 and stopping when the column reaches scratch1A (exclusive).  Used for the top and
; bottom frame edges.
; In:  Y = tile offset in the set, blockPtr = the tile set, scratch18 = row, scratch19 = first column,
;      scratch1A = stop column
; Out: scratch19 = scratch1A, scratch1C = the tile's character code, the cells drawn
; Called from: drawFrameBox ($2CEE bottom edge, $2CFE top edge)
; ----------------------------------------------------------------------
drawFrameRow:
        lda     (blockPtr),y            ; 2D2F  fetch the edge tile's character code once
        sta     scratch1C               ; 2D31  keep it in zp_1C - drawCharAt clobbers A

; ----------------------------------------------------------------------
; One cell per column until the corner column is reached.
; ----------------------------------------------------------------------
L_2D33:
        lda     scratch1C               ; 2D33  the edge tile
        jsr     drawCharAt              ; 2D35  draw it at the current row/column
        inc     scratch19               ; 2D38  step one column right
        ldy     scratch19               ; 2D3A  the column we have just moved to
        cpy     scratch1A               ; 2D3C  reached the column holding the corner?
        bne     L_2D33                  ; 2D3E  not yet - keep going
        rts                             ; 2D40  the run is finished one cell before the corner


; ----------------------------------------------------------------------
; initBitmapScreen - Builds the game's display from scratch: a full-screen text window, grey border
; and background, colour RAM filled with grey, all 1024 screen-RAM bytes set to $CC (grey foreground
; on grey background so an empty bitmap looks uniform), the whole 8000-byte bitmap $A000-$BF3F
; cleared, and then the three VIC control registers and their shadows set for a hires bitmap in VIC
; bank 2 - $D016 = $C8 (40 columns, hires), $D018 = $38 (video matrix $8C00, bitmap $A000) and $D011 =
; $3B (bitmap mode, 25 rows, Y-scroll 3).  Finally initSpritePointers rewrites the eight sprite
; pointers the screen fill just overwrote.
; In:  none
; Out: VIC_BORDER/BG0/CTRL1/CTRL2/MEM_PTR and the shadows $9000-$9004; colour RAM, screen RAM and
;     bitmap
;      cleared; screenBuiltFlag = 1, decimalValueHigh = 0, spriteEnableShadow = 0, sprite pointers
;      $8FF8-$8FFF = $28-$2F; mapCellPtr (zp_48/49) clobbered
; Called from: mainStart ($0AAF), drawBattlefieldScreen ($2636), the screen switcher ($06E8) and the
;      start-up block ($0318 by JMP)
; ----------------------------------------------------------------------
initBitmapScreen:
        jsr     setFullScreenWindow     ; 2D41  text window = the whole 40x25 screen
        lda     #$01                    ; 2D44  mark the screen as (re)built
        sta     screenBuiltFlag         ; 2D46  every screen builder sets this flag; nothing ever reads it
        lda     #$0C                    ; 2D48  colour $0C = medium grey
        sta     vicBorderShadow         ; 2D4A  shadow the IRQ writes back to $D020
        sta     VIC_BORDER              ; 2D4D  grey border
        sta     vicBg0Shadow            ; 2D50  shadow of $D021
        sta     VIC_BG0                 ; 2D53  grey background

; ----------------------------------------------------------------------
; Fill all four pages of colour RAM ($D800-$DBFF) with grey.
; ----------------------------------------------------------------------
        ldy     #$00                    ; 2D56  256 offsets per page
        lda     #$0C                    ; 2D58  grey again (the STAs below clobber A)
L_2D5A:
        sta     COLOR_RAM,y             ; 2D5A  $D800-$D8FF
        sta     D_D900,y                ; 2D5D  $D900-$D9FF
        sta     D_DA00,y                ; 2D60  $DA00-$DAFF
        sta     D_DB00,y                ; 2D63  $DB00-$DBFF (only $D800-$DBE7 is real colour RAM)
        iny                             ; 2D66  next offset in the page
        bne     L_2D5A                  ; 2D67  256 bytes per page

; ----------------------------------------------------------------------
; Fill the video matrix $8C00-$8FFF with $CC: in bitmap mode the high nibble is the '1' colour and the
; low nibble the '0' colour, so grey on grey hides the empty bitmap.
; ----------------------------------------------------------------------
        ldy     #$00                    ; 2D69  256 offsets per page
        lda     #$CC                    ; 2D6B  $CC = grey foreground, grey background
L_2D6D:
        sta     D_8C00,y                ; 2D6D  $8C00-$8CFF
        sta     D_8D00,y                ; 2D70  $8D00-$8DFF
        sta     D_8E00,y                ; 2D73  $8E00-$8EFF
        sta     D_8F00,y                ; 2D76  $8F00-$8FFF (this also wipes the sprite pointers at $8FF8, restored below)
        iny                             ; 2D79  next offset in the page
        bne     L_2D6D                  ; 2D7A  all four pages of the video matrix

; ----------------------------------------------------------------------
; Clear the 8000-byte bitmap $A000-$BF3F (25 rows x 320 bytes).
; ----------------------------------------------------------------------
        lda     #$00                    ; 2D7C  point zp_48/49 at the start of the bitmap ...
        sta     mapCellPtr              ; 2D7E  start of the bitmap ...
        lda     #$A0                    ; 2D80  ...
        sta     mapCellPtrHi            ; 2D82  ... $A000
        lda     #$00                    ; 2D84  0 for the store below and for the bitmap fill
        sta     decimalValueHigh        ; 2D86  clear the third byte of printDecimal's 24-bit accumulator (zp_7E); the printer clears it again
        ldy     #$FF                    ; 2D88  fill each page from offset $FF downwards
        lda     #$00                    ; 2D8A  the bitmap is cleared to background pixels
L_2D8C:
        sta     (mapCellPtr),y          ; 2D8C  clear one bitmap byte
        dey                             ; 2D8E  work down through the page
        cpy     #$FF                    ; 2D8F  wrapped past 0 - the page is done
        bne     L_2D8C                  ; 2D91  not wrapped yet - keep clearing
        inc     mapCellPtrHi            ; 2D93  next page
        ldx     mapCellPtrHi            ; 2D95  check how far up the bitmap we have got
        cpx     #$BF                    ; 2D97  $BF00 is the last, partial page
        bcc     L_2D8C                  ; 2D99  still below it - clear a whole page
        bne     L_2DA1                  ; 2D9B  past $BF00 - the bitmap is finished
        ldy     #$3F                    ; 2D9D  $BF00-$BF3F: the final 64 bytes of the 8000-byte bitmap
        bne     L_2D8C                  ; 2D9F  always taken

; ----------------------------------------------------------------------
; Switch the VIC into the game's display mode.
; ----------------------------------------------------------------------
L_2DA1:
        lda     #$C8                    ; 2DA1  $D016 = $C8: hires (bit 4 clear), 40 columns (bit 3), X-scroll 0
        sta     vicCtrl2Shadow          ; 2DA3  shadow the raster IRQ writes back to $D016
        sta     VIC_CTRL2               ; 2DA6  and the register itself
        lda     #$38                    ; 2DA9  $D018 = $38: video matrix at bank+$0C00 = $8C00, bitmap at bank+$2000 = $A000
        sta     vicMemPtrShadow         ; 2DAB  shadow of $D018
        sta     VIC_MEM_PTR             ; 2DAE  and the register itself
        lda     #$3B                    ; 2DB1  $D011 = $3B: display on, bitmap mode, 25 rows, Y-scroll 3
        sta     vicCtrl1Shadow          ; 2DB3  shadow of $D011
        sta     VIC_CTRL1               ; 2DB6  and the register itself - the bitmap screen is now live
        jsr     initSpritePointers      ; 2DB9  put the eight sprite pointers back after the screen fill
        rts                             ; 2DBC  the display is ready


; ----------------------------------------------------------------------
; setTerrainTilePtr - Points glyphPtr at the 8-byte terrain tile definition number A in the table at
; $9C6E and turns off the per-glyph colour override so the caller's drawColourByte is used.  The
; caller at
; $27B2 forms A by subtracting $40 from the map byte, and $27C2 expands the 8 bytes into the
; 16x16-pixel
; cell of the magnified battlefield view.
; In:  A = terrain tile index (map cell byte - $40)
; Out: glyphPtr/glyphPtrHi (zp_B2/B3) = $9C6E + A*8, useDrawColourFlag = 0, Y = 0
; Called from: renderMapTile ($27B8) and the radar tile renderer of overlay B ($73A4)
; ----------------------------------------------------------------------
setTerrainTilePtr:
        ldy     #$00                    ; 2DBD  Y = 0 is used as the zero source for the three stores below
        sty     glyphPtr                ; 2DBF  clear the tile pointer before building it
        sty     glyphPtrHi              ; 2DC1  clear the high byte so the three ROLs can shift into it
        sty     useDrawColourFlag       ; 2DC3  0 = let the caller's drawColourByte colour the cell
        asl     a                       ; 2DC6  index * 2 ...
        rol     glyphPtrHi              ; 2DC7  carry the overflow into the high byte
        asl     a                       ; 2DC9  ... * 4 ...
        rol     glyphPtrHi              ; 2DCA  carry again
        asl     a                       ; 2DCC  ... * 8: eight bytes per terrain tile
        rol     glyphPtrHi              ; 2DCD  and again - glyphPtrHi holds bits 8-10 of index*8
        clc                             ; 2DCF  clear the carry for the base-address add
        adc     #$6E                    ; 2DD0  add the low byte of the tile table base $9C6E
        sta     glyphPtr                ; 2DD2  low byte of $9C6E + index*8
        lda     glyphPtrHi              ; 2DD4  bits 8-10 of index*8
        adc     #$9C                    ; 2DD6  and its high byte
        sta     glyphPtrHi              ; 2DD8  zp_B2/B3 now points at the 8-byte terrain tile
        rts                             ; 2DDA  the caller can now read the eight tile bytes through (glyphPtr),y


; ----------------------------------------------------------------------
; clearMessageLineAndScreen - Wipes the status/message line first, then falls into
; clearCurrentScreenWindows to clear the rest of whatever screen is showing.
; In:  currentScreen ($90FB)
; Out: see clearCurrentScreenWindows
; Called from: the F1/F3/F5/F7 screen switcher at $070C
; ----------------------------------------------------------------------
clearMessageLineAndScreen:
        jsr     clearMessageLine        ; 2DDB  blank the bottom message line ($C39F)

; ----------------------------------------------------------------------
; clearCurrentScreenWindows - Re-clears the content areas of whichever screen is active, dispatching
; on currentScreen: 0 = battlefield (sprites off, text colour 0, the console info panel unless the
; game is paused, then the overview window and the viewport text area), 1 = the comcen radar screen
; (overlay B's
; $7A6B), 2 = the drone screen (only sprite 1 enabled, then overlay B's $821F), 3 = the menu screen.
; All
; four exits are tail calls.
; In:  currentScreen ($90FB), pauseState (zp_BE)
; Out: spriteEnableShadow = 0 (or 2 on the drone screen), textColour = 0 on the battlefield; the
;      per-screen clear routine is jumped to
; Called from: clearMessageLineAndScreen by fall-through, and from the text engine at $C75E (after the
;      text window has been saved, e.g. when a pause ends)
; ----------------------------------------------------------------------
clearCurrentScreenWindows:
        lda     #$00                    ; 2DDE  0 is used both as the sprite mask and as the text colour below
        sta     spriteEnableShadow      ; 2DE0  hide every sprite while the screen is rebuilt
        ldy     currentScreen           ; 2DE3  which screen is showing? 0 battlefield, 1 comcen radar, 2 drone, 3 menu
        bne     L_2DF7                  ; 2DE6  not the battlefield - dispatch below
        sta     textColour              ; 2DE8  battlefield: draw the panels in colour 0
        lda     pauseState              ; 2DEA  pause / disconnect flags
        bne     L_2DF1                  ; 2DEC  while paused the console keeps the pause text - do not wipe it
        jsr     clearInfoPanel          ; 2DEE  clear the console info panel (rows 12-21, columns 25-38)
L_2DF1:
        jsr     clearOverviewWindow     ; 2DF1  clear the 20x20 overview map window
        jmp     clearViewportTextArea   ; 2DF4  clear the text area of the battlefield viewport and return through it

L_2DF7:
        dey                             ; 2DF7  screen 1?
        bne     L_2DFD                  ; 2DF8  no
        jmp     redrawRadarMapWindow    ; 2DFA  comcen radar screen - overlay B redraws the radar map window

L_2DFD:
        dey                             ; 2DFD  screen 2?
        bne     L_2E0A                  ; 2DFE  no - it must be screen 3, the menu
        lda     #$02                    ; 2E00  drone screen: only sprite 1 (the drone marker) stays visible
        sta     spriteEnableShadow      ; 2E02  %00000010 - sprite 1 only
        lda     #$00                    ; 2E05  argument 0 = clear the whole drone view window
        jmp     fillDroneViewWindow     ; 2E07  overlay B's drone window filler (variant A holds unrelated code at this address)  (also mapGenerator6F00:runGameTypeMenu)

L_2E0A:
        jmp     clearMenuWindowPlain    ; 2E0A  menu screen: plain clear of the menu window


; Two work flags of magnifyWindow2x, set when the source rectangle has an odd width or height so that
; its outermost row/column is drawn at half size and the zoom stays centred.
magnifyWidthOdd:
        .byte   $00                     ; 2E0D  .  magnifyWidthOdd: $FF when frameWidth is odd
magnifyHeightOdd:
        .byte   $00                     ; 2E0E  magnifyHeightOdd: $FF when frameHeight is odd

; ----------------------------------------------------------------------
; magnifyWindow2x - Redraws the character rectangle at windowOriginRow/windowOriginCol, frameWidth by
; frameHeight cells, at twice the size in place and centred on itself: every source cell (its 8 bitmap
; bytes plus its screen-RAM colour byte) is re-plotted as a 2x2 cell block through the text engine's
; double-size glyph renderer plotGlyphDoubleSize ($C24B), at (windowOriginRow - ceil(h/2) + 2*row,
; windowOriginCol - ceil(w/2) + 2*col).  The rectangle is walked as four quadrants, each from the
; outer edge inwards, so no source cell is destroyed before it has been read.  An odd width or height
; makes the routine step half a cell beyond each edge and suppress the outer half of those cells
; through the quadrant mask magnifyEdgeMask (zp_BA), which keeps the zoom centred.  It first waits for
; raster line $80 to reduce tearing.
; In:  windowOriginRow, windowOriginCol, frameWidth ($9221), frameHeight ($9222)
; Out: the area redrawn twice the size; frameWidth/frameHeight decremented when even; scratch20 =
;      ceil(w/2), scratch21 = ceil(h/2), textCursorCol/textCursorRow (zp_3F/zp_40) = destination
; origin,
;      magnifySrcRow/Col (zp_30/31) = counters, textColour = the current cell's colour,
; magnifyEdgeMask
;      left at 0, zp_B2-zp_B9 clobbered
; Called from: overlay B's zoom-in animation at $756B, four times over a 7-wide by 6-tall area at row
;     9,
;      column 16
; ----------------------------------------------------------------------
magnifyWindow2x:
        lda     #$00                    ; 2E0F  clear both odd-size flags before measuring the rectangle
        sta     magnifyWidthOdd         ; 2E11  assume an even width ...
        sta     magnifyHeightOdd        ; 2E14  ... and an even height
        lda     frameWidth              ; 2E17  source rectangle width in cells
        lsr     a                       ; 2E1A  half of it, carry = the odd bit
        sta     scratch20               ; 2E1B  zp_20 = how many columns each half-pass covers
        bcc     L_2E28                  ; 2E1D  even width - nothing special to do
        inc     scratch20               ; 2E1F  odd: round the half width up
        lda     #$FF                    ; 2E21  $FF = odd
        sta     magnifyWidthOdd         ; 2E23  remember that the left/right edge cells are drawn half width
        bmi     L_2E2B                  ; 2E26  always taken ($FF is negative)
L_2E28:
        dec     frameWidth              ; 2E28  even width: turn the count into the index of the last column
L_2E2B:
        lda     windowOriginCol         ; 2E2B  source left column ...
        sec                             ; 2E2E  prepare the subtraction
        sbc     scratch20               ; 2E2F  ... minus half the width = destination left column
        sta     textCursorCol           ; 2E31  zp_3F holds the destination origin column (the text cursor is free here)
        lda     frameHeight             ; 2E33  source rectangle height in cells
        lsr     a                       ; 2E36  half of it
        sta     scratch21               ; 2E37  zp_21 = how many rows each half-pass covers
        bcc     L_2E44                  ; 2E39  even height
        inc     scratch21               ; 2E3B  odd: round the half height up
        lda     #$FF                    ; 2E3D  $FF = odd
        sta     magnifyHeightOdd        ; 2E3F  remember that the top/bottom edge cells are drawn half height
        bmi     L_2E47                  ; 2E42  always taken
L_2E44:
        dec     frameHeight             ; 2E44  even height: turn the count into the index of the last row
L_2E47:
        lda     windowOriginRow         ; 2E47  source top row ...
        sec                             ; 2E4A  prepare the subtraction
        sbc     scratch21               ; 2E4B  ... minus half the height = destination top row
        sta     textCursorRow           ; 2E4D  zp_40 holds the destination origin row

; ----------------------------------------------------------------------
; Wait for the raster to reach line $80 so the redraw starts at a predictable place on screen.
; ----------------------------------------------------------------------
L_2E4F:
        lda     VIC_RASTER              ; 2E4F  current raster line
        cmp     #$80                    ; 2E52  line 128, roughly the middle of the display
        bne     L_2E4F                  ; 2E54  spin until it comes round

; ----------------------------------------------------------------------
; Quadrant pass 1 and 2: the top half of the rectangle, row 0 downwards.  For each row the left half
; is drawn left-to-right and then the right half right-to-left, so a cell is always read before the
; enlarged copy of its neighbour can cover it.
; ----------------------------------------------------------------------
        lda     #$00                    ; 2E56  top-half row counter
        sta     magnifySrcRow           ; 2E58  start at the topmost source row
        lda     magnifyHeightOdd        ; 2E5A  odd height?
        beq     L_2E63                  ; 2E5D  no - no vertical clipping on this row
        lda     #$01                    ; 2E5F  bit 0 = suppress the top cells of the 2x2 block ...
        sta     magnifyEdgeMask         ; 2E61  ... so the top row is drawn only half tall
L_2E63:
        lda     #$00                    ; 2E63  left-half column counter
        sta     magnifySrcCol           ; 2E65  start at the leftmost source column
        lda     magnifyWidthOdd         ; 2E67  odd width?
        beq     L_2E72                  ; 2E6A  no - no horizontal clipping on this column
        lda     magnifyEdgeMask         ; 2E6C  add to the mask that is already there
        ora     #$04                    ; 2E6E  bit 2 = suppress the left cells of the 2x2 block
        sta     magnifyEdgeMask         ; 2E70  so the leftmost column is drawn half wide
L_2E72:
        jsr     setMagnifyCellPointers  ; 2E72  set up source and destination pointers for this cell
        ldy     #$00                    ; 2E75  the colour byte is the single byte at the source screen cell
        lda     (blockPtr),y            ; 2E77  the cell's colour byte, read from the source screen RAM copy in zp_B8/B9
        sta     textColour              ; 2E79  plotGlyphDoubleSize colours all four cells with zp_3D
        jsr     plotGlyphDoubleSize     ; 2E7B  expand the cell into a 2x2 block at the destination
        lda     magnifyEdgeMask         ; 2E7E  read the mask back
        and     #$03                    ; 2E80  clear the left/right suppression bits, keep the top/bottom ones for the rest of the row
        sta     magnifyEdgeMask         ; 2E82  only the top/bottom bits survive into the next cell
        inc     magnifySrcCol           ; 2E84  next column to the right
        lda     magnifySrcCol           ; 2E86  the column just drawn
        cmp     scratch20               ; 2E88  compare against the half width
        bcc     L_2E72                  ; 2E8A  keep going until the middle of the row
        lda     frameWidth              ; 2E8C  now the right half: start at the outermost column ...
        sta     magnifySrcCol           ; 2E8F  ... which for an odd width is half a cell past the rectangle
        lda     magnifyWidthOdd         ; 2E91  odd width?
        beq     L_2E9C                  ; 2E94  no
        lda     magnifyEdgeMask         ; 2E96  add to the mask that is already there
        ora     #$08                    ; 2E98  bit 3 = suppress the right cells, so this column is drawn half wide
        sta     magnifyEdgeMask         ; 2E9A  so the rightmost column is drawn half wide
L_2E9C:
        jsr     setMagnifyCellPointers  ; 2E9C  set up the pointers for this cell
        ldy     #$00                    ; 2E9F  the colour byte is the single byte at the source screen cell
        lda     (blockPtr),y            ; 2EA1  its colour byte
        sta     textColour              ; 2EA3  colour for the 2x2 block
        jsr     plotGlyphDoubleSize     ; 2EA5  expand it
        lda     magnifyEdgeMask         ; 2EA8  read the mask back
        and     #$03                    ; 2EAA  drop the left/right suppression again
        sta     magnifyEdgeMask         ; 2EAC  only the top/bottom bits survive into the next cell
        dec     magnifySrcCol           ; 2EAE  next column to the left
        lda     magnifySrcCol           ; 2EB0  the column just drawn
        cmp     scratch20               ; 2EB2  compare against the half width
        bcs     L_2E9C                  ; 2EB4  keep going while we are still right of the middle
        lda     magnifyEdgeMask         ; 2EB6  read the mask back
        and     #$0C                    ; 2EB8  row finished: drop the top/bottom suppression, keep nothing else
        sta     magnifyEdgeMask         ; 2EBA  only the left/right bits survive into the next row
        inc     magnifySrcRow           ; 2EBC  next row down
        lda     magnifySrcRow           ; 2EBE  the row just finished
        cmp     scratch21               ; 2EC0  compare against the half height
        bcc     L_2E63                  ; 2EC2  keep going until the middle row

; ----------------------------------------------------------------------
; Quadrant pass 3 and 4: the bottom half, walked upwards from the outermost row.
; ----------------------------------------------------------------------
        lda     frameHeight             ; 2EC4  start at the outermost row ...
        sta     magnifySrcRow           ; 2EC7  ... which for an odd height is half a cell below the rectangle
        lda     magnifyHeightOdd        ; 2EC9  odd height?
        beq     L_2ED2                  ; 2ECC  no
        lda     #$02                    ; 2ECE  bit 1 = suppress the bottom cells ...
        sta     magnifyEdgeMask         ; 2ED0  ... so this row is drawn only half tall
L_2ED2:
        lda     #$00                    ; 2ED2  left-half column counter
        sta     magnifySrcCol           ; 2ED4  start at the leftmost source column again
        lda     magnifyWidthOdd         ; 2ED6  odd width?
        beq     L_2EE1                  ; 2ED9  no
        lda     magnifyEdgeMask         ; 2EDB  add to the mask that is already there
        ora     #$04                    ; 2EDD  bit 2 = suppress the left cells
        sta     magnifyEdgeMask         ; 2EDF  so the leftmost column is drawn half wide
L_2EE1:
        jsr     setMagnifyCellPointers  ; 2EE1  set up the pointers for this cell
        ldy     #$00                    ; 2EE4  read the source cell's colour byte
        lda     (blockPtr),y            ; 2EE6  its colour byte
        sta     textColour              ; 2EE8  colour for the 2x2 block
        jsr     plotGlyphDoubleSize     ; 2EEA  expand it
        lda     magnifyEdgeMask         ; 2EED  read the mask back
        and     #$03                    ; 2EEF  drop the left/right suppression
        sta     magnifyEdgeMask         ; 2EF1  only the top/bottom bits survive into the next cell
        inc     magnifySrcCol           ; 2EF3  next column to the right
        lda     magnifySrcCol           ; 2EF5  the column just drawn
        cmp     scratch20               ; 2EF7  compare against the half width
        bcc     L_2EE1                  ; 2EF9  until the middle of the row
        lda     frameWidth              ; 2EFB  right half of this row: outermost column ...
        sta     magnifySrcCol           ; 2EFE  start the right half at the outermost column
        lda     magnifyWidthOdd         ; 2F00  odd width?
        beq     L_2F0B                  ; 2F03  no
        lda     magnifyEdgeMask         ; 2F05  add to the mask that is already there
        ora     #$08                    ; 2F07  bit 3 = suppress the right cells
        sta     magnifyEdgeMask         ; 2F09  so the rightmost column is drawn half wide
L_2F0B:
        jsr     setMagnifyCellPointers  ; 2F0B  set up the pointers for this cell
        ldy     #$00                    ; 2F0E  read the source cell's colour byte
        lda     (blockPtr),y            ; 2F10  its colour byte
        sta     textColour              ; 2F12  colour for the 2x2 block
        jsr     plotGlyphDoubleSize     ; 2F14  expand it
        lda     magnifyEdgeMask         ; 2F17  read the mask back
        and     #$03                    ; 2F19  drop the left/right suppression
        sta     magnifyEdgeMask         ; 2F1B  only the top/bottom bits survive into the next cell
        dec     magnifySrcCol           ; 2F1D  next column to the left
        lda     magnifySrcCol           ; 2F1F  the column just drawn
        cmp     scratch20               ; 2F21  compare against the half width
        bcs     L_2F0B                  ; 2F23  while still right of the middle
        lda     magnifyEdgeMask         ; 2F25  read the mask back
        and     #$0C                    ; 2F27  row finished - this also leaves magnifyEdgeMask at 0 on the final pass
        sta     magnifyEdgeMask         ; 2F29  only the left/right bits survive into the next row
        dec     magnifySrcRow           ; 2F2B  next row up
        lda     magnifySrcRow           ; 2F2D  the row just finished
        cmp     scratch21               ; 2F2F  compare against the half height
        bcs     L_2ED2                  ; 2F31  keep going while still below the middle row
        rts                             ; 2F33  the whole rectangle has been redrawn at double size


; ----------------------------------------------------------------------
; setMagnifyCellPointers - Helper of magnifyWindow2x.  Computes the pointers of the source cell
; (windowOriginRow + magnifySrcRow, windowOriginCol + magnifySrcCol), copies them into glyphPtr (so
; the bitmap cell is used as the 'glyph' to enlarge) and blockPtr (so its screen-RAM colour byte can
; be read), then computes the destination cell (zp_40 + 2*row, zp_3F + 2*column) into
; bitmapPtr/screenPtr.
; In:  magnifySrcRow/magnifySrcCol (zp_30/31), windowOriginRow/Col, textCursorRow/Col (zp_40/zp_3F)
; Out: glyphPtr (zp_B2/B3) = source bitmap cell, blockPtr (zp_B8/B9) = source screen cell,
;      bitmapPtr/screenPtr (zp_B4-B7) = destination cell, scratch18/19 = destination row/column
; Called from: magnifyWindow2x ($2E72, $2E9C, $2EE1, $2F0B)
; ----------------------------------------------------------------------
setMagnifyCellPointers:
        clc                             ; 2F34  clear the carry for the row add
        lda     magnifySrcRow           ; 2F35  row offset within the rectangle ...
        adc     windowOriginRow         ; 2F37  ... plus the rectangle's top row
        sta     scratch18               ; 2F3A  absolute source row
        clc                             ; 2F3C  clear the carry for the column add
        lda     magnifySrcCol           ; 2F3D  column offset within the rectangle ...
        adc     windowOriginCol         ; 2F3F  ... plus the rectangle's left column
        sta     scratch19               ; 2F42  absolute source column
        jsr     calcCellPointers        ; 2F44  zp_B4-zp_B7 now point at the source cell

; ----------------------------------------------------------------------
; Copy both source pointers aside before they are recomputed for the destination.  LDA zp,Y does not
; exist on the 6502, so these are absolute accesses into page zero.
; ----------------------------------------------------------------------
        ldy     #$01                    ; 2F47  two bytes each, high byte first
L_2F49:
        lda     a:bitmapPtr,y           ; 2F49  bitmapPtr -> ...
        sta     a:glyphPtr,y            ; 2F4C  ... glyphPtr: plotGlyphDoubleSize reads the source cell as if it were a font glyph
        lda     a:screenPtr,y           ; 2F4F  screenPtr -> ...
        sta     a:blockPtr,y            ; 2F52  ... blockPtr: the caller reads the source cell's colour byte through it
        dey                             ; 2F55  high byte then low byte
        bpl     L_2F49                  ; 2F56  two bytes of each pointer copied

; ----------------------------------------------------------------------
; Now the destination cell: the zoom doubles every offset.
; ----------------------------------------------------------------------
        lda     magnifySrcRow           ; 2F58  row offset within the rectangle
        asl     a                       ; 2F5A  row offset * 2
        clc                             ; 2F5B  clear the carry for the origin add
        adc     textCursorRow           ; 2F5C  plus the destination top row
        sta     scratch18               ; 2F5E  destination row
        lda     magnifySrcCol           ; 2F60  column offset within the rectangle
        asl     a                       ; 2F62  column offset * 2 (no CLC needed - the offset is always below $80, so ASL clears the carry)
        adc     textCursorCol           ; 2F63  plus the destination left column
        sta     scratch19               ; 2F65  destination column
        jmp     calcCellPointers        ; 2F67  leave zp_B4-zp_B7 pointing at the destination cell and return through it


; ----------------------------------------------------------------------
; initSpritePointers - Rewrites the eight sprite pointers in the last bytes of the video matrix,
; $8FF8-$8FFF, with $28..$2F.  In VIC bank 2 a pointer value n means shape data at $8000 + n*64, so
; sprite 0 takes its shape from $8A00 and sprite 7 from $8BC0 - the 512-byte block clearAllSpriteData
; wipes.  Also clears the sprite enable shadow.  Needed after initBitmapScreen fills $8C00-$8FFF.
; In:  none
; Out: spriteEnableShadow = 0, $8FF8-$8FFF = $28-$2F; mapCellPtr (zp_48/49) = $8FF8; A/X/Y clobbered
; Called from: initBitmapScreen ($2DB9) only
; ----------------------------------------------------------------------
initSpritePointers:
        lda     #$00                    ; 2F6A  no sprite is on screen while the display is rebuilt
        sta     spriteEnableShadow      ; 2F6C  no sprites visible yet
        lda     #$F8                    ; 2F6F  low byte of $8FF8 ...
        sta     mapCellPtr              ; 2F71  ...
        lda     #$8F                    ; 2F73  ... high byte
        sta     mapCellPtrHi            ; 2F75  zp_48/49 = $8FF8, the first sprite pointer
        ldy     #$07                    ; 2F77  eight pointers, counted down
        ldx     #$2F                    ; 2F79  $2F = sprite 7's shape at $8000 + $2F*64 = $8BC0
L_2F7B:
        txa                             ; 2F7B  the pointer value for this sprite
        sta     (mapCellPtr),y          ; 2F7C  $8FF8+Y = the pointer of sprite Y
        dex                             ; 2F7E  next shape block down
        dey                             ; 2F7F  next sprite down
        bpl     L_2F7B                  ; 2F80  until sprite 0 gets $28 = $8A00
        adc     #$01                    ; 2F82  leftover: A becomes $29/$2A and is never used by the only caller
        rts                             ; 2F84  the eight sprite pointers are back in place


; The two bit-mask tables.  spriteBitMasks is the set mask for bit n and is used both for sprite
; enable bits ($3065) and as the keyboard column mask ($0E16); invertedBitMasks is its complement,
; used to clear a sprite bit ($337D) and as the keyboard row mask ($0E08).
spriteBitMasks:
        .byte   $01,$02,$04,$08,$10,$20,$40,$80; 2F85  ..... @.  set masks $01,$02,$04 ... $80 for bit / sprite / keyboard column n
invertedBitMasks:
        .byte   $FE                     ; 2F8D  clear masks $FE,$FD,$FB ... $7F - the same table complemented (was D_2F8D)
        .byte   $FD,$FB,$F7,$EF,$DF,$BF,$7F; 2F8E  .......

; Sprite shape addresses split into two tables so that $8A00 + n*64 can be formed with a single 16-bit
; add (see clearSpriteShape).
spriteOffsetLoTable:
        .byte   $00,$40,$80,$C0,$00,$40,$80,$C0; 2F95  .@...@..  low byte of n*64 for sprites 0-7

; spriteOffsetHiTable: byteTable, 8 bytes. High byte of n*64 for sprite 0-7 (0,0,0,0,1,1,1,1)
spriteOffsetHiTable:
        .byte   $00,$00,$00,$00,$01,$01,$01,$01; 2F9D  ........  high byte of n*64: 0 for sprites 0-3, 1 for sprites 4-7

; ----------------------------------------------------------------------
; clearAllSpriteData - Zeroes $8A00-$8BFF, the 64-byte shape blocks of all eight sprites, in one
; 256-pass loop that writes two pages at a time.
; In:  none
; Out: $8A00-$8BFF = 0; A = 0, X = 0
; Called from: the start-up block at $0386 (by JMP, but the routine ends in RTS)
; ----------------------------------------------------------------------
clearAllSpriteData:
        ldx     #$00                    ; 2FA5  one pass over 256 offsets
        txa                             ; 2FA7  the fill value is 0
L_2FA8:
        sta     sprite0Shape,x          ; 2FA8  sprites 0-3 ($8A00-$8AFF)
        sta     sprite4Shape,x          ; 2FAB  sprites 4-7 ($8B00-$8BFF)
        inx                             ; 2FAE  next offset
        bne     L_2FA8                  ; 2FAF  256 offsets, both pages at once
        rts                             ; 2FB1  all eight sprite shapes are blank


; ----------------------------------------------------------------------
; clearSpriteShape - Zeroes the 64-byte shape of sprite A at $8A00 + A*64.  The address is built from
; the two half tables and patched into the operand of the STA abs,Y at $2FC8, which in the raw image
; still holds its unpatched $FFFF operand (hence the 'sta irqVectorHi,y' the disassembler shows).
; In:  A = sprite number 0-7
; Out: that sprite's 64 shape bytes are 0; the operand at $2FC9/$2FCA is patched; A = 0, Y = $FF
; Called from: $2245, $224A, setCursorShapeSquare ($2FDB), setCursorShapeBlock ($2FFD), $3385, $346A,
;      $3CFF and the jump at $3D53, plus the overlay screens ($7184, $76A1, $7781, $8241)
; ----------------------------------------------------------------------
clearSpriteShape:
        tay                             ; 2FB2  index the two half tables with the sprite number
        clc                             ; 2FB3  clear the carry for the 16-bit add
        lda     #$00                    ; 2FB4  the low half of the base address $8A00 is zero
        adc     spriteOffsetLoTable,y   ; 2FB6  low byte of sprite*64
        sta     L_2FC8+1                ; 2FB9  patch the low operand byte of the STA at $2FC8
        lda     #$8A                    ; 2FBC  high byte of the shape area base $8A00 ...
        adc     spriteOffsetHiTable,y   ; 2FBE  ... plus the high byte of sprite*64
        sta     L_2FC8+2                ; 2FC1  patch the high operand byte
        ldy     #$3F                    ; 2FC4  64 bytes per sprite shape
        lda     #$00                    ; 2FC6  the fill value
L_2FC8:
        sta     irqVectorHi,y           ; 2FC8  operand patched at $2FB9/$2FC1 - really STA $8A00+sprite*64,Y
        dey                             ; 2FCB  work down through the 64 bytes
        bpl     L_2FC8                  ; 2FCC  all 64 bytes cleared
        rts                             ; 2FCE  the sprite is blank and ready to be filled


; ----------------------------------------------------------------------
; setCursorShapeSquare - Gives sprite 0, the map cursor, the 18-byte (6 rows of 24 pixels) hollow
; square at $9E6E, sets its colour shadow to 0 and enables it.  The sprite is Y-expanded elsewhere so
; the six rows cover a whole 12-pixel map cell.
; In:  none
; Out: sprite 0's shape at $8A00, spriteColourShadow ($9008) = 0, sprite 0 enabled
; Called from: $2015, $4C53, $5E40, $5E87, $61F5, $6393 - whenever a unit is picked up or ordered
; ----------------------------------------------------------------------
setCursorShapeSquare:
        lda     #$6E                    ; 2FCF  low byte of the hollow-square shape at $9E6E
        ldy     #$9E                    ; 2FD1  high byte

; ----------------------------------------------------------------------
; installCursorShape - Shared tail of setCursorShapeSquare and setCursorShapeCross: copies the 18-byte
; shape whose address is in A/Y into sprite 0, after blanking the rest of the sprite, and enables it
; in black.  The source address is planted in the operand of the LDA abs,Y at $2FE0.
; In:  A/Y = lo/hi address of an 18-byte (6 row) sprite shape
; Out: sprite 0's shape rewritten, spriteColourShadow = 0, spriteEnableShadow bit 0 set; the operand
;     at
;      $2FE1/$2FE2 patched
; Called from: setCursorShapeSquare by fall-through and setCursorShapeCross ($2FF7 by JMP)
; ----------------------------------------------------------------------
installCursorShape:
        sta     L_2FE0+1                ; 2FD3  patch the low operand byte of the LDA at $2FE0
        sty     L_2FE0+2                ; 2FD6  and its high operand byte
        lda     #$00                    ; 2FD9  sprite 0 = the map cursor
        jsr     clearSpriteShape        ; 2FDB  blank all 64 bytes so the previous shape cannot show through
        ldy     #$11                    ; 2FDE  18 bytes = 6 rows of 3 bytes
L_2FE0:
        lda     irqVectorHi,y           ; 2FE0  operand patched at $2FD3/$2FD6 - really LDA $9E6E,Y or $9E80,Y
        sta     sprite0Shape,y          ; 2FE3  sprite 0's shape data at $8A00
        dey                             ; 2FE6  work down through the 18 bytes
        bpl     L_2FE0                  ; 2FE7  18 bytes copied
        lda     #$00                    ; 2FE9  colour 0 = black; shadow values below $10 are steady, $10 and above flash between two nibbles
        sta     spriteColourShadow      ; 2FEB  sprite 0's colour shadow, applied to $D027 by the raster IRQ
        lda     #$01                    ; 2FEE  sprite 0's enable bit
        jmp     enableSprites           ; 2FF0  OR it into the enable shadow and return through it


; ----------------------------------------------------------------------
; setCursorShapeCross - The same as setCursorShapeSquare but installs the thick plus/cross shape at
; $9E80 instead.  Used for the ranged attack cursor, when cursorTargetMode (zp_BB) is positive.
; In:  none
; Out: sprite 0 = the cross shape, colour 0, enabled
; Called from: the cursor selector at $2195
; ----------------------------------------------------------------------
setCursorShapeCross:
        lda     #$80                    ; 2FF3  low byte of the cross shape at $9E80
        ldy     #$9E                    ; 2FF5  high byte
        jmp     installCursorShape      ; 2FF7  share the copy/enable tail


; ----------------------------------------------------------------------
; setCursorShapeBlock - Loads the small 4x4 solid block at $9E92 into the sprite named by
; activeSpriteIndex ($91CC) and sets that sprite's colour shadow to $0C (grey) - except for sprite 0,
; which keeps whatever colour the cursor code gave it.  Note the shape address is formed with six
; ASLs, so it only reaches sprites 0-3; both callers pass 0 or 1.
; In:  activeSpriteIndex ($91CC) = sprite number (0 or 1 in practice)
; Out: that sprite's 64-byte shape holds the block; spriteColourShadow[n] = $0C for n other than 0
; Called from: the cursor selector at $21A8 (plain battlefield cursor) and $2550 (sprite 1 when no
;     group
;      box is up)
; ----------------------------------------------------------------------
setCursorShapeBlock:
        lda     activeSpriteIndex       ; 2FFA  which sprite is being dressed
        jsr     clearSpriteShape        ; 2FFD  blank its 64 shape bytes first
        lda     activeSpriteIndex       ; 3000  the sprite number again
        asl     a                       ; 3003  sprite number * 2 ...
        asl     a                       ; 3004  ... * 4 ...
        asl     a                       ; 3005  ... * 8 ...
        asl     a                       ; 3006  ... * 16 ...
        asl     a                       ; 3007  ... * 32 ...
        asl     a                       ; 3008  ... * 64 = its offset inside the shape block (wraps above sprite 3)
        tax                             ; 3009  X = the byte offset of this sprite's shape inside $8A00
        ldy     #$00                    ; 300A  start at the first byte of the shape
L_300C:
        lda     titleScreenRow16Col18,y ; 300C  the 18-byte solid block shape at $9E92  (also graphicsData9300:cursorBlockShape)
        sta     sprite0Shape,x          ; 300F  into $8A00 + sprite*64
        inx                             ; 3012  next destination byte
        iny                             ; 3013  next source byte
        cpy     #$12                    ; 3014  18 bytes = 6 rows of 3
        bcc     L_300C                  ; 3016  18 bytes = 6 rows of 3
        lda     #$0C                    ; 3018  colour $0C = medium grey
        ldx     activeSpriteIndex       ; 301A  which sprite was dressed?
        beq     L_3022                  ; 301D  sprite 0 keeps the cursor colour set by installCursorShape
        sta     spriteColourShadow,x    ; 301F  any other sprite is drawn grey
L_3022:
        rts                             ; 3022  done - the cursor sprite is dressed


; ----------------------------------------------------------------------
; mapCellToSpriteXY - Converts a battlefield cell (X = column 0-39, Y = row 0-39) into the sprite
; coordinates that put a marker on that cell of the overview (mini) map in the left panel. While a
; group formation box is being dragged (groupBoxActive $920A bit 7 set) the cell is first shifted by
; the box's top-left offsets, so the formation ghost sprite is anchored at the corner of the box
; instead of at the cursor cell, and the result is nudged one pixel right and down to line up with the
; 4x4 pixel blocks the overview map is drawn with.
; In:  X = map column, Y = map row, groupBoxActive $920A, groupBoxLeftOffset $922F, groupBoxTopOffset
;     $9231
; Out: X = sprite X coordinate, Y = sprite Y coordinate; A clobbered
; Called from: positionMarkerSprite ($2585), positionCursorSprite ($3055), the unit-follow code
;     ($483F)
; ----------------------------------------------------------------------
mapCellToSpriteXY:
        lda     groupBoxActive          ; 3023  $920A = $FF while a group formation box is being dragged around the map
        bpl     mapCellToSpriteXYRaw    ; 3026  branch if no group box is active: convert the cell as it stands
        txa                             ; 3028  group box: start from the requested map column
        clc                             ; 3029  clear carry for the offset addition
        adc     groupBoxLeftOffset      ; 302A  $922F = column offset of the box's left edge from the cursor cell (may be negative)
        tax                             ; 302D  X = column of the top-left cell of the box
        tya                             ; 302E  same for the row
        clc                             ; 302F  clear carry for the row offset addition
        adc     groupBoxTopOffset       ; 3030  $9231 = row offset of the box's top edge from the cursor cell
        tay                             ; 3033  Y = row of the top-left cell of the box
        jsr     mapCellToSpriteXYRaw    ; 3034  convert that corner cell to overview-map sprite coordinates
        inx                             ; 3037  +1 pixel in X: the 4x4 cell blocks are drawn one pixel into their cell
        iny                             ; 3038  +1 pixel in Y, likewise
        rts                             ; 3039  return with the shifted sprite coordinates


; ----------------------------------------------------------------------
; mapCellToSpriteXYRaw - The plain cell-to-sprite conversion for the overview map: spriteX = column*4
; + $1F, spriteY = row*4 + $39. The whole 40x40 battlefield is drawn in the left panel at 4x4 pixels
; per cell starting at screen pixel (7,7); $1F and $39 are that origin plus the VIC sprite coordinate
; offsets ($18 horizontally, $32 vertically).
; In:  X = map column 0-39, Y = map row 0-39
; Out: X = sprite X coordinate, Y = sprite Y coordinate; A clobbered
; Called from: mapCellToSpriteXY ($3026/$3034), showExplosionMarker ($33E5), the shot-line setup
;     ($3A70, $3A7B)
; ----------------------------------------------------------------------
mapCellToSpriteXYRaw:
        txa                             ; 303A  map column 0-39
        asl     a                       ; 303B  column*2
        asl     a                       ; 303C  column*4 - the overview map uses 4 pixels per cell
        clc                             ; 303D  clear carry before adding the map origin
        adc     #$1F                    ; 303E  sprite X of overview column 0 = 24 (sprite origin) + 7 (left edge of the map)
        tax                             ; 3040  return the sprite X in X
        tya                             ; 3041  map row 0-39
        asl     a                       ; 3042  row*2
        asl     a                       ; 3043  row*4
        clc                             ; 3044  clear carry before adding the map origin
        adc     #$39                    ; 3045  sprite Y of overview row 0 = 50 (sprite origin) + 7 (top edge of the map)
        tay                             ; 3047  return the sprite Y in Y
        rts                             ; 3048  done


; ----------------------------------------------------------------------
; positionCursorSprite - Puts the sprite named by activeSpriteIndex ($91CC) over the cursor cell of
; the overview map. The cursor cell is always the middle of the 7x5 tactical view, i.e. (viewOriginCol
; + 3, viewOriginRow + 2). Nothing happens when activeSpriteIndex is negative (no cursor sprite).
; In:  viewOriginCol zp_A5, viewOriginRow zp_A6, activeSpriteIndex $91CC, group-box state
; Out: spriteXShadow/spriteYShadow of that sprite, spriteEnableShadow bit set; A/X/Y clobbered
; Called from: the viewport scroller ($1D46), battlefield entry ($2012), buildGroupFormationSprite
;      ($31A0, by JMP) and the map generator overlay ($83F2)
; ----------------------------------------------------------------------
positionCursorSprite:
        lda     viewOriginCol           ; 3049  map column of the left edge of the 7x5 tactical view
        clc                             ; 304B  clear carry before stepping to the cursor column
        adc     #$03                    ; 304C  +3 = the middle column of the seven, where the cursor sits
        tax                             ; 304E  X = cursor column
        lda     viewOriginRow           ; 304F  map row of the top edge of the view
        clc                             ; 3051  clear carry before stepping to the cursor row
        adc     #$02                    ; 3052  +2 = the middle row of the five
        tay                             ; 3054  Y = cursor row
        jsr     mapCellToSpriteXY       ; 3055  cell -> overview sprite coordinates (shifted when a group box is active)
        txa                             ; 3058  keep the sprite X byte
        ldx     activeSpriteIndex       ; 3059  $91CC = which sprite is currently acting as the cursor
        bmi     L_306B                  ; 305C  negative = no cursor sprite wanted, leave everything alone
        sta     spriteXShadow,x         ; 305E  $9010+n: shadow of the VIC sprite X register, copied out by the raster IRQ
        tya                             ; 3061  sprite Y
        sta     spriteYShadow,x         ; 3062  $9018+n: shadow of the VIC sprite Y register
        lda     spriteBitMasks,x        ; 3065  $2F85[n] = 1 << n, the enable bit of this sprite
        jsr     enableSprites           ; 3068  turn the sprite on (unless sprites are suppressed)
L_306B:
        rts                             ; 306B  done


; ----------------------------------------------------------------------
; drawLineInPathSprite - Draws a straight line into the shape of sprite 3 ($8AC0), which is the game's
; path/shot-line sprite. It uses the resident high-memory Bresenham stepper ($FBB8 init, $FBFA step),
; but here its zp_92-zp_95 coordinates are pixel positions inside the 24x21 sprite, not map cells. A
; negative argument makes the walker skip two pixels after every plotted one, giving a dotted line
; (used for a BOOMER's arcing shot).
; In:  A = 0 solid / $80 dotted; lineCurRow/Col zp_92/zp_93 = start pixel, lineEndRow/Col zp_94/zp_95
;     = end pixel
; Out: pixels set in $8AC0-$8AFF; the line-walker state is consumed, zp_92/zp_93 left at the end point
; Called from: the shot-line builder ($3B11) and the drone heading indicator in overlay B ($8266)
; ----------------------------------------------------------------------
drawLineInPathSprite:
        sta     L_307C+1                ; 306C  patch the LDA # at $307C with the dot flag: 0 = solid, $80 = dotted
        jsr     initLineStepper         ; 306F  $FBB8: set up the Bresenham walk from (zp_93,zp_92) to (zp_95,zp_94)

; ----------------------------------------------------------------------
; Walk the line one pixel at a time, plotting into sprite 3.
; ----------------------------------------------------------------------
L_3072:
        jsr     plotPathSpritePixel     ; 3072  set the pixel the walker is standing on
        lda     lineStepsLeft           ; 3075  steps still to be taken
        beq     L_3089                  ; 3077  branch when the end point has been reached
        jsr     stepLine                ; 3079  $FBFA: advance the walker one pixel
L_307C:
        lda     #$FF                    ; 307C  dot flag patched at $306C
        bpl     L_3072                  ; 307E  solid line: plot every pixel
        jsr     stepLine                ; 3080  dotted line: skip this pixel...
        jsr     stepLine                ; 3083  ...and the next one, so only every third pixel is drawn
        jmp     L_3072                  ; 3086  plot the pixel we landed on

L_3089:
        rts                             ; 3089  line finished


; ----------------------------------------------------------------------
; plotPathCross - Marks the end of a path/shot line by plotting the four orthogonal neighbours of the
; walker's current pixel into sprite 3: left, up, down and (by falling through into
; plotPathSpritePixel) right. Together with the line's own last pixel this makes a small cross over
; the target.
; In:  lineCurRow zp_92, lineCurCol zp_93 = the pixel to mark
; Out: 4 more pixels set in $8AC0; zp_93 left one greater than on entry
; Called from: the shot-line builder ($3B14), immediately after drawLineInPathSprite
; ----------------------------------------------------------------------
plotPathCross:
        dec     lineCurCol              ; 308A  one pixel to the left of the end point
        jsr     plotPathSpritePixel     ; 308C  plot it
        inc     lineCurCol              ; 308F  back to the end column
        dec     lineCurRow              ; 3091  one pixel above the end point
        jsr     plotPathSpritePixel     ; 3093  plot it
        inc     lineCurRow              ; 3096  back to the end row
        inc     lineCurRow              ; 3098  one pixel below the end point
        jsr     plotPathSpritePixel     ; 309A  plot it
        dec     lineCurRow              ; 309D  back to the end row
        inc     lineCurCol              ; 309F  one pixel to the right - fall through and plot that one too

; ----------------------------------------------------------------------
; plotPathSpritePixel - Sets one pixel in the shape of sprite 3 at $8AC0. A sprite row is 3 bytes, so
; the byte index is row*3 + column/8 and the bit is taken from the $80>>n mask table at $C008. The
; column/8 part is patched into the ADC # at $30B3 rather than added with a register.
; In:  lineCurRow zp_92 = pixel row 0-20, lineCurCol zp_93 = pixel column 0-23
; Out: one bit set in $8AC0-$8AFF; A/X/Y clobbered, the operand at $30B4 patched
; Called from: drawLineInPathSprite ($3072), plotPathCross ($308C/$3093/$309A and by fall-through),
;      and directly by overlay B ($7852, $785E)
; ----------------------------------------------------------------------
plotPathSpritePixel:
        lda     lineCurCol              ; 30A1  pixel column 0-23 inside the sprite
        lsr     a                       ; 30A3  column/2
        lsr     a                       ; 30A4  column/4
        lsr     a                       ; 30A5  column/8 = which of the three bytes of a sprite row holds the pixel
        sta     L_30B3+1                ; 30A6  patch the ADC # at $30B3 with that byte offset
        lda     lineCurCol              ; 30A9  the column again
        and     #$07                    ; 30AB  bit number 0-7 inside the byte
        tax                             ; 30AD  index into the bit mask table
        lda     lineCurRow              ; 30AE  pixel row 0-20
        asl     a                       ; 30B0  row*2
        adc     lineCurRow              ; 30B1  row*3 = offset of the start of that sprite row (3 bytes per row)
L_30B3:
        adc     #$FF                    ; 30B3  + byte within the row, patched at $30A6
        tay                             ; 30B5  Y = byte offset inside the 63-byte shape
        lda     sprite3Shape,y          ; 30B6  $8AC0 = shape of sprite 3, the path/shot line sprite
        ora     bootReadSectorLoop,x    ; 30B9  $C008[bit] = $80 >> bit  (also textEngineC000:bitMaskTable_C008)
        sta     sprite3Shape,y          ; 30BC  set the pixel
        rts                             ; 30BF  done


; ----------------------------------------------------------------------
; buildGroupFormationSprite - Builds the 'formation ghost' shown while a whole group is being ordered
; to a new place: every living member of the selected group is drawn as one 2x2-pixel dot into sprite
; 0, laid out exactly as the group's ordered destinations are laid out on the map. Because sprite 0 is
; X- and Y-expanded, one dot covers the 4x4 pixels the overview map gives a cell. The bounding box of
; the destinations is turned into offsets relative to the dragged unit, so the ghost follows the
; cursor, groupBoxActive is set, the view is scrolled until the box fits on the map and the ghost is
; positioned.
; In:  selectedGroupKey $9206, selectedUnit $90F8, unit arrays $F76C/$FB54/$F7D0/$F834
; Out: sprite 0 shape, spriteBuildBuffer $9025, groupBox offsets $922E-$9231, groupBoxActive $920A =
;     $FF,
;      spritePlotMode $91F3 = $40, X/Y expand shadows bits 0-1 set, view origin possibly moved
; Called from: runTargetCursorLoop ($21A2), when the group being ordered has 2 or more members
; ----------------------------------------------------------------------
buildGroupFormationSprite:
        lda     selectedGroupKey        ; 30C0  $9206 = group id bits of the group being ordered
        ldy     #$80                    ; 30C3  bit 7 = measure the members' ordered destinations, not their current cells
        jsr     computeGroupBounds      ; 30C5  $4B5E -> zp_18/zp_19 = min column/row, zp_1B/zp_1C = max column/row
        ldy     #$3F                    ; 30C8  clear the 64-byte shape build buffer
        lda     #$00                    ; 30CA  with zeroes
L_30CC:
        sta     spriteBuildBuffer,y     ; 30CC  $9025 = scratch sprite shape used while the ghost is assembled
        dey                             ; 30CF  next byte down
        bpl     L_30CC                  ; 30D0  loop over all 64 bytes
        lda     #$40                    ; 30D2  bit 6 = 'set the pixel'
        sta     spritePlotMode          ; 30D4  $91F3 tells plotSpritePixel which operation to use
        lda     scratch1B               ; 30D7  max column of the group's destinations
        sta     groupBoxRightOffset     ; 30D9  $922E = right edge of the group box
        lda     scratch18               ; 30DC  min column
        sta     groupBoxLeftOffset      ; 30DE  $922F = left edge of the group box
        lda     scratch1C               ; 30E1  max row
        sta     groupBoxBottomOffset    ; 30E3  $9230 = bottom edge of the group box
        lda     scratch19               ; 30E6  min row
        sta     groupBoxTopOffset       ; 30E8  $9231 = top edge of the group box
        lda     selectedGroupKey        ; 30EB  group id bits again
        ora     #$40                    ; 30EE  bit 6 = 'this unit is in a group'
        sta     scratch1B               ; 30F0  the value unitDisplayFlags bits 2-6 must have for a member of this group
        ldx     #$63                    ; 30F2  start at unit 99, the last unit record
        stx     scratch1A               ; 30F4  keep the loop counter in memory (X is needed for the pixel plotter)

; ----------------------------------------------------------------------
; Walk units 99..0 and plot a 2x2 dot for every living member of the group at its ordered destination,
; relative to the top-left corner of the group box.
; ----------------------------------------------------------------------
L_30F6:
        lda     unitTypeTable,x         ; 30F6  $F76C[unit] = type byte, bit 7 set = destroyed
        bmi     L_3142                  ; 30F9  skip dead units
        lda     unitDisplayFlagsTable,x ; 30FB  $FB54[unit] = local flags copy
        and     #$7C                    ; 30FE  keep the group id (bits 2-4) and the in-a-group bit (6)
        cmp     scratch1B               ; 3100  does it belong to the group being ordered?
        bne     L_3142                  ; 3102  no - try the next unit
        lda     unitDestRowTable,x      ; 3104  $F834[unit] = ordered destination row, bit 7 = already standing there
        and     #$7F                    ; 3107  drop that flag, keep the row
        sec                             ; 3109  set carry for the subtraction
        sbc     groupBoxTopOffset       ; 310A  row inside the group box
        asl     a                       ; 310D  x2: two sprite pixels per map cell (the sprite is Y-expanded)
        tay                             ; 310E  Y = pixel row of this member's dot
        sty     scratch19               ; 310F  stash it, plotSpritePixel needs Y for each of the four pixels
        lda     unitDestColTable,x      ; 3111  $F7D0[unit] = ordered destination column
        and     #$7F                    ; 3114  drop the 'already there' flag
        sec                             ; 3116  set carry for the subtraction
        sbc     groupBoxLeftOffset      ; 3117  column inside the group box
        asl     a                       ; 311A  x2: two sprite pixels per map cell (the sprite is X-expanded)
        tax                             ; 311B  X = pixel column of this member's dot
        stx     scratch18               ; 311C  stash it too
        lda     #$FF                    ; 311E  bit 7 set = plot into spriteBuildBuffer $9025 instead of a real sprite
        jsr     setSpritePixel          ; 3120  top-left pixel of the 2x2 dot
        ldx     scratch18               ; 3123  pixel column again
        inx                             ; 3125  one pixel to the right
        ldy     scratch19               ; 3126  same pixel row
        lda     #$FF                    ; 3128  again into the build buffer
        jsr     setSpritePixel          ; 312A  top-right pixel
        ldx     scratch18               ; 312D  pixel column
        ldy     scratch19               ; 312F  pixel row
        iny                             ; 3131  one pixel down
        lda     #$FF                    ; 3132  build buffer
        jsr     setSpritePixel          ; 3134  bottom-left pixel
        ldx     scratch18               ; 3137  pixel column
        inx                             ; 3139  one right
        ldy     scratch19               ; 313A  pixel row
        iny                             ; 313C  one down
        lda     #$FF                    ; 313D  build buffer
        jsr     setSpritePixel          ; 313F  bottom-right pixel - the dot is complete
L_3142:
        dec     scratch1A               ; 3142  step to the next unit
        ldx     scratch1A               ; 3144  reload the loop counter
        bpl     L_30F6                  ; 3146  keep going until unit 0 has been handled
        ldy     #$3F                    ; 3148  64 bytes to copy

; ----------------------------------------------------------------------
; Publish the finished shape and set up the sprite geometry.
; ----------------------------------------------------------------------
L_314A:
        lda     spriteBuildBuffer,y     ; 314A  the assembled ghost
        sta     sprite0Shape,y          ; 314D  $8A00 = shape of sprite 0
        dey                             ; 3150  next byte down
        bpl     L_314A                  ; 3151  loop
        lda     spriteXExpandShadow     ; 3153  $9023 = shadow of VIC_SPR_EXP_X
        ora     #$03                    ; 3156  double the width of sprites 0 and 1
        sta     spriteXExpandShadow     ; 3158  store the shadow back
        lda     spriteYExpandShadow     ; 315B  $9024 = shadow of VIC_SPR_EXP_Y
        ora     #$03                    ; 315E  double the height too, so one 2x2 dot covers a whole 4x4 overview cell
        sta     spriteYExpandShadow     ; 3160  store the shadow back

; ----------------------------------------------------------------------
; Turn the absolute box corners into offsets from the dragged unit's destination so that
; mapCellToSpriteXY can keep the box under the cursor while the player scrolls.
; ----------------------------------------------------------------------
        ldy     selectedUnit            ; 3163  $90F8 = the unit the player is actually dragging
        lda     unitDestColTable,y      ; 3166  its ordered destination column
        and     #$7F                    ; 3169  without the 'already there' flag
        sta     scratch18               ; 316B  keep it
        lda     unitDestRowTable,y      ; 316D  its ordered destination row
        and     #$7F                    ; 3170  without the flag
        sta     scratch19               ; 3172  keep it
        lda     groupBoxLeftOffset      ; 3174  left edge of the box
        sec                             ; 3177  set carry for the subtraction
        sbc     scratch18               ; 3178  minus the dragged unit's column
        sta     groupBoxLeftOffset      ; 317A  $922F is now an offset relative to the cursor
        lda     groupBoxRightOffset     ; 317D  right edge
        sec                             ; 3180  set carry for the subtraction
        sbc     scratch18               ; 3181  minus the dragged unit's column
        sta     groupBoxRightOffset     ; 3183  $922E likewise
        lda     groupBoxTopOffset       ; 3186  top edge
        sec                             ; 3189  set carry for the subtraction
        sbc     scratch19               ; 318A  minus the dragged unit's row
        sta     groupBoxTopOffset       ; 318C  $9231 likewise
        lda     groupBoxBottomOffset    ; 318F  bottom edge
        sec                             ; 3192  set carry for the subtraction
        sbc     scratch19               ; 3193  minus the dragged unit's row
        sta     groupBoxBottomOffset    ; 3195  $9230 likewise
        lda     #$FF                    ; 3198  flag value
        sta     groupBoxActive          ; 319A  $920A: from now on mapCellToSpriteXY applies the box offsets
        jsr     keepGroupBoxInsideMap   ; 319D  $1F4E: scroll the view until the whole box fits inside the 40x40 map
        jmp     positionCursorSprite    ; 31A0  place the ghost over the cursor cell and switch it on, then return to the caller


; ----------------------------------------------------------------------
; enableSprites - ORs the sprite mask in A into spriteEnableShadow ($9022), the shadow the raster IRQ
; copies into VIC_SPR_ENA. The store is skipped while comcenStunStatus ($922A) bit 6 marks the comcen
; knock-out blackout round, so no sprite can reappear during it; A still returns the combined mask.
; In:  A = sprite bit mask (1 << sprite number)
; Out: spriteEnableShadow updated unless suppressed; A = old shadow OR the mask
; Called from: everywhere a cursor, marker or ghost is switched on - $2539, $2566, $2FF0, $3068,
;      $333D, $3380, $33AE, $3402, $344A, $3B19, $3D04, $484A, the $C000 overlay and both $6F00
; overlays
; ----------------------------------------------------------------------
enableSprites:
        ora     spriteEnableShadow      ; 31A3  $9022 = shadow of VIC_SPR_ENA, one bit per sprite
        bit     comcenStunStatus        ; 31A6  $922A = comcen stun/knock-out status
        bvs     L_31AE                  ; 31A9  bit 6 set = knock-out blackout round: keep every sprite off
        sta     spriteEnableShadow      ; 31AB  the raster IRQ writes this to $D015 next frame
L_31AE:
        rts                             ; 31AE  A holds the combined mask either way


; ----------------------------------------------------------------------
; plotSpritePixel - Sets, clears or toggles one pixel of a sprite shape. The operation is chosen by
; spritePlotMode ($91F3): bit 7 = toggle (EOR), bit 6 = set (ORA), neither = clear (AND with the
; inverted mask). It works by patching the opcode and the mask of the read-modify-write instruction at
; $3232. A negative sprite number selects the 64-byte build buffer at $9025 instead of the real sprite
; data at $8A00 + n*64.
; In:  A = sprite number 0-7 (bit 7 set = use $9025), X = pixel column 0-23, Y = pixel row 0-20,
;      spritePlotMode $91F3
; Out: one pixel modified; spritePlotPtr zp_98/zp_99 = byte pointer, spritePlotWork clobbered;
;      self-modifies $31EE (sprite number), $3232 (opcode) and $3233 (mask)
; Called from: plotCircleOctantPoints ($32CE) and, through setSpritePixel, buildGroupFormationSprite
; ----------------------------------------------------------------------
plotSpritePixel:
        bit     spritePlotMode          ; 31AF  $91F3 selects the operation
        bmi     L_31C0                  ; 31B2  bit 7 set = toggle the pixel
        bvs     setSpritePixel          ; 31B4  bit 6 set = set the pixel
        sta     L_31ED+1                ; 31B6  clear mode: stash the sprite number in the LDX # operand at $31EE
        lda     #$29                    ; 31B9  opcode of AND #imm
        sta     spritePlotOperation     ; 31BB  patch the read-modify-write instruction at $3232
        bne     L_31D2                  ; 31BE  always taken ($29 is never zero)
L_31C0:
        sta     L_31ED+1                ; 31C0  toggle mode: stash the sprite number
        lda     #$49                    ; 31C3  opcode of EOR #imm
        sta     spritePlotOperation     ; 31C5  patch the operation
        bne     L_31D2                  ; 31C8  always taken

; ----------------------------------------------------------------------
; setSpritePixel - Entry point of plotSpritePixel that forces the ORA (set) operation. Note that the
; mask is still inverted further down unless spritePlotMode has bit 6 or 7 set, so callers set
; spritePlotMode = $40 first; buildGroupFormationSprite does exactly that.
; In:  A = sprite number (bit 7 = build buffer), X = pixel column, Y = pixel row
; Out: the pixel is set
; Called from: buildGroupFormationSprite ($3120, $312A, $3134, $313F) and by branch from $31B4
; ----------------------------------------------------------------------
setSpritePixel:
        sta     L_31ED+1                ; 31CA  stash the sprite number in the LDX # operand at $31EE
        lda     #$09                    ; 31CD  opcode of ORA #imm
        sta     spritePlotOperation     ; 31CF  patch the operation at $3232

; ----------------------------------------------------------------------
; Shared body: build a pointer to the byte holding the pixel and the bit mask to apply.
; ----------------------------------------------------------------------
L_31D2:
        stx     spritePlotWork          ; 31D2  keep the pixel column
        lda     L_31ED+1                ; 31D5  the sprite number stashed above
        bpl     L_31E5                  ; 31D8  0-7 = a real sprite
        lda     #$25                    ; 31DA  negative: use the scratch shape buffer at $9025...
        sta     spritePlotPtr           ; 31DC  low byte of the pointer
        lda     #$90                    ; 31DE  high byte $90
        sta     spritePlotPtrHi         ; 31E0  pointer = $9025
        jmp     L_31FE                  ; 31E2  ...which needs no per-sprite offset

L_31E5:
        lda     #$00                    ; 31E5  real sprite: base of the sprite data block
        sta     spritePlotPtr           ; 31E7  low byte
        lda     #$8A                    ; 31E9  high byte $8A
        sta     spritePlotPtrHi         ; 31EB  pointer = $8A00
L_31ED:
        ldx     #$FF                    ; 31ED  sprite number, patched at $31B6/$31C0/$31CA
        lda     spritePlotPtr           ; 31EF  add the sprite's 64-byte offset
        clc                             ; 31F1  clear carry for the 16-bit addition of the sprite offset
        adc     spriteOffsetLoTable,x   ; 31F2  $2F95[n] = low byte of n*64
        sta     spritePlotPtr           ; 31F5  store it back
        lda     spritePlotPtrHi         ; 31F7  high byte of the pointer
        adc     spriteOffsetHiTable,x   ; 31F9  $2F9D[n] = high byte of n*64
        sta     spritePlotPtrHi         ; 31FC  pointer now addresses the shape of sprite n
L_31FE:
        lda     spritePlotWork          ; 31FE  the pixel column
        tax                             ; 3201  keep a copy in X
        and     #$07                    ; 3202  bit number inside the byte
        sta     spritePlotWork          ; 3204  save it for the mask lookup
        txa                             ; 3207  column again
        lsr     a                       ; 3208  column/2
        lsr     a                       ; 3209  column/4
        lsr     a                       ; 320A  column/8 = byte 0, 1 or 2 of a sprite row
        clc                             ; 320B  clear carry before folding the byte offset into the pointer
        adc     spritePlotPtr           ; 320C  fold it into the pointer
        sta     spritePlotPtr           ; 320E  low byte
        bcc     L_3214                  ; 3210  no carry out
        inc     spritePlotPtrHi         ; 3212  carry into the high byte
L_3214:
        ldx     spritePlotWork          ; 3214  bit number
        lda     bootReadSectorLoop,x    ; 3217  $C008[bit] = $80 >> bit  (also textEngineC000:bitMaskTable_C008)
        bit     spritePlotMode          ; 321A  which operation was chosen?
        bmi     L_3223                  ; 321D  toggle: EOR with the plain mask
        bvs     L_3223                  ; 321F  set: ORA with the plain mask
        eor     #$FF                    ; 3221  clear: AND with the inverted mask
L_3223:
        sta     spritePlotOperation+1   ; 3223  patch the immediate operand of the operation at $3232
        tya                             ; 3226  pixel row 0-20
        sta     spritePlotWork          ; 3227  keep it
        asl     a                       ; 322A  row*2
        clc                             ; 322B  clear carry for row*2 + row
        adc     spritePlotWork          ; 322C  row*3 = byte index of that sprite row (3 bytes per row)
        tay                             ; 322F  Y = index into the shape
        lda     (spritePlotPtr),y       ; 3230  read the shape byte
spritePlotOperation:
        ora     #$FF                    ; 3232  patched: ORA/AND/EOR with the patched mask
        sta     (spritePlotPtr),y       ; 3234  write the modified byte back
        rts                             ; 3236  done


; Two work bytes living inside the code.
spritePlotWork:
        .byte   $00                     ; 3237  .  scratch used by plotSpritePixel: pixel column, then bit number, then pixel row
circlePointIndex:
        .byte   $00                     ; 3238  which of the eight mirrored circle points plotCircleOctantPoints is plotting (7 down to 0)

; ----------------------------------------------------------------------
; drawCircleInSprite - Draws a circle of radius X (1-10) centred on pixel (12,10) - the middle of a
; 24x21 sprite - into sprite A, using 8-way octant symmetry. The dx/dy point lists of each radius live
; in $9F69.. and are reached through the pointer tables at $9F37/$9F41 (dx) and $9F4B/$9F55 (dy); the
; index of the last point of the octant comes from $9F5F. spritePlotMode decides whether the pixels
; are set, cleared or toggled. Radius 1 degenerates into a filled 3x3 dot.
; In:  A = sprite number (bit 7 = build buffer), X = radius 1-10, spritePlotMode $91F3
; Out: the circle is plotted; activeSpriteIndex $91CC = A, circleStepsLeft $91F2 = $FF,
;      circleDxPtr/circleDyPtr zp_AD-zp_B0 point at the two lists
; Called from: drawRingsInSprite4 ($3471), the unit blob markers ($3D13, $3D43) and overlay B's
;      range rings ($7190-$71DA)
; ----------------------------------------------------------------------
drawCircleInSprite:
        sta     activeSpriteIndex       ; 3239  $91CC: plotCircleOctantPoints will plot into this sprite
        dex                             ; 323C  the tables are indexed by radius-1
        lda     titleScreenRow20Col23,x ; 323D  $9F37[r-1] = low byte of the dx list  (also graphicsData9300:circleDxPtrLoTable)
        sta     circleDxPtr             ; 3240  zp_AD
        lda     titleScreenRow20Col33,x ; 3242  $9F41[r-1] = high byte (always $9F)  (also graphicsData9300:circleDxPtrHiTable)
        sta     circleDxPtrHi           ; 3245  zp_AE
        lda     titleScreenRow21Col03,x ; 3247  $9F4B[r-1] = low byte of the dy list  (also graphicsData9300:circleDyPtrLoTable)
        sta     circleDyPtr             ; 324A  zp_AF
        lda     titleScreenRow21Col13,x ; 324C  $9F55[r-1] = high byte  (also graphicsData9300:circleDyPtrHiTable)
        sta     circleDyPtrHi           ; 324F  zp_B0
        ldy     titleScreenRow21Col23,x ; 3251  $9F5F[r-1] = index of the last point of the octant  (also graphicsData9300:circleStepCountTable)
        sty     circleStepsLeft         ; 3254  $91F2 = octant point counter

; ----------------------------------------------------------------------
; Walk the octant point list from the last entry down to entry 0.
; ----------------------------------------------------------------------
L_3257:
        jsr     computeCircleOctantPoints; 3257  mirror point Y into the 8 symmetric coordinates
        jsr     plotCircleOctantPoints  ; 325A  plot those 8 pixels
        dec     circleStepsLeft         ; 325D  previous point of the octant
        ldy     circleStepsLeft         ; 3260  reload it as the index
        bpl     L_3257                  ; 3263  loop until point 0 has been plotted
        rts                             ; 3265  circle complete


; ----------------------------------------------------------------------
; computeCircleOctantPoints - Expands one octant point (dx,dy) of a circle into the coordinates of all
; eight mirrored points, writing them into circlePointX0-7 ($32DA) and circlePointY0-7 ($32E2). The
; centre is pixel (12,10), the middle of a 24x21 sprite.
; In:  Y = index into the octant lists, circleDxPtr zp_AD/zp_AE, circleDyPtr zp_AF/zp_B0
; Out: $32DA-$32E9 filled with 8 X and 8 Y coordinates
; Called from: drawCircleInSprite ($3257)
; ----------------------------------------------------------------------
computeCircleOctantPoints:
        clc                             ; 3266  prepare the additions
        lda     #$0C                    ; 3267  12 = centre column of a 24-pixel-wide sprite
        adc     (circleDxPtr),y         ; 3269  +dx of this octant point
        sta     circlePointX0           ; 326B  X of mirrored point 0
        sta     circlePointX7           ; 326E  X of mirrored point 7
        sec                             ; 3271  prepare the subtractions
        lda     #$0C                    ; 3272  centre column again
        sbc     (circleDxPtr),y         ; 3274  12-dx
        sta     circlePointX3           ; 3276  X of mirrored point 3
        sta     circlePointX4           ; 3279  X of mirrored point 4
        clc                             ; 327C  clear carry for the next addition
        lda     #$0C                    ; 327D  centre column
        adc     (circleDyPtr),y         ; 327F  12+dy - the octant swap that mirrors about the diagonal
        sta     circlePointX1           ; 3281  X of mirrored point 1
        sta     circlePointX6           ; 3284  X of mirrored point 6
        sec                             ; 3287  set carry for the next subtraction
        lda     #$0C                    ; 3288  centre column
        sbc     (circleDyPtr),y         ; 328A  12-dy
        sta     circlePointX2           ; 328C  X of mirrored point 2
        sta     circlePointX5           ; 328F  X of mirrored point 5
        clc                             ; 3292  clear carry for the next addition
        lda     #$0A                    ; 3293  10 = centre row of a 21-pixel-high sprite
        adc     (circleDyPtr),y         ; 3295  10+dy
        sta     circlePointY0           ; 3297  Y of mirrored point 0
        sta     circlePointY3           ; 329A  Y of mirrored point 3
        sec                             ; 329D  set carry for the next subtraction
        lda     #$0A                    ; 329E  centre row
        sbc     (circleDyPtr),y         ; 32A0  10-dy
        sta     circlePointY4           ; 32A2  Y of mirrored point 4
        sta     circlePointY7           ; 32A5  Y of mirrored point 7
        clc                             ; 32A8  clear carry for the next addition
        lda     #$0A                    ; 32A9  centre row
        adc     (circleDxPtr),y         ; 32AB  10+dx
        sta     circlePointY1           ; 32AD  Y of mirrored point 1
        sta     circlePointY2           ; 32B0  Y of mirrored point 2
        sec                             ; 32B3  set carry for the next subtraction
        lda     #$0A                    ; 32B4  centre row
        sbc     (circleDxPtr),y         ; 32B6  10-dx
        sta     circlePointY5           ; 32B8  Y of mirrored point 5
        sta     circlePointY6           ; 32BB  Y of mirrored point 6
        rts                             ; 32BE  all eight points ready


; ----------------------------------------------------------------------
; plotCircleOctantPoints - Plots the eight mirrored points prepared by computeCircleOctantPoints into
; the sprite named by activeSpriteIndex, counting circlePointIndex ($3238) down from 7 to 0.
; In:  circlePointX0-7 $32DA, circlePointY0-7 $32E2, activeSpriteIndex $91CC, spritePlotMode $91F3
; Out: 8 pixels plotted; circlePointIndex = $FF
; Called from: drawCircleInSprite ($325A)
; ----------------------------------------------------------------------
plotCircleOctantPoints:
        ldx     #$07                    ; 32BF  eight symmetric points per octant step
        stx     circlePointIndex        ; 32C1  keep the point index in memory, X is needed by the plotter

; ----------------------------------------------------------------------
; Plot the eight mirrored points of the current octant step.
; ----------------------------------------------------------------------
L_32C4:
        ldy     circlePointY0,x         ; 32C4  $32E2[n] = Y coordinate of point n
        lda     circlePointX0,x         ; 32C7  $32DA[n] = X coordinate of point n
        tax                             ; 32CA  X = pixel column
        lda     activeSpriteIndex       ; 32CB  the sprite chosen by drawCircleInSprite
        jsr     plotSpritePixel         ; 32CE  set/clear/toggle the pixel
        dec     circlePointIndex        ; 32D1  next point
        ldx     circlePointIndex        ; 32D4  reload the index
        bpl     L_32C4                  ; 32D7  loop until point 0 has been plotted
        rts                             ; 32D9  done


; Sixteen RAM bytes inside the code: the eight X coordinates ($32DA-$32E1) and the eight Y coordinates
; ($32E2-$32E9) of the mirrored points of one circle step. Written by computeCircleOctantPoints and
; read, indexed 0-7, by plotCircleOctantPoints.
circlePointX0:
        .byte   $00                     ; 32DA  .  X coordinate of mirrored circle point 0
circlePointX1:
        .byte   $00                     ; 32DB  X coordinate of mirrored circle point 1
circlePointX2:
        .byte   $00                     ; 32DC  X coordinate of mirrored circle point 2
circlePointX3:
        .byte   $00                     ; 32DD  X coordinate of mirrored circle point 3
circlePointX4:
        .byte   $00                     ; 32DE  X coordinate of mirrored circle point 4
circlePointX5:
        .byte   $00                     ; 32DF  X coordinate of mirrored circle point 5
circlePointX6:
        .byte   $00                     ; 32E0  X coordinate of mirrored circle point 6
circlePointX7:
        .byte   $00                     ; 32E1  X coordinate of mirrored circle point 7
circlePointY0:
        .byte   $00                     ; 32E2  Y coordinate of mirrored circle point 0
circlePointY1:
        .byte   $00                     ; 32E3  Y coordinate of mirrored circle point 1
circlePointY2:
        .byte   $00                     ; 32E4  Y coordinate of mirrored circle point 2
circlePointY3:
        .byte   $00                     ; 32E5  Y coordinate of mirrored circle point 3
circlePointY4:
        .byte   $00                     ; 32E6  Y coordinate of mirrored circle point 4
circlePointY5:
        .byte   $00                     ; 32E7  Y coordinate of mirrored circle point 5
circlePointY6:
        .byte   $00                     ; 32E8  Y coordinate of mirrored circle point 6
circlePointY7:
        .byte   $00                     ; 32E9  Y coordinate of mirrored circle point 7

; ----------------------------------------------------------------------
; hideSprite1 - Clears bit 1 of the sprite enable shadow and writes the result straight to
; VIC_SPR_ENA, so the unit marker sprite disappears in the current frame instead of at the next raster
; interrupt.
; In:  spriteEnableShadow $9022
; Out: $9022 and $D015 with bit 1 cleared; A clobbered
; Called from: the battlefield cursor and selection code ($229F, $22F4, $2359, $253C, $2540, $3D4E)
; ----------------------------------------------------------------------
hideSprite1:
        lda     spriteEnableShadow      ; 32EA  $9022 = shadow of VIC_SPR_ENA
        and     #$FD                    ; 32ED  clear bit 1 = sprite 1, the unit marker on the overview map
        sta     spriteEnableShadow      ; 32EF  keep the shadow in step
        sta     VIC_SPR_ENA             ; 32F2  write $D015 at once so the marker vanishes immediately
        rts                             ; 32F5  done


; ----------------------------------------------------------------------
; hideSprite0Dead - Dead code: a byte-for-byte copy of hideSprite1 that clears bit 0 (sprite 0)
; instead of bit 1. No traced code refers to $32F6, and callers that want sprite 0 off clear the
; shadow themselves, so this is a leftover of an earlier build.
; In:  none (never called)
; Out: would clear bit 0 of $9022 and $D015
; ----------------------------------------------------------------------
hideSprite0Dead:
        lda     spriteEnableShadow      ; 32F6  dead code: sprite enable shadow
        and     #$FE                    ; 32F9  clear bit 0 = sprite 0
        sta     spriteEnableShadow      ; 32FB  store the shadow
        sta     VIC_SPR_ENA             ; 32FE  write it to $D015
        rts                             ; 3301  return


; ----------------------------------------------------------------------
; showCrosshairSprite - Loads the 63-byte diamond crosshair (the second shape of the pair at $9EA4,
; bytes $9EE3-$9F21) into sprite 2 and falls into the shared body at $3308, which paints it black,
; doubles it in both directions, parks it over the centre cell of the 7x5 tactical view and enables
; it.
; In:  none
; Out: sprite 2 shape/colour/size/position set, sprite 2 enabled
; Called from: runTargetCursorLoop ($21AB) when the player is choosing an attack target
; ----------------------------------------------------------------------
showCrosshairSprite:
        ldx     #$7D                    ; 3302  $7D = last byte of shape 1 ($9EE3-$9F21), the diamond crosshair
        bne     L_3308                  ; 3304  always taken

; ----------------------------------------------------------------------
; showCursorBoxSprite - Same as showCrosshairSprite but copies the first shape of the pair ($9EA4-
; $9EE2), the hollow box that frames the cursor cell of the tactical view.
; In:  none
; Out: sprite 2 holds the box, black, expanded, at sprite X 256 / Y $4D, enabled
; Called from: battlefield entry ($1FEB, $2260) and the order-cancel path ($4C56, by JMP)
; ----------------------------------------------------------------------
showCursorBoxSprite:
        ldx     #$3E                    ; 3306  $3E = last byte of shape 0 ($9EA4-$9EE2), the hollow cursor box

; ----------------------------------------------------------------------
; Shared body: copy 63 bytes backwards into sprite 2, then make it a black, double-sized frame sitting
; over the centre cell of the 7x5 tactical view.
; ----------------------------------------------------------------------
L_3308:
        ldy     #$3E                    ; 3308  63 bytes, copied from the end backwards
L_330A:
        lda     titleScreenRow16Col36,x ; 330A  $9EA4 holds the two 63-byte shapes back to back  (also graphicsData9300:cursorBoxSprite)
        sta     sprite2Shape,y          ; 330D  $8A80 = shape of sprite 2
        dex                             ; 3310  previous source byte
        dey                             ; 3311  previous destination byte
        bpl     L_330A                  ; 3312  loop over all 63 bytes
        lda     #$00                    ; 3314  colour 0
        sta     sprite2ColourShadow     ; 3316  $900A = sprite 2 colour shadow: black
        lda     spriteXExpandShadow     ; 3319  $9023 = X expansion shadow
        ora     #$04                    ; 331C  double the width of sprite 2
        sta     spriteXExpandShadow     ; 331E  store it back
        lda     spriteYExpandShadow     ; 3321  $9024 = Y expansion shadow
        ora     #$04                    ; 3324  double the height too, so the frame covers a whole 16x16 view cell
        sta     spriteYExpandShadow     ; 3326  store it back
        lda     #$00                    ; 3329  low byte of the sprite X coordinate
        sta     sprite2XShadow          ; 332B  $9012 = sprite 2 X shadow
        lda     spriteXMsbShadow        ; 332E  $9020 = shadow of the sprite X MSB register
        ora     #$04                    ; 3331  set bit 2: sprite 2 X coordinate becomes 256
        sta     spriteXMsbShadow        ; 3333  store it back
        lda     #$4D                    ; 3336  sprite Y 77
        sta     sprite2YShadow          ; 3338  $901A: the centre cell of the 7x5 tactical view
        lda     #$04                    ; 333B  sprite 2 enable bit
        jmp     enableSprites           ; 333D  switch it on and return to the caller


; ----------------------------------------------------------------------
; loadDirectionShapeSprite - Copies sprite shape number A (0-7, one per facing direction) from the
; decompressed shape buffer at $0200 + A*64 into sprite 0 ($8A00) when activeSpriteIndex is 0, or into
; sprite 5 ($8B40) otherwise, and enables that sprite. The buffer is filled by the RLE decompressor at
; $67CB from the compressed shape sets at $FD70/$FE24.
; In:  A = shape index 0-7, activeSpriteIndex $91CC = target sprite (0 or 5)
; Out: 63 bytes of sprite 0 or 5 rewritten and the sprite enabled; spritePlotPtr zp_98/zp_99
;     clobbered;
;      self-modifies the destination address of the STA at $3373
; Called from: the unit direction display ($5322) and overlay B ($802A, $810B, $829E)
; ----------------------------------------------------------------------
loadDirectionShapeSprite:
        sta     spritePlotPtrHi         ; 3340  shape number in the high byte = shape*256
        lda     #$00                    ; 3342  low byte starts at 0
        lsr     spritePlotPtrHi         ; 3344  16-bit shift right...
        ror     a                       ; 3346  ...through the low byte
        lsr     spritePlotPtrHi         ; 3347  and again
        ror     a                       ; 3349  shape*64
        sta     spritePlotPtr           ; 334A  low byte of the source pointer
        lda     #$00                    ; 334C  no extra low offset
        ldy     #$02                    ; 334E  $0200 = base of the decompressed shape buffer
        clc                             ; 3350  clear carry for the 16-bit pointer addition
        adc     spritePlotPtr           ; 3351  add the shape offset
        sta     spritePlotPtr           ; 3353  source pointer low byte
        tya                             ; 3355  base page $02
        adc     spritePlotPtrHi         ; 3356  plus the high byte of shape*64
        sta     spritePlotPtrHi         ; 3358  source pointer = $0200 + shape*64
        ldx     #$00                    ; 335A  default destination low byte
        ldy     #$8A                    ; 335C  default destination high byte: $8A00 = sprite 0
        lda     activeSpriteIndex       ; 335E  $91CC = which sprite to load
        beq     L_3367                  ; 3361  sprite 0 keeps the default
        ldx     #$40                    ; 3363  otherwise $8B40...
        ldy     #$8B                    ; 3365  ...= the shape of sprite 5
L_3367:
        stx     L_3373+1                ; 3367  patch the low byte of the STA absolute,x at $3373
        sty     L_3373+2                ; 336A  patch its high byte
        ldy     #$3E                    ; 336D  63 bytes to copy
        ldx     #$3E                    ; 336F  same index for source and destination
L_3371:
        lda     (spritePlotPtr),y       ; 3371  read from the shape buffer
L_3373:
        sta     irqVectorHi,x           ; 3373  patched to $8A00,x or $8B40,x; the $FFFF resting value is never used
        dex                             ; 3376  previous destination byte
        dey                             ; 3377  previous source byte
        bpl     L_3371                  ; 3378  loop over all 63 bytes
        ldx     activeSpriteIndex       ; 337A  which sprite was loaded
        lda     spriteBitMasks,x        ; 337D  $2F85[n] = 1 << n
        jmp     enableSprites           ; 3380  switch it on and return to the caller


; ----------------------------------------------------------------------
; showComcenCrossMarker - Puts the small cross marker at $9F22 into rows 7-13 of sprite 2, colours it
; light red and parks it on the comcen's cell of overlay B's 21x17 radar grid (grid column 10, row 8 -
; the grid is always drawn centred on the comcen).
; In:  none
; Out: sprite 2 shape/colour/position set and enabled, gridCursorCol zp_67 = 10, gridCursorRow zp_68 =
;     8
; Called from: overlay B ($75B6) while the RADAR/MISSILE screen is being built
; ----------------------------------------------------------------------
showComcenCrossMarker:
        lda     #$02                    ; 3383  sprite 2
        jsr     clearSpriteShape        ; 3385  blank its 64 shape bytes first
        ldy     #$14                    ; 3388  21 bytes = 7 sprite rows of 3 bytes
L_338A:
        lda     titleScreenRow20Col02,y ; 338A  $9F22 = the small cross/plus shape  (also graphicsData9300:comcenCrossMarkerShape)
        sta     sprite2ShapeRow7,y      ; 338D  $8A95 = sprite 2 shape + $15, i.e. row 7 of the sprite
        dey                             ; 3390  previous byte
        bpl     L_338A                  ; 3391  loop over the 21 bytes
        lda     #$0A                    ; 3393  colour $0A = light red
        sta     sprite2ColourShadow     ; 3395  $900A = sprite 2 colour shadow
        ldx     #$0A                    ; 3398  grid column 10 - where the own comcen always sits
        stx     gridCursorCol           ; 339A  zp_67 = radar grid cursor column
        ldy     #$08                    ; 339C  grid row 8
        sty     gridCursorRow           ; 339E  zp_68 = radar grid cursor row
        jsr     isRadarGridCellInRange  ; 33A0  overlay B range test; its carry result is never examined here, so the call is a leftover
        jsr     gridCellToSpriteCoords  ; 33A3  grid cell -> sprite coordinates
        stx     sprite2XShadow          ; 33A6  $9012 = sprite 2 X shadow
        sty     sprite2YShadow          ; 33A9  $901A = sprite 2 Y shadow
        lda     #$04                    ; 33AC  sprite 2 enable bit
        jmp     enableSprites           ; 33AE  switch it on and return to the caller


; ----------------------------------------------------------------------
; gridCellToSpriteCoords - Converts a cell of overlay B's 21x17 radar grid (one map cell per 8x8
; character cell) into sprite coordinates: spriteX = column*8 + $27, spriteY = row*8 + $43. The grid
; starts at character position (3,3); the two constants fold in the VIC sprite origin (24,50) and the
; offset of the marker shapes inside their sprite.
; In:  X = grid column 0-20, Y = grid row 0-16
; Out: X = sprite X, Y = sprite Y; A clobbered
; Called from: showComcenCrossMarker ($33A3), getUnitSpriteCentreOnGrid ($3410) and overlay B
;      ($7709, $77BC)
; ----------------------------------------------------------------------
gridCellToSpriteCoords:
        txa                             ; 33B1  grid column
        asl     a                       ; 33B2  column*2
        asl     a                       ; 33B3  column*4
        asl     a                       ; 33B4  column*8 - the radar grid uses one 8x8 character cell per map cell
        clc                             ; 33B5  clear carry before adding the grid origin
        adc     #$27                    ; 33B6  sprite X of grid column 0
        tax                             ; 33B8  return it in X
        tya                             ; 33B9  grid row
        asl     a                       ; 33BA  row*2
        asl     a                       ; 33BB  row*4
        asl     a                       ; 33BC  row*8
        clc                             ; 33BD  clear carry before adding the grid origin
        adc     #$43                    ; 33BE  sprite Y of grid row 0
        tay                             ; 33C0  return it in Y
        rts                             ; 33C1  done


; Three 2-entry tables indexed by explosion type: 0 = comcen missile strike, 1 = drone detonation.
; They give the sprite offsets that centre the ring blob on the target cell and the number of
; concentric rings that make the blob.
explosionMarkerXOffset:
        .byte   $0A,$08                 ; 33C2  ..  X offsets: 10 pixels for a missile strike, 8 for a drone blast
explosionMarkerYOffset:
        .byte   $08                     ; 33C4  matching Y offsets: 8 and 9 pixels
        .byte   $09                     ; 33C5  .
explosionMarkerRingCount:
        .byte   $02                     ; 33C6  ring counts: 2 rings for a missile strike, 4 for the bigger drone blast
        .byte   $04                     ; 33C7  .

; ----------------------------------------------------------------------
; showExplosionMarker - Shows the explosion marker for a weapon effect at map cell (X,Y). On the
; battlefield screen it draws a blob of concentric rings into sprite 4 and puts it straight on the
; 4x4-per-cell overview map; a missile strike that arrives while a drone is in the air instead just
; records the cell in the two patched operands of updateExplosionMarker, which keeps the marker in
; step with the scrolling drone camera window. Either way markerSpriteTimer is set to 90 frames, and
; the per-frame update in updateGameFrame switches sprite 4 off when it expires. The survey called
; this routine dead; it is not - it is called by two command handlers.
; In:  A = effect type (0 = comcen missile strike, 1 = drone detonation), X = map column, Y = map row;
;      droneControlFlags $920D, currentScreen $90FB
; Out: sprite 4 shape/position/colour, markerSpriteTimer $923E = 90; self-modifies $33EA (the effect
;      index) and, on the drone path, $3425/$3437 (the marker cell)
; Called from: cmdDroneDetonate ($5375, A=1) and cmdMissileStrike ($541C, A=0)
; ----------------------------------------------------------------------
showExplosionMarker:
        sta     L_33E9+1                ; 33C8  patch the LDX # at $33E9 with the effect index (0 missile, 1 drone)
        cmp     #$00                    ; 33CB  is this the comcen missile strike?
        bne     L_33E0                  ; 33CD  no - a drone blast always goes on the overview map
        lda     droneControlFlags       ; 33CF  $920D = drone control flags
        bpl     L_33E0                  ; 33D2  bit 7 clear = no drone in the air, so the drone camera is not on screen
        jsr     startExplosionMarkerTimer; 33D4  start the 90-frame marker countdown
        stx     loadExplosionMarkerCol+1; 33D7  remember the blast column in the LDA # of updateExplosionMarker
        sty     loadExplosionMarkerRow+1; 33DA  and the blast row
        jmp     updateExplosionMarker   ; 33DD  position sprite 4 inside the drone camera window and return


; ----------------------------------------------------------------------
; Battlefield path: build the ring blob and drop it straight on the overview map.
; ----------------------------------------------------------------------
L_33E0:
        lda     currentScreen           ; 33E0  $90FB = which screen is showing; 0 = the battlefield
        bne     L_340A                  ; 33E3  not on the battlefield - there is nothing to draw on
        jsr     mapCellToSpriteXYRaw    ; 33E5  blast cell -> overview map sprite coordinates
        txa                             ; 33E8  sprite X of the cell
L_33E9:
        ldx     #$FF                    ; 33E9  effect index, patched at $33C8
        sec                             ; 33EB  set carry for the centring subtraction
        sbc     explosionMarkerXOffset,x; 33EC  shift left so the ring blob is centred on the cell
        sta     sprite4XShadow          ; 33EF  $9014 = sprite 4 X shadow
        tya                             ; 33F2  sprite Y of the cell
        sec                             ; 33F3  set carry for the centring subtraction
        sbc     explosionMarkerYOffset,x; 33F4  shift up by the matching Y offset
        sta     sprite4YShadow          ; 33F7  $901C = sprite 4 Y shadow
        lda     explosionMarkerRingCount,x; 33FA  how many concentric rings this effect uses
        jsr     drawRingsInSprite4      ; 33FD  draw them into sprite 4
        lda     #$10                    ; 3400  sprite 4 enable bit
        jsr     enableSprites           ; 3402  switch the marker on

; ----------------------------------------------------------------------
; startExplosionMarkerTimer - Two-instruction tail shared by both paths of showExplosionMarker: arms
; the explosion marker for 90 frames (about 1.5 seconds).
; In:  none
; Out: markerSpriteTimer $923E = $5A
; Called from: showExplosionMarker ($33D4) and by fall-through from $3402
; ----------------------------------------------------------------------
startExplosionMarkerTimer:
        lda     #$5A                    ; 3405  $5A = 90 frames
        sta     markerSpriteTimer       ; 3407  $923E; updateGameFrame counts it down and hides sprite 4 at zero
L_340A:
        rts                             ; 340A  return (also the exit taken when the battlefield screen is not showing)


; ----------------------------------------------------------------------
; getUnitSpriteCentreOnGrid - Maps a battlefield cell onto overlay B's radar grid and returns the
; sprite coordinates of the centre of that grid cell, or C=1 when the cell lies outside the 21x17 grid
; drawn around the comcen.
; In:  X = map column, Y = map row
; Out: C=0 with X/Y = sprite coordinates of the cell centre, or C=1 and X/Y meaningless
; Called from: the unit tracking code ($5307) and overlay B ($7A03)
; ----------------------------------------------------------------------
getUnitSpriteCentreOnGrid:
        jsr     mapCellToRadarGrid      ; 340B  overlay B $7A13: map cell -> radar grid cell relative to the comcen, C=1 when off-grid
        bcs     L_341E                  ; 340E  outside the 21x17 grid: return with C=1
        jsr     gridCellToSpriteCoords  ; 3410  grid cell -> sprite coordinates of its top-left
        tya                             ; 3413  sprite Y
        clc                             ; 3414  clear carry before adding the half-cell offset
        adc     #$04                    ; 3415  +4 = half a character cell, i.e. the centre of the cell
        tay                             ; 3417  back into Y
        txa                             ; 3418  sprite X
        clc                             ; 3419  clear carry before adding the half-cell offset
        adc     #$04                    ; 341A  +4 for the same reason
        tax                             ; 341C  back into X
        clc                             ; 341D  C=0 = the cell is on the grid
L_341E:
        rts                             ; 341E  return


; ----------------------------------------------------------------------
; updateExplosionMarker - Keeps the explosion marker (sprite 4) on the right cell of the drone camera
; window, which is 14 columns by 12 rows of 8x8 pixel cells starting at the view origin. The marker
; cell lives in the two immediate operands at $3425 and $3437, patched by showExplosionMarker. When
; the countdown has expired or the cell has scrolled out of the window, sprite 4 is switched off
; instead. The survey believed nothing ever patched the operands; showExplosionMarker does.
; In:  markerSpriteTimer $923E, viewOriginCol zp_A5, viewOriginRow zp_A6, the operands at $3425/$3437
; Out: sprite 4 position shadows $9014/$901C and its enable bit; A clobbered
; Called from: showExplosionMarker ($33DD, by JMP) and overlay B's drone camera step ($827E)
; ----------------------------------------------------------------------
updateExplosionMarker:
        lda     markerSpriteTimer       ; 341F  $923E, counted down once per frame by updateGameFrame
        beq     L_344D                  ; 3422  zero = no explosion pending: make sure sprite 4 is off
loadExplosionMarkerCol:
        lda     #$00                    ; 3424  blast column, patched at $33D7
        sec                             ; 3426  set carry for the subtraction of the view origin
        sbc     viewOriginCol           ; 3427  column relative to the left edge of the drone camera window
        cmp     #$0E                    ; 3429  the window is 14 cells wide
        bcs     L_344D                  ; 342B  outside it (this also catches negative differences): hide the marker
        asl     a                       ; 342D  x2
        asl     a                       ; 342E  x4
        asl     a                       ; 342F  x8 - the drone camera draws one map cell per 8x8 character cell
        clc                             ; 3430  clear carry before adding the window origin
        adc     #$78                    ; 3431  sprite X that centres the ring blob on window column 0
        sta     sprite4XShadow          ; 3433  $9014 = sprite 4 X shadow
loadExplosionMarkerRow:
        lda     #$FF                    ; 3436  blast row, patched at $33DA
        sec                             ; 3438  set carry for the subtraction of the view origin
        sbc     viewOriginRow           ; 3439  row relative to the top of the window
        cmp     #$0C                    ; 343B  the window is 12 cells high
        bcs     L_344D                  ; 343D  outside it: hide the marker
        asl     a                       ; 343F  x2
        asl     a                       ; 3440  x4
        asl     a                       ; 3441  x8
        clc                             ; 3442  clear carry before adding the window origin
        adc     #$5C                    ; 3443  sprite Y that centres the blob on window row 0
        sta     sprite4YShadow          ; 3445  $901C = sprite 4 Y shadow
        lda     #$10                    ; 3448  sprite 4 enable bit
        jmp     enableSprites           ; 344A  switch it on and return to the caller

L_344D:
        lda     spriteEnableShadow      ; 344D  $9022 = sprite enable shadow
        and     #$EF                    ; 3450  clear bit 4 = sprite 4, the explosion marker
        sta     spriteEnableShadow      ; 3452  store it back (the raster IRQ writes $D015)
        rts                             ; 3455  done


; ----------------------------------------------------------------------
; drawComcenRingSprite - Builds the comcen marker of the drone screen: two concentric rings (radius 2
; then radius 1, which is a filled 3x3 dot) in sprite 4, coloured with the flashing pair $12 so it
; alternates between white and red.
; In:  none
; Out: sprite 4 shape rebuilt, sprite4ColourShadow $900C = $12, sprite 4 left disabled here,
;      activeSpriteIndex $91CC = 4, spritePlotMode $91F3 = $40
; Called from: overlay B ($80E1), right after the view has been centred on the comcen
; ----------------------------------------------------------------------
drawComcenRingSprite:
        lda     #$02                    ; 3456  two concentric rings for the comcen marker

; ----------------------------------------------------------------------
; drawRingsInSprite4 - Shared body of drawComcenRingSprite: clears sprite 4 and draws A concentric
; rings into it, radius A down to radius 1, then gives it the flashing colour pair $12. The ring count
; is held in the LDX # operand at $346E and counted down there.
; In:  A = number of rings / radius of the outermost one
; Out: sprite 4 shape and colour; sprite 4 disabled while it is rebuilt; self-modifies $346E
; Called from: showExplosionMarker ($33FD, A = 2 or 4) and by fall-through from drawComcenRingSprite
; ----------------------------------------------------------------------
drawRingsInSprite4:
        sta     L_346D+1                ; 3458  patch the LDX # at $346D with the outermost radius / ring count
        lda     spriteEnableShadow      ; 345B  $9022 = sprite enable shadow
        and     #$EF                    ; 345E  clear bit 4: hide sprite 4 while its shape is rebuilt
        sta     spriteEnableShadow      ; 3460  store it back
        lda     #$40                    ; 3463  bit 6 = 'set the pixel'
        sta     spritePlotMode          ; 3465  $91F3 = plot mode for drawCircleInSprite
        lda     #$04                    ; 3468  sprite 4
        jsr     clearSpriteShape        ; 346A  blank its 64 shape bytes
L_346D:
        ldx     #$FF                    ; 346D  current radius, patched at $3458 and decremented below
        lda     #$04                    ; 346F  draw into sprite 4
        jsr     drawCircleInSprite      ; 3471  one circle of that radius, centred on sprite pixel (12,10)
        dec     L_346D+1                ; 3474  next smaller radius
        bne     L_346D                  ; 3477  loop down to radius 1, which fills the centre with a 3x3 dot
        lda     #$12                    ; 3479  $12 = flashing colour pair: alternates colour 2 (red) and colour 1 (white)
        sta     sprite4ColourShadow     ; 347B  $900C = sprite 4 colour shadow
        rts                             ; 347E  done


; ----------------------------------------------------------------------
; orderSelectedUnitToCursor - Issues a MOVE order for the selected unit to the cell under the cursor:
; takes the unit out of its group, queues command $80 unit,col,row into the outgoing ring (so the
; engine, the opponent and the game film all see the same bytes) and refreshes the 'already at the
; destination' flag.
; In:  selectedUnit $90F8, viewOriginCol zp_A5, viewOriginRow zp_A6
; Out: cmdParam0-2 zp_73-zp_75 = unit/column/row, $FB54[unit] bit 6 cleared and bit 7 refreshed,
;      4 bytes queued for the command interpreter, C=0
; Called from: the fire-button handler of the battlefield cursor loop ($2212)
; ----------------------------------------------------------------------
orderSelectedUnitToCursor:
        lda     viewOriginCol           ; 347F  left edge column of the tactical view
        clc                             ; 3481  clear carry before stepping to the cursor column
        adc     #$03                    ; 3482  +3 = the cursor column
        sta     cmdParam1               ; 3484  zp_74 = command argument 1 (destination column)
        lda     viewOriginRow           ; 3486  top edge row of the tactical view
        clc                             ; 3488  clear carry before stepping to the cursor row
        adc     #$02                    ; 3489  +2 = the cursor row
        sta     cmdParam2               ; 348B  zp_75 = command argument 2 (destination row)
        ldx     selectedUnit            ; 348D  $90F8 = the unit being ordered
        lda     unitDisplayFlagsTable,x ; 3490  $FB54[unit] = local display/group flags
        and     #$BF                    ; 3493  clear bit 6: a unit given its own order leaves its group
        sta     unitDisplayFlagsTable,x ; 3495  store the flags back
        stx     cmdParam0               ; 3498  zp_73 = command argument 0 (unit index)
        lda     #$80                    ; 349A  command byte $80 = MOVE unit,col,row
        jsr     queueCmd4               ; 349C  $560A: queue the 4 bytes for the engine, the link and the film
        jsr     updateUnitAtDestinationFlag; 349F  set or clear the 'standing on its destination' bit
        clc                             ; 34A2  C=0 tells the cursor loop the order was accepted
        rts                             ; 34A3  done


; ----------------------------------------------------------------------
; updateUnitAtDestinationFlag - Sets bit 7 of unitDisplayFlags ($FB54) for the unit in cmdParam0 when
; its current cell is the same as the ordered destination in cmdParam1/cmdParam2, and clears it
; otherwise. The bit is what the console and the map drawing use to tell a moving unit from an idle
; one.
; In:  cmdParam0 zp_73 = unit, cmdParam1 zp_74 = destination column, cmdParam2 zp_75 = destination row
; Out: $FB54[unit] bit 7; self-modifies the ORA operand at $34C1
; Called from: orderSelectedUnitToCursor ($349F) and the command executors at $4A4D and $4C10
; ----------------------------------------------------------------------
updateUnitAtDestinationFlag:
        ldy     cmdParam0               ; 34A4  zp_73 = unit index
        lda     #$00                    ; 34A6  assume the unit still has somewhere to go
        ldx     unitColTable,y          ; 34A8  $F640[unit] = current map column
        cpx     cmdParam1               ; 34AB  same as the ordered column?
        bne     L_34B8                  ; 34AD  no - it is still travelling
        ldx     unitRowTable,y          ; 34AF  $F6A4[unit] = current map row
        cpx     cmdParam2               ; 34B2  same as the ordered row?
        bne     L_34B8                  ; 34B4  no - still travelling
        lda     #$80                    ; 34B6  bit 7 = the unit is already standing on its destination
L_34B8:
        sta     L_34C0+1                ; 34B8  patch the ORA # at $34C0 with $80 or $00
        lda     unitDisplayFlagsTable,y ; 34BB  $FB54[unit]
        and     #$7F                    ; 34BE  clear the old 'at destination' bit
L_34C0:
        ora     #$FF                    ; 34C0  patched: put the freshly computed bit back
        sta     unitDisplayFlagsTable,y ; 34C2  store the flags
        rts                             ; 34C5  done


; ----------------------------------------------------------------------
; applyMoveOrder - Executor of command $80 MOVE (unit, column, row). It takes the unit out of its
; group, republishes the group bits into the local flags copy, and falls into setUnitDestination to
; store the clamped destination. The survey listed this as an unreferenced older version; it is in
; fact live - cmdMoveUnit jumps here from $5017 - and there is no other move executor in the build.
; In:  cmdParam0 zp_73 = unit, cmdParam1/cmdParam2 zp_74/zp_75 = destination (already mirrored for a
;      remote command)
; Out: $F708[unit] bit 6 cleared, $FB54[unit] group bits refreshed, then everything setUnitDestination
;     does
; Called from: cmdMoveUnit ($5017, by JMP)
; ----------------------------------------------------------------------
applyMoveOrder:
        ldx     cmdParam0               ; 34C6  zp_73 = the unit being ordered
        lda     unitFlagsTable,x        ; 34C8  $F708[unit] = authoritative unit flags (facing, side, group)
        and     #$BF                    ; 34CB  clear bit 6: an individual move order takes the unit out of its group
        sta     unitFlagsTable,x        ; 34CD  store the flags back
        jsr     syncLocalGroupFlags     ; 34D0  $4C6B: copy the group bits into the $FB54 display flags
        ldy     cmdParam0               ; 34D3  Y = unit index for the destination code below

; ----------------------------------------------------------------------
; setUnitDestination - Stores a destination for unit Y. A comcen whose speed option is class 4 (fixed)
; has its destination forced back to its own cell; a destination equal to the current cell is stored
; with bit 7 set, which marks the unit idle and is mirrored into unitDisplayFlags bit 7; anything else
; is clamped to the 40x40 map. The walking waypoint is reset to the unit's current cell (except for
; the solo trainer's own units, which keep theirs), and the order cancels any dig-in in progress.
; In:  Y = unit index, cmdParam1/cmdParam2 zp_74/zp_75 = destination column/row, gamePhase zp_B1,
;      comcenSpeedClass $92B4 / opponentComcenSpeedClass $92B5, comcenUnit $9236,
; soloTrainerAtGameStart $92FE
; Out: $F7D0/$F834 destination, $FB54 bit 7, $F898/$F8FC waypoint, $F960 bits 5-7 cleared,
;      $F76C bit 6 cleared; self-modifies the ORA operand at $3521
; Called from: the group formation move ($4A31, $4A9B), the destination command ($4D21), the group
;      order tail ($4C68, by JMP) and by fall-through from applyMoveOrder
; ----------------------------------------------------------------------
setUnitDestination:
        lda     gamePhase               ; 34D5  zp_B1 = 0 only while the battle is actually running
        bne     L_34FA                  ; 34D7  in the setup, film and menu phases the comcen speed rule does not apply
        cpy     #$31                    ; 34D9  unit 49 = player 0's comcen
        beq     L_34E1                  ; 34DB  it is a comcen
        cpy     #$63                    ; 34DD  unit 99 = player 1's comcen
        bne     L_34FA                  ; 34DF  not a comcen: nothing special
L_34E1:
        lda     comcenSpeedClass        ; 34E1  $92B4 = the local player's COMCEN SPEED option
        cpy     comcenUnit              ; 34E4  $9236 = index of the local player's comcen
        beq     L_34EC                  ; 34E7  it is our own comcen, use our own option
        lda     opponentComcenSpeedClass; 34E9  $92B5 = the opponent's comcen speed class
L_34EC:
        cmp     #$04                    ; 34EC  class 4 = the comcen may not move at all
        bne     L_34FA                  ; 34EE  any other class: the ordered destination stands
        lda     unitColTable,y          ; 34F0  immobile comcen: destination column...
        sta     cmdParam1               ; 34F3  ...forced to where it already stands
        lda     unitRowTable,y          ; 34F5  and the same for the row
        sta     cmdParam2               ; 34F8  zp_75

; ----------------------------------------------------------------------
; Store the destination: equal to the current cell means 'idle', anything else is clamped to the map,
; and the idle bit is mirrored into the display flags.
; ----------------------------------------------------------------------
L_34FA:
        lda     cmdParam1               ; 34FA  destination column
        cmp     unitColTable,y          ; 34FC  $F640[unit] = current column
        bne     L_350E                  ; 34FF  different - the unit really has to move
        lda     cmdParam2               ; 3501  destination row
        cmp     unitRowTable,y          ; 3503  $F6A4[unit] = current row
        bne     L_350E                  ; 3506  different - the unit really has to move
        lda     cmdParam1               ; 3508  same cell: take the column again
        ora     #$80                    ; 350A  bit 7 = 'destination reached, nothing to do'
        bmi     L_3513                  ; 350C  always taken (bit 7 was just set)
L_350E:
        lda     cmdParam1               ; 350E  destination column
        jsr     clampMapCoord           ; 3510  keep it inside the 40x40 battlefield
L_3513:
        sta     unitDestColTable,y      ; 3513  $F7D0[unit] = destination column (bit 7 = idle)
        and     #$80                    ; 3516  isolate the idle bit
        sta     L_3520+1                ; 3518  patch the ORA # at $3520 with it
        lda     unitDisplayFlagsTable,y ; 351B  $FB54[unit] = display flags
        and     #$7F                    ; 351E  clear the old idle bit
L_3520:
        ora     #$FF                    ; 3520  patched: mirror the new idle bit into bit 7
        sta     unitDisplayFlagsTable,y ; 3522  store the display flags
        lda     cmdParam2               ; 3525  destination row
        jsr     clampMapCoord           ; 3527  clamp it to the map as well
        sta     unitDestRowTable,y      ; 352A  $F834[unit] = destination row
        bit     soloTrainerAtGameStart  ; 352D  $92FE bit 7 = this game is against the solo trainer
        bpl     L_3536                  ; 3530  modem or film game: always reset the waypoint
        cpy     #$32                    ; 3532  units 50-99 are the trainer's own units
        bcs     L_354C                  ; 3534  the trainer maintains their waypoints itself

; ----------------------------------------------------------------------
; Reset the walking waypoint to the cell the unit is standing on, keeping the two flag bits in the top
; of those bytes.
; ----------------------------------------------------------------------
L_3536:
        lda     unitPathColTable,y      ; 3536  $F898[unit] = waypoint column, bits 6-7 are flags
        and     #$C0                    ; 3539  keep only those two flag bits
        ora     unitColTable,y          ; 353B  waypoint column = the cell the unit is standing on
        sta     unitPathColTable,y      ; 353E  store it back
        lda     unitPathRowTable,y      ; 3541  $F8FC[unit] = waypoint row, bits 6-7 are flags
        and     #$C0                    ; 3544  keep the flag bits
        ora     unitRowTable,y          ; 3546  waypoint row = current row
        sta     unitPathRowTable,y      ; 3549  store it back

; ----------------------------------------------------------------------
; An order to move cancels any dig-in the unit had started or completed.
; ----------------------------------------------------------------------
L_354C:
        lda     unitStunTable,y         ; 354C  $F960[unit]: low nibble stun counter, bits 5-7 dig progress
        and     #$1F                    ; 354F  clear bits 5-7 - the unit stops digging in
        sta     unitStunTable,y         ; 3551  store it back
        lda     unitTypeTable,y         ; 3554  $F76C[unit] = type byte
        and     #$BF                    ; 3557  clear bit 6 = no longer dug in / fortified
        sta     unitTypeTable,y         ; 3559  store it back
        rts                             ; 355C  the order is stored


; ----------------------------------------------------------------------
; clampMapCoord - Clamps a signed map coordinate to the battlefield: negative becomes 0, anything from
; 40 upwards becomes 39.
; In:  A = coordinate
; Out: A = 0..39
; Called from: setUnitDestination ($3510 for the column, $3527 for the row)
; ----------------------------------------------------------------------
clampMapCoord:
        bpl     L_3561                  ; 355D  already zero or positive
        lda     #$00                    ; 355F  negative coordinates clamp to column/row 0
L_3561:
        cmp     #$28                    ; 3561  40 = width and height of the battlefield
        bcc     L_3567                  ; 3563  inside the map
        lda     #$27                    ; 3565  39 = last valid column/row
L_3567:
        rts                             ; 3567  done


; ----------------------------------------------------------------------
; markAllUnitsVisible - Sets bits 6 and 7 (spotted this cycle, currently visible) of unitEnergy for
; all 100 units, i.e. reveals the whole battlefield. Used at the end of a game and whenever no game is
; in progress.
; In:  none
; Out: $F9C4[0..99] bits 6-7 set; Y = $FF, A clobbered
; Called from: the game reset at $4018 and ageUnitVisibility ($3579, by branch)
; ----------------------------------------------------------------------
markAllUnitsVisible:
        ldy     #$63                    ; 3568  unit 99 down to unit 0
L_356A:
        lda     unitEnergyTable,y       ; 356A  $F9C4[unit]: bits 0-5 energy, bit 6 spotted, bit 7 visible
        ora     #$C0                    ; 356D  mark it both spotted and visible
        sta     unitEnergyTable,y       ; 356F  store it back
        dey                             ; 3572  previous unit
        bpl     L_356A                  ; 3573  loop over all 100 units
        rts                             ; 3575  done


; ----------------------------------------------------------------------
; ageUnitVisibility - Run once per unit-processing cycle, before the spotting passes: every unit that
; was spotted during the last cycle (energy bit 6) stays visible (bit 7) and everything else fades
; out, and bit 6 is cleared ready for the coming scan. It also resets the unit-scan side filter to
; 'accept every unit'. When the game is over it simply reveals everything instead.
; In:  gameEndReason $0BA8, unitEnergyTable $F9C4
; Out: $F9C4 visibility bits rewritten, the collectUnitsInRadius filter at $37FF/$3800 set to
;      compare against 0 with BCC; self-modifies $359C
; Called from: finishRound ($4660)
; ----------------------------------------------------------------------
ageUnitVisibility:
        lda     gameEndReason           ; 3576  $0BA8 = nonzero once the game has ended
        bne     markAllUnitsVisible     ; 3579  game over: show every unit instead of ageing the flags
        lda     #$00                    ; 357B  compare value 0...
        sta     scanSideFilterCompare+1 ; 357D  ...into the CMP # of the unit scan filter at $37FE
        lda     #$90                    ; 3580  opcode of BCC
        sta     scanSideFilterBranch    ; 3582  so the filter at $3800 accepts every unit index
        ldy     #$00                    ; 3585  unit 0 upwards

; ----------------------------------------------------------------------
; Shift each unit's visibility: bit 6 (spotted last cycle) becomes bit 7 (visible now).
; ----------------------------------------------------------------------
L_3587:
        ldx     #$80                    ; 3587  assume the unit stays visible
        lda     unitEnergyTable,y       ; 3589  $F9C4[unit]
        and     #$40                    ; 358C  bit 6 = it was spotted during the last scan cycle
        bne     L_3592                  ; 358E  it was - keep it visible
        ldx     #$00                    ; 3590  it was not - it fades out of sight
L_3592:
        lda     unitEnergyTable,y       ; 3592  read the byte again
        and     #$3F                    ; 3595  keep the energy value, drop both visibility bits
        sta     L_359B+1                ; 3597  patch the ORA # at $359B with it
        txa                             ; 359A  $80 (visible) or $00 (hidden)
L_359B:
        ora     #$FF                    ; 359B  patched energy value
        sta     unitEnergyTable,y       ; 359D  bit 7 = visible, bit 6 cleared ready for this cycle's scan
        iny                             ; 35A0  next unit
        cpy     #$64                    ; 35A1  100 units
        bcc     L_3587                  ; 35A3  loop
        rts                             ; 35A5  done


; sightRangeByType - base range in cells, indexed by unit type for the spotting pass (entries 0-4) and
; by type+5 for the attack pass (entries 5-9). The terrain bonus below is added on top and the sum is
; capped at 10.
sightRangeByType:
        .byte   $03,$03,$03,$05,$03,$03,$03,$06; 35A6  ........  spotting: GRUNT 3, RIDER 3, BOOMER 3, SPY 5, COMCEN 3; attack: GRUNT 3, RIDER 3, BOOMER 6
        .byte   $05                     ; 35AE  attack range of SPY 5 and COMCEN 3 (entries 8 and 9)
        .byte   $03                     ; 35AF  .

; rangeDistanceSqTable - r*(r+1) for r = 0..10. scanVisibleEnemies compares the squared distance of a
; candidate against this, which rounds the square search box into a circle.
rangeDistanceSqTable:
        .byte   $00,$02,$06,$0C,$14,$1E,$2A,$38; 35B0  ......*8  r*(r+1) for r = 0..7: 0, 2, 6, 12, 20, 30, 42, 56
        .byte   $48                     ; 35B8  r*(r+1) for r = 8, 9, 10: 72, 90, 110
        .byte   $5A,$6E                 ; 35B9  Zn

; terrainSightBonusTable - extra range given by the terrain class under the observer (0-7); doubled on
; the spotting pass, counted once on the attack pass.
terrainSightBonusTable:
        .byte   $00,$00,$00,$01,$01,$02,$02,$00; 35BB  ........  classes 0-7: 0, 0, 0, 1, 1, 2, 2, 0 extra cells of range

; ----------------------------------------------------------------------
; scanVisibleEnemies - Builds the list of enemy units that unit Y can see (X bit 7 clear, the spotting
; pass) or can shoot at (X bit 7 set, the attack-target pass). The effective range is
; sightRangeByType[type, +5 when attacking] plus the terrain bonus of the cell the unit stands on
; (doubled while spotting), capped at 10 cells. collectUnitsInRadius fills $0400 with the enemy units
; in the square and $0432 with their squared distances; this routine then walks that list and strikes
; off ($0400[n] = $FF) every candidate that is out of range, dead, not yet visible (attack pass),
; protected by the spy/hidden rules (spotting pass) or hidden behind a ridge. $0464[n] receives the
; direction 0-3 from the scanner to the candidate. Candidates that are not in the unit's preferred
; direction (zp_6B) are pushed one step further away, which biases a unit towards the target it is
; already facing. The first sighting of an enemy spy or comcen queues status message $0F / $0D. Only
; the head of the routine ($35C3-$366E) belongs to this chunk; it continues to $3745.
; In:  X = pass (bit 7: 0 = spotting, 1 = attack targets), Y = the scanning unit; unit arrays, the
;     map,
;      the terrain class table at $077E
; Out: $0400 candidate list (entries struck off with $FF), $0432 squared distances, $0464 directions,
;      scanResultCount zp_2F = index of the last entry ($FF = none), fireTargetDistance $91F1 = range,
;      scanUnitTerrain/$9227, scanUnitType/$9228, scanUnitTerrainHeight/$9229; self-modifies $35F9
;      (range table index) and $362B (distance limit)
; Called from: the boomer target search ($3C8A) and the two scan passes of processUnitStep ($43AA
;      spotting, $43DC attack targets)
; ----------------------------------------------------------------------
scanVisibleEnemies:
        stx     scratch20               ; 35C3  zp_20 = pass flag: bit 7 clear = who can I see, bit 7 set = who can I shoot at
        sty     scratch21               ; 35C5  zp_21 = the unit doing the scanning
        lda     unitTypeTable,y         ; 35C7  $F76C[unit] = type byte
        and     #$07                    ; 35CA  unit type 0-4 (GRUNT, RIDER, BOOMER, SPY, COMCEN)
        sta     scanUnitType            ; 35CC  $9228 = type of the scanning unit, used by the spy/hidden rules below
        bit     scratch20               ; 35CF  which pass is this?
        bpl     L_35D6                  ; 35D1  spotting pass uses sightRangeByType entries 0-4
        clc                             ; 35D3  clear carry before shifting to the attack half of the table
        adc     #$05                    ; 35D4  attack pass uses entries 5-9
L_35D6:
        sta     L_35F8+1                ; 35D6  patch the LDX # at $35F8 with that table index
        jsr     setScanFilterOppositeSide; 35D9  $378B: set the collectUnitsInRadius filter to 'units of the other side only'
        lda     unitTerrainUnderTable,y ; 35DC  $FAF0[unit] = the terrain tile hidden under the unit
        sta     scanUnitTerrain         ; 35DF  $9227 = that tile, kept for the callers
        tax                             ; 35E2  index it
        lda     D_077E,x                ; 35E3  $077E[tile] = terrain class 0-7
        tax                             ; 35E6  index the class tables with it
        lda     terrainHeightTable,x    ; 35E7  $38B3[class] = height, used by the line-of-sight walk
        sta     scanUnitTerrainHeight   ; 35EA  $9229 = height the observer is standing at
        lda     terrainSightBonusTable,x; 35ED  extra range for standing on high ground
        beq     L_35F7                  ; 35F0  flat ground: no bonus
        bit     scratch20               ; 35F2  which pass?
        bmi     L_35F7                  ; 35F4  attack pass: the bonus counts once
        asl     a                       ; 35F6  spotting pass: high ground doubles the terrain bonus
L_35F7:
        clc                             ; 35F7  prepare the addition
L_35F8:
        ldx     #$FF                    ; 35F8  range table index, patched at $35D6
        adc     sightRangeByType,x      ; 35FA  base range for this unit type and pass
        cmp     #$0A                    ; 35FD  10 cells is the hard limit
        bcc     L_3603                  ; 35FF  below it
        lda     #$0A                    ; 3601  clamp to 10
L_3603:
        sta     fireTargetDistance      ; 3603  $91F1 = effective range of this scan
        tax                             ; 3606  index the squared-distance table with it
        lda     rangeDistanceSqTable,x  ; 3607  r*(r+1) = the squared distance a target must stay below
        sta     L_362A+1                ; 360A  patch the CMP # at $362A with that limit
        ldx     unitColTable,y          ; 360D  $F640[unit] = the scanner's column
        lda     unitRowTable,y          ; 3610  $F6A4[unit] = its row
        tay                             ; 3613  Y = centre row
        lda     fireTargetDistance      ; 3614  half width of the square to search = the range
        jsr     collectUnitsInRadius    ; 3617  $37AA: gather the enemy units in that square into $0400 / $0432
        stx     scanResultCount         ; 361A  zp_2F = index of the last candidate ($FF if none)
        stx     scratch18               ; 361C  zp_18 = index of the candidate being examined
        bpl     L_3623                  ; 361E  at least one candidate
        jmp     scanCandidatesDone      ; 3620  nothing in range: return with zp_2F = $FF


; ----------------------------------------------------------------------
; Candidate loop: zp_18 walks the list from the last entry down to entry 0. A candidate that fails any
; test is struck off at $3735 by writing $FF into $0400[n].
; ----------------------------------------------------------------------
L_3623:
        lda     checksumXorConstant,x   ; 3623  $0400[n] = index of the candidate unit  (also trainerPlaybook0200:play3Col12)
        tay                             ; 3626  Y = candidate unit
        lda     play3Row12,x            ; 3627  $0432[n] = squared distance from the scanner
L_362A:
        cmp     #$FF                    ; 362A  patched at $360A with r*(r+1) for the effective range
        bcc     L_3631                  ; 362C  inside the circle
L_362E:
        jmp     rejectScanCandidate     ; 362E  outside it: strike this candidate off the list

L_3631:
        ldx     scratch21               ; 3631  the scanning unit
        jsr     initLineBetweenUnits    ; 3633  $375D: set up the line walker from scanner to candidate; A = direction 0-3
        ldx     scratch18               ; 3636  candidate index again
        sta     play4Grp12,x            ; 3638  $0464[n] = direction of this candidate from the scanner
        cmp     targetDirection         ; 363B  zp_6B = the direction the unit prefers (where its current target is)
        beq     L_3657                  ; 363D  same direction: no penalty
        lda     scanResultCount         ; 363F  how many candidates were found
        cmp     #$01                    ; 3641  index 0 means a single candidate
        bcc     L_3657                  ; 3643  only one candidate: no penalty, take it whatever the direction
        lda     play3Row12,x            ; 3645  squared distance again
        cmp     #$03                    ; 3648  squared distance below 3 = an adjacent or diagonal cell
        bcc     L_3657                  ; 364A  point blank: no penalty
        clc                             ; 364C  otherwise...
        adc     #$01                    ; 364D  ...count it as one step further away than it is
        sta     play3Row12,x            ; 364F  store the penalised distance back
        cmp     L_362A+1                ; 3652  did the penalty push it out of range?
        bcs     L_362E                  ; 3655  yes: strike it off
L_3657:
        lda     unitTypeTable,y         ; 3657  $F76C[candidate] = type byte
        bmi     L_362E                  ; 365A  bit 7 = destroyed: strike it off
        and     #$07                    ; 365C  candidate type 0-4
        bit     scratch20               ; 365E  which pass?
        bpl     L_3671                  ; 3660  spotting pass: apply the spy and hidden-unit rules
        lda     unitEnergyTable,y       ; 3662  attack pass: $F9C4[candidate] bit 7 = the target is currently visible
        bpl     L_362E                  ; 3665  you cannot shoot at something you have not spotted
        lda     L_35F8+1                ; 3667  the range table index of this scan
        cmp     #$07                    ; 366A  index 7 = BOOMER attacking; 5 and 6 are GRUNT and RIDER
        bcc     beginLineOfSightWalk    ; 366C  grunts and riders must have a clear line of sight
        jmp     nextScanCandidate       ; 366E  boomers, spies and comcens fire without one: accept this candidate


; ----------------------------------------------------------------------
; Spotting pass only (scan mode zp_20 bit 7 clear): the two kinds of target that need more than plain
; range.  A = candidate unit type 0-4, X = its index in the candidate lists, Y = its unit index, zp_18
; = the same list index.
; ----------------------------------------------------------------------
L_3671:
        cmp     #$03                    ; 3671  is the candidate a SPY (type 3)?
        bne     L_3685                  ; 3673  no - fall through to the cloak rule
        lda     scanUnitType            ; 3675  type of the unit that is doing the looking
        cmp     #$03                    ; 3678  only a SPY can see another SPY
        bne     L_362E                  ; 367A  anything else never notices it -> drop the candidate
        lda     play3Row12,x            ; 367C  squared distance from the $0432 list
        cmp     #$14                    ; 367F  a spy is visible only inside dist^2 20
        bcs     L_362E                  ; 3681  farther away -> drop the candidate
        bcc     beginLineOfSightWalk    ; 3683  close enough, but it still needs a clear line of sight (always taken, C=0 here)

; ----------------------------------------------------------------------
; A cloaked unit ($F898 bit 7, set by the CLOAKING ON command) can only be made out from close up, and
; a SPY has twice the range for it.
; ----------------------------------------------------------------------
L_3685:
        lda     unitPathColTable,y      ; 3685  cloak flag of the candidate ($F898 bit 7)
        bpl     beginLineOfSightWalk    ; 3688  not cloaked -> ordinary line-of-sight test
        lda     play3Row12,x            ; 368A  squared distance to the cloaked unit
        cmp     #$0C                    ; 368D  nobody sees a cloaked unit beyond dist^2 12
        bcs     L_362E                  ; 368F  too far -> drop the candidate
        ldy     scanUnitType            ; 3691  type of the spotter (this overwrites the candidate index in Y; $36AC reloads it)
        cpy     #$03                    ; 3694  is the spotter itself a SPY?
        beq     beginLineOfSightWalk    ; 3696  a spy detects cloaked units out to the full dist^2 12
        cmp     #$06                    ; 3698  everyone else only inside dist^2 6 (A still holds the distance)
        bcs     L_362E                  ; 369A  too far for a non-spy -> drop the candidate

; ----------------------------------------------------------------------
; Line-of-sight walk.  The line from the spotter to the candidate was set up by initLineBetweenUnits
; at $3633; zp_1E carries the terrain height of the previous cell and zp_1F the ridge state, both used
; by checkLineOfSightStep.
; ----------------------------------------------------------------------
beginLineOfSightWalk:
        lda     #$80                    ; 369C  $80 = the ground has not risen yet along this line
        sta     scratch1F               ; 369E  reset the ridge tracker
        lda     scanUnitTerrainHeight   ; 36A0  terrain height under the spotter itself
        sta     scratch1E               ; 36A3  the height walk starts at the spotter's own level
losWalkNextCell:
        jsr     stepLine                ; 36A5  advance the line by one map cell
        bne     L_36F7                  ; 36A8  steps left -> this is a cell between the two units

; ----------------------------------------------------------------------
; The line has reached the candidate's own cell: forest cover and one last height step.
; ----------------------------------------------------------------------
        ldx     scratch18               ; 36AA  index of the candidate in the scan lists
        ldy     checksumXorConstant,x   ; 36AC  its unit index (the $0400 candidate list lies over the boot checksum table)  (also trainerPlaybook0200:play3Col12)
        ldx     unitTerrainUnderTable,y ; 36AF  map byte hidden underneath the candidate ($FAF0)
        lda     D_077E,x                ; 36B2  -> terrain class 0-8 (the table is addressed with the raw cell byte)
        cmp     #$02                    ; 36B5  class 2 = forest
        bne     L_36C7                  ; 36B7  open ground -> only the height test is left
        lda     unitEnergyTable,y       ; 36B9  energy byte; bit 7 = the unit is already visible
        bmi     L_36C7                  ; 36BC  a unit that has been seen once stays visible even in the trees
        ldx     scratch18               ; 36BE  back to the candidate's list index
        lda     play3Row12,x            ; 36C0  its squared distance
        cmp     #$06                    ; 36C3  inside dist^2 6 the trees do not hide it
        bcs     rejectScanCandidate     ; 36C5  farther away and in forest -> not visible
L_36C7:
        ldx     unitTerrainUnderTable,y ; 36C7  terrain byte under the candidate again (X was reused above)
        lda     D_077E,x                ; 36CA  -> terrain class
        tax                             ; 36CD  checkLineOfSightStep wants the class in X
        jsr     checkLineOfSightStep    ; 36CE  last height step: does a ridge stand between us and the target?
        bcs     rejectScanCandidate     ; 36D1  blocked -> not visible
        cpy     #$32                    ; 36D3  unit 50 = first unit of side 1: is the candidate one of the local player's units?
D_36D5:
        bcc     nextScanCandidate       ; 36D5  branch opcode patched per side by overlay A ($7BD8: $90 BCC for side 0, $B0 BCS for side 1) - our own unit, accept it without a message
        lda     unitEnergyTable,y       ; 36D7  an enemy: bit 7 = it was already visible
        bmi     nextScanCandidate       ; 36DA  not a first sighting -> no message
        lda     unitTypeTable,y         ; 36DC  candidate's type byte
        and     #$07                    ; 36DF  strip the dead/dug-in bits
        cmp     #$03                    ; 36E1  only SPY (3) and COMCEN (4) are announced
        bcc     nextScanCandidate       ; 36E3  grunt/rider/boomer -> accept silently
        bne     L_36EB                  ; 36E5  not a spy -> test for the comcen
        lda     #$0F                    ; 36E7  message $0F 'SPOTTED ENEMY SPY!'
        bne     L_36F1                  ; 36E9  (always taken)
L_36EB:
        cmp     #$04                    ; 36EB  type 4 = COMCEN?
        bne     nextScanCandidate       ; 36ED  neither -> accept silently
        lda     #$0D                    ; 36EF  message $0D 'SPOTTED ENEMY COMCEN!'
L_36F1:
        jsr     queueMessageUnlessFilm  ; 36F1  queue it (dropped during solo/film playback)
        jmp     nextScanCandidate       ; 36F4  candidate accepted, on to the next one


; ----------------------------------------------------------------------
; An intermediate cell of the line.  A unit standing in it can stop a shot but never blocks plain
; spotting; forest and a ridge stop both.
; ----------------------------------------------------------------------
L_36F7:
        ldx     lineCurCol              ; 36F7  column the line walker has reached
        ldy     lineCurRow              ; 36F9  and its row
        jsr     readMapCell             ; 36FB  read that map cell (N set = a unit stands there)
        bpl     L_3724                  ; 36FE  bare terrain -> straight to the height test
        tay                             ; 3700  unit cell: Y = $80|index, which the base-$80 alias tables undo
        lda     scratch20               ; 3701  scan mode
        bpl     L_3721                  ; 3703  spotting pass: units in the way never block the view
        lda     unitTypeTableAlias,y    ; 3705  attack pass: type of the unit standing in the line of fire ($F76C-$80)
        and     #$07                    ; 3708  strip the dead/dug-in bits
        cmp     #$03                    ; 370A  type 3 = SPY
        beq     L_3721                  ; 370C  a spy is too small to stop a shot
        lda     unitFlagsTableAlias,y   ; 370E  flags of the blocking unit ($F708-$80)
        ldx     scratch21               ; 3711  the unit that wants to fire
        eor     unitFlagsTable,x        ; 3713  compare the two flag bytes
        and     #$20                    ; 3716  bit 5 = side
        bne     rejectScanCandidate     ; 3718  an enemy unit in the way stops the shot
        lda     scanUnitTerrainHeight   ; 371A  one of ours in the way: are we firing from high ground?
        bne     L_3721                  ; 371D  yes -> shoot over his head
        beq     rejectScanCandidate     ; 371F  on the flat a friendly unit blocks the shot (always taken)
L_3721:
        lda     unitTerrainUnderAlias,y ; 3721  terrain byte hidden under that unit ($FAF0-$80)
L_3724:
        tax                             ; 3724  map byte -> table index
        lda     D_077E,x                ; 3725  -> terrain class of this cell
        tax                             ; 3728  checkLineOfSightStep wants the class in X
        cpx     #$02                    ; 3729  class 2 = forest
        beq     rejectScanCandidate     ; 372B  woods in the way cut the line completely
        jsr     checkLineOfSightStep    ; 372D  feed this cell's height into the ridge test
        bcs     rejectScanCandidate     ; 3730  a ridge cuts the line -> candidate not visible
        jmp     losWalkNextCell         ; 3732  keep walking toward the target


; ----------------------------------------------------------------------
; Candidate rejected: $FF in its slot of the $0400 list tells every reader to ignore the entry.
; $373C is the tail of the per-candidate loop whose head is at $3623.
; ----------------------------------------------------------------------
rejectScanCandidate:
        ldx     scratch18               ; 3735  index of the candidate
        lda     #$FF                    ; 3737  $FF = 'no unit'
        sta     checksumXorConstant,x   ; 3739  strike it off the candidate list  (also trainerPlaybook0200:play3Col12)
nextScanCandidate:
        dec     scratch18               ; 373C  the list is walked backwards
        ldx     scratch18               ; 373E  reload the index
        bmi     scanCandidatesDone      ; 3740  index went negative -> every candidate has been tested
        jmp     L_3623                  ; 3742  test the next candidate

scanCandidatesDone:
        rts                             ; 3745  back to the caller (the per-unit update loop or selectBoomerTarget)


; ----------------------------------------------------------------------
; checkLineOfSightStep - Feeds the terrain class of one more cell into the running line-of-sight test.
; It compares the height of that cell with the height of the previous one: a rise is remembered in
; zp_1F, and a fall after a rise means a ridge stands between the two units and hides the target.
; Equal heights, or a fall while the line has never risen (simply looking downhill), leave the line
; clear.
; In:  X = terrain class 0-7 of the next cell, zp_1E = height of the previous cell, zp_1F = $80 until
;     the line has gone uphill
; Out: C = 1 blocked / C = 0 still clear, zp_1E = height of this cell, zp_1F = size of the rise; A and
;     X clobbered
; Called from: scanVisibleEnemies ($36CE for the target's own cell, $372D for each cell in between)
; ----------------------------------------------------------------------
checkLineOfSightStep:
        lda     terrainHeightTable,x    ; 3746  height 0-4 of this terrain class
        tax                             ; 3749  keep it as the new 'previous height'
        sec                             ; 374A  prepare the subtraction
        sbc     scratch1E               ; 374B  difference to the height of the previous cell
        beq     L_3755                  ; 374D  same level -> nothing changes, the line stays clear
        stx     scratch1E               ; 374F  remember this cell's height for the next step
        bcc     L_3757                  ; 3751  borrow: the ground drops here
        sta     scratch1F               ; 3753  the ground rises: record the rise, which clears the $80 'no rise yet' marker
L_3755:
        clc                             ; 3755  C = 0: the line of sight is still clear
        rts                             ; 3756  back to the walk

L_3757:
        lda     scratch1F               ; 3757  the ground is dropping - had it risen before?
        bmi     L_3755                  ; 3759  still $80: we are only looking downhill, and that hides nothing
        sec                             ; 375B  a drop after a rise = a ridge in between
        rts                             ; 375C  C = 1: the target is hidden


; ----------------------------------------------------------------------
; initLineBetweenUnits - Points the line walker at unit Y's cell and falls into initLineFromUnit,
; which sets the start to unit X's cell.  Together they are the standard 'walk from unit X to unit Y'
; set-up.
; In:  X = unit the line starts at, Y = unit the line ends at
; Out: zp_94/zp_95 = target row/column plus everything initLineFromUnit produces (A = direction code
;     0-3, line walker state zp_8C-zp_97)
; Called from: scanVisibleEnemies ($3633)
; ----------------------------------------------------------------------
initLineBetweenUnits:
        lda     unitColTable,y          ; 375D  column of the far unit
        sta     lineEndCol              ; 3760  line walker target column
        lda     unitRowTable,y          ; 3762  row of the far unit
        sta     lineEndRow              ; 3765  line walker target row (falls into initLineFromUnit)

; ----------------------------------------------------------------------
; initLineFromUnit - Sets the line walker start to unit X's cell, runs initLineStepper ($FBB8) and
; reports the principal direction of the line: 0 = north or 1 = south for a row-major line, 2 = east
; or 3 = west for a column-major one.  That value is the unit facing code kept in bits 0-1 of $F708
; (stored at $4464 and used by the combat facing bonus).
; In:  X = unit the line starts at, zp_94/zp_95 = target row/column
; Out: A = direction code 0-3, line walker state zp_8C-zp_97 set up (X = 1 for a row-major line)
; Called from: initLineBetweenUnits (falls through), $428E where a unit is turned toward its
;     destination
; ----------------------------------------------------------------------
initLineFromUnit:
        lda     unitColTable,x          ; 3767  column of the unit the line starts at
        sta     lineCurCol              ; 376A  line walker start column
        lda     unitRowTable,x          ; 376C  its row
        sta     lineCurRow              ; 376F  line walker start row
        jsr     initLineStepper         ; 3771  set up the Bresenham walk; it returns X = 1 when the row is the major axis
        cpx     #$01                    ; 3774  was the line mostly vertical?
        bne     L_3782                  ; 3776  no, it is mostly horizontal -> report east or west
        lda     #$00                    ; 3778  facing 0 = north (up)
        bit     lineStepRow             ; 377A  row step is $01 (down) or $FF (up)
        bmi     L_378A                  ; 377C  stepping up -> north
        lda     #$01                    ; 377E  facing 1 = south (down)
        bne     L_378A                  ; 3780  (always taken)
L_3782:
        lda     #$02                    ; 3782  facing 2 = east (right)
        bit     lineStepCol             ; 3784  column step is $01 (right) or $FF (left)
        bpl     L_378A                  ; 3786  stepping right -> east
        lda     #$03                    ; 3788  facing 3 = west (left)
L_378A:
        rts                             ; 378A  A = direction/facing code 0-3


; ----------------------------------------------------------------------
; setScanFilterOppositeSide - Configures the unit filter of collectUnitsInRadius so that only units of
; the side opposite to unit Y are collected, by writing the compare value and the branch opcode of the
; test at $37FE/$3800.  A side 0 scanner (unit < 50) keeps the units >= 50 with a BCC, a side 1
; scanner keeps the units < 50 with a BCS.
; In:  Y = the reference unit whose enemies are wanted
; Out: scanSideFilterCompare+1 = $32 and scanSideFilterBranch = $90 (BCC) or $B0 (BCS); A clobbered
; Called from: scanVisibleEnemies ($35D9), checkEnemyRecyclerSpotted ($3F64)
; ----------------------------------------------------------------------
setScanFilterOppositeSide:
        lda     #$32                    ; 378B  unit 50 = the first unit of side 1
        sta     scanSideFilterCompare+1 ; 378D  patch the CMP operand of the scan filter
        lda     #$90                    ; 3790  $90 = BCC, i.e. skip everything below unit 50
        cpy     #$32                    ; 3792  is the reference unit itself on side 0?
        bcc     L_3798                  ; 3794  yes -> keep the units of side 1
        eor     #$20                    ; 3796  side 1 scanner: $90 EOR $20 = $B0 = BCS, keep the units of side 0
L_3798:
        sta     scanSideFilterBranch    ; 3798  patch the branch opcode of the scan filter
        rts                             ; 379B  the filter is now set for the next collectUnitsInRadius


; Leftover of an older build: as code these two bytes are 'bcc $3798', the tail of an earlier version
; of the side test.  Overlay A's per-side patcher still stores its branch opcode here ($7BDB), where
; it now has no effect at all.
leftoverScanSideBranch:
        .byte   $90,$FA                 ; 379C  ..  $90 $FA = the dead 'bcc $3798' of an earlier layout

; ----------------------------------------------------------------------
; collectAllUnitsInRadiusDead - Unreferenced alternate entry of collectUnitsInRadius that turns the
; side filter into 'accept every unit' (compare against unit 0 with a BCC) before falling into the
; scan.  Nothing calls it - ageUnitVisibility ($357D) writes the same two bytes inline - so this is
; leftover code from an earlier build.
; In:  A = radius, X = centre column, Y = centre row
; Out: dead code; it would leave scanSideFilterCompare+1 = 0 and scanSideFilterBranch = $90
; Called from: nothing (dead code, rendered as .byte in the listing)
; ----------------------------------------------------------------------
collectAllUnitsInRadiusDead:
        pha                             ; 379E  keep the radius across the patching
        lda     #$00                    ; 379F  compare the unit index against 0 ...
        sta     scanSideFilterCompare+1 ; 37A1  -> the compare operand of the scan filter
        lda     #$90                    ; 37A4  ... with a BCC, so no unit is ever skipped
        sta     scanSideFilterBranch    ; 37A6  -> the branch opcode of the scan filter
        pla                             ; 37A9  radius back into A and fall into the scan

; ----------------------------------------------------------------------
; collectUnitsInRadius - Scans the square of half-width A around map cell (X,Y), clipped to the 40x40
; battlefield, and lists every cell holding a unit that passes the side filter patched by
; setScanFilterOppositeSide: the unit index goes into $0400[n] and its squared distance from the
; centre into $0432[n].  When more than one unit was found, side 1 reverses the lists so that both
; machines break distance ties identically.
; In:  A = radius in cells, X = centre column, Y = centre row, playerSide $0B9F, the map at $F000
; Out: candidate lists $0400/$0432, X = zp_29 = index of the last entry ($FF = nothing found);
;     zp_22-zp_29 and zp_48/zp_49 used as scratch; operand scanReverseLimit+1 patched
; Called from: scanVisibleEnemies ($3617), checkEnemyRecyclerSpotted ($3F72)
; ----------------------------------------------------------------------
collectUnitsInRadius:
        sta     cellColA                ; 37AA  keep the radius (zp_22 becomes the running column further down)
        asl     a                       ; 37AC  2 * radius
        sta     scanColCount            ; 37AD  width of the scan square (the +1 comes at $37DA)
        sta     scanRowCount            ; 37AF  and its height
        stx     cellColB                ; 37B1  centre column = point B of distanceSquared
        sty     cellRowB                ; 37B3  centre row
        txa                             ; 37B5  centre column back into A
        sec                             ; 37B6  prepare the subtraction
        sbc     cellColA                ; 37B7  leftmost column = centre - radius
        bcs     L_37C3                  ; 37B9  still on the map -> use it
        txa                             ; 37BB  clipped at the left edge: the square still ends at centre + radius ...
        clc                             ; 37BC  prepare the addition
        adc     cellColA                ; 37BD  centre + radius
        sta     scanColCount            ; 37BF  ... so that is all the width there is left
        lda     #$00                    ; 37C1  and the scan starts at column 0
L_37C3:
        sta     scratch23               ; 37C3  first column of every scanned row
        tax                             ; 37C5  -> X for readMapCell
        tya                             ; 37C6  centre row back into A
        sec                             ; 37C7  prepare the subtraction
        sbc     cellColA                ; 37C8  topmost row = centre - radius (zp_22 still holds the radius)
        bcs     L_37D4                  ; 37CA  still on the map -> use it
        tya                             ; 37CC  clipped at the top edge ...
        clc                             ; 37CD  prepare the addition
        adc     cellColA                ; 37CE  centre + radius
        sta     scanRowCount            ; 37D0  ... so there are fewer rows to scan
        lda     #$00                    ; 37D2  and the scan starts at row 0
L_37D4:
        sta     cellRowA                ; 37D4  row the scan starts on
        tay                             ; 37D6  -> Y for readMapCell
        jsr     readMapCell             ; 37D7  zp_48/49 = address of the top left cell of the square, Y = 0
        inc     scanColCount            ; 37DA  width  = 2*radius+1 cells
        inc     scanRowCount            ; 37DC  height = 2*radius+1 rows
        lda     #$FF                    ; 37DE  $FF ...
        sta     scanListIndex           ; 37E0  ... = the candidate list is still empty

; ----------------------------------------------------------------------
; Row loop: one map row of the square per pass; zp_48/49 walks down the map 40 bytes at a time.
; ----------------------------------------------------------------------
L_37E2:
        lda     cellRowA                ; 37E2  row about to be scanned
        bmi     L_3818                  ; 37E4  negative row -> nothing to do here
        cmp     #$28                    ; 37E6  the battlefield is 40 rows deep
        bcs     L_3818                  ; 37E8  past the bottom edge -> skip the row
        ldy     #$00                    ; 37EA  Y = offset from the start of the map row
        lda     scratch23               ; 37EC  first column of the square
        sta     cellColA                ; 37EE  restart at the left edge of the square

; ----------------------------------------------------------------------
; Column loop over one row of the square.
; ----------------------------------------------------------------------
L_37F0:
        lda     cellColA                ; 37F0  column about to be tested
        bmi     L_3811                  ; 37F2  off the left edge of the map
        cmp     #$28                    ; 37F4  40 columns
        bcs     L_3811                  ; 37F6  off the right edge
        lda     (mapCellPtr),y          ; 37F8  the map byte of this cell
        bpl     L_3811                  ; 37FA  bit 7 clear = plain terrain, no unit here
        and     #$7F                    ; 37FC  bit 7 is the 'unit here' marker; the rest is the unit index 0-99
scanSideFilterCompare:
        cmp     #$FF                    ; 37FE  operand patched by setScanFilterOppositeSide ($32) or by the dead entry above (0)
scanSideFilterBranch:
        bcc     L_3811                  ; 3800  opcode patched BCC/BCS: drop the units of the unwanted side
        inc     scanListIndex           ; 3802  one more candidate
        ldx     scanListIndex           ; 3804  its slot number
        sta     checksumXorConstant,x   ; 3806  unit index -> the $0400 candidate list  (also trainerPlaybook0200:play3Col12)
        jsr     distanceSquared         ; 3809  dist^2 between this cell (zp_22/zp_24) and the centre (zp_27/zp_28)
        ldx     scanListIndex           ; 380C  same slot
        sta     play3Row12,x            ; 380E  -> the parallel $0432 distance list
L_3811:
        inc     cellColA                ; 3811  next column
        iny                             ; 3813  next byte of the map row
        cpy     scanColCount            ; 3814  whole width of the square done?
        bcc     L_37F0                  ; 3816  no -> next cell

; ----------------------------------------------------------------------
; End of a row: step the map pointer down to the next one.
; ----------------------------------------------------------------------
L_3818:
        lda     mapCellPtr              ; 3818  map row pointer, low byte
        clc                             ; 381A  prepare the addition
        adc     #$28                    ; 381B  40 bytes per map row
        sta     mapCellPtr              ; 381D  store it back
        lda     mapCellPtrHi            ; 381F  high byte
        adc     #$00                    ; 3821  propagate the carry
        sta     mapCellPtrHi            ; 3823  zp_48/49 now points at the next row of the square
        inc     cellRowA                ; 3825  next row number
        dec     scanRowCount            ; 3827  one row less to do
        bne     L_37E2                  ; 3829  until the whole square has been scanned

; ----------------------------------------------------------------------
; Side 1 reverses both lists.  The two machines see the battlefield mirrored and therefore scan the
; square in opposite directions; reversing makes them pick the same unit whenever two candidates tie
; on distance.
; ----------------------------------------------------------------------
        ldx     scanListIndex           ; 382B  index of the last entry
        bmi     L_385D                  ; 382D  nothing found at all
        beq     L_385D                  ; 382F  only one entry - nothing to reverse
        lda     playerSide              ; 3831  0 = the local player is side 0, 1 = side 1
        beq     L_385D                  ; 3834  side 0 keeps the natural scan order
        ldy     #$00                    ; 3836  Y walks up from the front while X walks down from the back
L_3838:
        lda     checksumXorConstant,y   ; 3838  swap the two unit slots ...  (also trainerPlaybook0200:play3Col12)
        pha                             ; 383B  keep the front entry
        lda     checksumXorConstant,x   ; 383C  back entry  (also trainerPlaybook0200:play3Col12)
        sta     checksumXorConstant,y   ; 383F  -> to the front  (also trainerPlaybook0200:play3Col12)
        pla                             ; 3842  front entry back off the stack
        sta     checksumXorConstant,x   ; 3843  -> to the back  (also trainerPlaybook0200:play3Col12)
        lda     play3Row12,y            ; 3846  ... and the two matching distances
        pha                             ; 3849  keep the front distance
        lda     play3Row12,x            ; 384A  back distance
        sta     play3Row12,y            ; 384D  -> to the front
        pla                             ; 3850  front distance back off the stack
        sta     play3Row12,x            ; 3851  -> to the back
        iny                             ; 3854  next pair from the front
        dex                             ; 3855  next pair from the back
        stx     scanReverseLimit+1      ; 3856  patch the loop limit with the index that is walking down
scanReverseLimit:
        cpy     #$FF                    ; 3859  operand = X
        bcc     L_3838                  ; 385B  keep swapping until the two indexes meet
L_385D:
        ldx     scanListIndex           ; 385D  X = index of the last entry, $FF when nothing was found
        rts                             ; 385F  back to the caller


; squaresTable - n*n for n = 0..15.  distanceSquared uses it forwards; cellDistance in the opponent
; module ($E832) and the map generator ($7831) use it backwards as a square root.
squaresTable:
        .byte   $00,$01,$04,$09,$10,$19,$24,$31; 3860  ......$1  0,1,4,9,16,25,36,49 = n*n for n = 0-7
        .byte   $40                     ; 3868  64,81,100,121,144,169,196,225 = n*n for n = 8-15
        .byte   $51,$64,$79,$90,$A9,$C4,$E1; 3869  Qdy....

; ----------------------------------------------------------------------
; distanceSquared - Squared distance between two map cells: |dCol|^2 + |dRow|^2 taken from
; squaresTable.  Each delta is clamped to 15 cells and the sum saturates at $FF, which makes it a
; usable range metric everywhere on the 40x40 map without a square root.
; In:  zp_22/zp_24 = column/row of the cell, zp_27/zp_28 = column/row of the reference point
; Out: A = squared distance ($FF when it overflows); X clobbered
; Called from: collectUnitsInRadius ($3809), the target cursor range check ($2159), cellDistance in
;     the opponent module ($E82A)
; ----------------------------------------------------------------------
distanceSquared:
        lda     cellColA                ; 3870  column of the cell
        sec                             ; 3872  prepare the subtraction
        sbc     cellColB                ; 3873  minus the reference column
        bpl     L_387C                  ; 3875  already positive
        eor     #$FF                    ; 3877  negate to get |dCol| ...
        clc                             ; 3879  prepare the addition
        adc     #$01                    ; 387A  ... two's complement
L_387C:
        tax                             ; 387C  -> table index
        cpx     #$10                    ; 387D  squaresTable only covers 0-15
        bcc     L_3883                  ; 387F  in range
        ldx     #$0F                    ; 3881  clamp the distance at 15 cells
L_3883:
        lda     squaresTable,x          ; 3883  dCol^2
        pha                             ; 3886  hold on to it while the row delta is worked out
        lda     cellRowA                ; 3887  row of the cell
        sec                             ; 3889  prepare the subtraction
        sbc     cellRowB                ; 388A  minus the reference row
        bpl     L_3893                  ; 388C  already positive
        eor     #$FF                    ; 388E  negate to get |dRow| ...
        clc                             ; 3890  prepare the addition
        adc     #$01                    ; 3891  ... two's complement
L_3893:
        tax                             ; 3893  -> table index
        cpx     #$10                    ; 3894  again only 0-15 are tabulated
        bcc     L_389A                  ; 3896  in range
        ldx     #$0F                    ; 3898  clamp at 15 cells
L_389A:
        pla                             ; 389A  dCol^2 back off the stack
        clc                             ; 389B  prepare the addition
        adc     squaresTable,x          ; 389C  + dRow^2
        bcc     L_38A3                  ; 389F  no overflow
        lda     #$FF                    ; 38A1  saturate at $FF instead of wrapping round
L_38A3:
        rts                             ; 38A3  A = dCol^2 + dRow^2


; baseDamageTable - base damage of one shot: 3 rows of 5 bytes, row = attacker type (0 GRUNT, 1 RIDER,
; 2 BOOMER; spies and comcens never fire) and column = target type (0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY,
; 4 COMCEN).  Read as baseDamageTable[attackerRow*5 + targetType] at $38F4.
baseDamageTable:
        .byte   $04,$05,$19,$08,$0C,$03,$04,$14; 38A4  ........  GRUNT vs grunt 4, rider 5, boomer 25, spy 8, comcen 12; RIDER vs grunt 3, rider 4, boomer 20
        .byte   $06                     ; 38AC  RIDER vs spy 6, comcen 8; BOOMER vs grunt 12, rider 16, boomer 8, spy 8, comcen 20
        .byte   $08,$0C,$10,$08,$08,$14 ; 38AD  ......

; terrainHeightTable - height (cover level) of each terrain class 0-7: class 0 blank, 1 river and 2
; forest are flat, 3 lower contour = 1, 4 hill = 2, 5 upper contour = 3, 6 summit = 4, 7 recycler = 0.
; Used by the line-of-sight ridge test and by the combat height bonus.
terrainHeightTable:
        .byte   $00,$00,$00,$01,$02,$03,$04,$00; 38B3  ........  heights of terrain classes 0-7: 0,0,0,1,2,3,4,0

; combatScratch - the seven work bytes of resolveAttack, held inside the code: target unit, attacker
; unit, the running damage total, and the four map coordinates of the shot.  The coordinates have to
; be saved because overlay B's drawShotLineInViewport destroys zp_92-zp_95.
combatScratch:
        .byte   $00                     ; 38BB  .  the unit being shot at
combatAttackerUnit:
        .byte   $00                     ; 38BC  the unit doing the shooting
combatDamage:
        .byte   $00                     ; 38BD  running damage total; half of it is what actually gets applied
combatAttackerRow:
        .byte   $00                     ; 38BE  attacker's map row
combatAttackerCol:
        .byte   $00                     ; 38BF  attacker's map column
combatTargetRow:
        .byte   $00                     ; 38C0  target's map row
combatTargetCol:
        .byte   $00                     ; 38C1  target's map column

; ----------------------------------------------------------------------
; resolveAttack - Resolves one shot of unit X at unit Y.  Only GRUNT/RIDER/BOOMER (types 0-2) carry a
; weapon.  The damage starts at baseDamageTable[attacker][target] and is then modified: a blitzing
; attacker that has built up a charge ($F708 bit 7) throws up to 15 energy (5 for a BOOMER) into the
; shot and pays 3/4 of that itself; +2 when the attacker is dug in and -25% when the target is; plus
; the height difference of the two cells; +10 when the target stands in a river (class 1) and +2 on a
; contour (class 3 or 5); and for non-BOOMERs +20 when the target faces the same way (shot in the
; back) or +10 for a flank shot.  A zero or negative total becomes 2, and half of the total is applied
; through applyHitToUnit.  If the battlefield screen is up and the comcen display is alive the shot is
; drawn both in the zoom window and on the overview map, then the shot or kill sound plays.
; In:  X = attacker unit, Y = target unit; the unit record arrays, terrain classes $077E,
;     currentScreen $90FB, comcenStunStatus $922A
; Out: target damaged via applyHitToUnit, the attacker may lose energy, attackerType $923D,
;     $38BB-$38C1 scratch, shot graphics (sprite 3 and the zoom window) and a sound; the operands
;     loadShotSoundId+1, loadChargeEnergySpent+1 and subtractDugInDiscount+1 are patched
; Called from: the per-unit update loop ($44A0)
; ----------------------------------------------------------------------
resolveAttack:
        stx     combatAttackerUnit      ; 38C2  remember the attacker
        sty     combatScratch           ; 38C5  and the target
        lda     #$00                    ; 38C8  no offset yet ...
        sta     scratch1A               ; 38CA  row offset into baseDamageTable (5 entries per attacker type)
        lda     unitTypeTable,x         ; 38CC  attacker's type byte
        and     #$07                    ; 38CF  0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY, 4 COMCEN
        sta     attackerType            ; 38D1  $923D, also read by both shot-drawing routines
        cmp     #$03                    ; 38D4  types 3 and 4 have no weapon
        bcc     L_38DB                  ; 38D6  only grunts, riders and boomers shoot
        jmp     L_3A59                  ; 38D8  a spy or comcen never fires - nothing happens at all


; ----------------------------------------------------------------------
; Pick the row of baseDamageTable that belongs to the attacker's type.
; ----------------------------------------------------------------------
L_38DB:
        cmp     #$02                    ; 38DB  BOOMER?
        bne     L_38E3                  ; 38DD  no
        lda     #$0A                    ; 38DF  boomer row = table entries 10-14
        sta     scratch1A               ; 38E1  -> row offset 10
L_38E3:
        cmp     #$01                    ; 38E3  RIDER?
        bne     L_38EB                  ; 38E5  no, so it is a GRUNT and the offset stays 0
        lda     #$05                    ; 38E7  rider row = table entries 5-9
        sta     scratch1A               ; 38E9  -> row offset 5
L_38EB:
        lda     unitTypeTable,y         ; 38EB  target's type byte
        and     #$07                    ; 38EE  type 0-4 selects the column
        clc                             ; 38F0  prepare the addition
        adc     scratch1A               ; 38F1  + the attacker's row offset
        tax                             ; 38F3  -> table index
        lda     baseDamageTable,x       ; 38F4  base damage for this attacker/target pairing
        sta     combatDamage            ; 38F7  start the running damage total
        lda     #$0A                    ; 38FA  sound 10 = ordinary shot
        ldx     attackerType            ; 38FC  attacker type
        cpx     #$02                    ; 38FF  BOOMER?
        bne     L_3905                  ; 3901  no -> keep sound 10
        lda     #$0B                    ; 3903  sound 11 = boomer shell
L_3905:
        sta     loadShotSoundId+1       ; 3905  patch the LDA # of the sound played when the shot is over

; ----------------------------------------------------------------------
; Blitz charge: a unit with blitz switched on ($F8FC bit 7) collects a one-shot charge flag in
; $F708 bit 7 while it moves ($3EE2).  Firing spends the charge, adding energy to the damage and
; taking 3/4 of that energy off the attacker itself.
; ----------------------------------------------------------------------
        ldx     combatAttackerUnit      ; 3908  the attacker
        lda     unitPathRowTable,x      ; 390B  $F8FC bit 7 = blitz is switched on
        bpl     L_3950                  ; 390E  not blitzing -> no charge bonus
        lda     unitFlagsTable,x        ; 3910  $F708 bit 7 = a charge was built up by moving
        bpl     L_3950                  ; 3913  nothing charged up
        and     #$7F                    ; 3915  clear the charge bit ...
        sta     unitFlagsTable,x        ; 3917  ... it is used up by this shot
        lda     #$0F                    ; 391A  up to 15 energy may be thrown into the charge
        ldy     scratch1A               ; 391C  which damage row is in use?
        cpy     #$0A                    ; 391E  row offset 10 = BOOMER
        bcc     L_3924                  ; 3920  grunt or rider
        lda     #$05                    ; 3922  a boomer only spends 5
L_3924:
        sta     scratch18               ; 3924  the amount we would like to spend
        lda     unitEnergyTable,x       ; 3926  attacker's energy byte
        and     #$3F                    ; 3929  bits 0-5 = energy 0-50, bits 6-7 are the visibility flags
        cmp     scratch18               ; 392B  enough energy for the whole charge?
        bcc     L_3931                  ; 392D  no - it cannot spend more than it has
        lda     scratch18               ; 392F  yes - spend the full amount
L_3931:
        sta     loadChargeEnergySpent+1 ; 3931  patch the amount spent into the LDA # below
        clc                             ; 3934  prepare the addition
        adc     combatDamage            ; 3935  the running damage total
        sta     combatDamage            ; 3938  the whole amount is added to the damage
        ldy     combatAttackerUnit      ; 393B  the attacker itself pays for it
loadChargeEnergySpent:
        lda     #$FF                    ; 393E  operand = the energy just spent
        lsr     a                       ; 3940  /2
        sta     scratch18               ; 3941  keep half of it
        lsr     a                       ; 3943  /4
        clc                             ; 3944  prepare the addition
        adc     scratch18               ; 3945  1/2 + 1/4 = 3/4 of the amount spent
        jsr     reduceUnitEnergyBy      ; 3947  take that off the attacker's own energy
        ldy     combatScratch           ; 394A  Y back to the target
        ldx     combatAttackerUnit      ; 394D  X back to the attacker

; ----------------------------------------------------------------------
; Dug-in modifiers: $F76C bit 6 marks a unit that has finished digging in.
; ----------------------------------------------------------------------
L_3950:
        lda     unitTypeTable,x         ; 3950  attacker's type byte
        and     #$40                    ; 3953  bit 6 = dug in / fortified
        beq     L_395D                  ; 3955  not dug in
        inc     combatDamage            ; 3957  +1 ...
        inc     combatDamage            ; 395A  ... +1 = +2 damage when firing from a prepared position
L_395D:
        lda     unitTypeTable,y         ; 395D  target's type byte
        and     #$40                    ; 3960  bit 6 = the target is dug in
        beq     L_3975                  ; 3962  not dug in
        lda     combatDamage            ; 3964  running damage total
        lsr     a                       ; 3967  /2
        lsr     a                       ; 3968  /4
        sta     subtractDugInDiscount+1 ; 3969  patch the subtraction below with damage/4
        lda     combatDamage            ; 396C  the total again
        sec                             ; 396F  prepare the subtraction
subtractDugInDiscount:
        sbc     #$FF                    ; 3970  operand = damage/4: a dug-in target takes 25% less
        sta     combatDamage            ; 3972  the reduced total

; ----------------------------------------------------------------------
; Terrain modifiers: the height difference between the two cells, plus a bonus for a target caught in
; the open (a river) or on a slope.
; ----------------------------------------------------------------------
L_3975:
        ldx     unitTerrainUnderTable,y ; 3975  map byte hidden under the target
        lda     D_077E,x                ; 3978  -> terrain class 0-8
        sta     scratch1C               ; 397B  keep the target's terrain class
        tax                             ; 397D  -> height table index
        lda     terrainHeightTable,x    ; 397E  height of the target's cell
        sta     scratch1E               ; 3981  keep it for the difference below
        ldx     combatAttackerUnit      ; 3983  the attacker
        ldy     unitTerrainUnderTable,x ; 3986  map byte hidden under the attacker
        lda     D_077E,y                ; 3989  -> terrain class
        sta     scratch1D               ; 398C  stored but never read again - a dead store
        tay                             ; 398E  -> height table index
        lda     terrainHeightTable,y    ; 398F  height of the attacker's cell
        ldy     combatScratch           ; 3992  Y back to the target unit
        sec                             ; 3995  prepare the subtraction
        sbc     scratch1E               ; 3996  attacker height - target height
        clc                             ; 3998  prepare the addition
        adc     combatDamage            ; 3999  + the running total
        sta     combatDamage            ; 399C  firing downhill adds, firing uphill subtracts
        lda     scratch1C               ; 399F  terrain class the target is standing on
        cmp     #$01                    ; 39A1  class 1 = river
        bne     L_39B1                  ; 39A3  not a river
        lda     combatDamage            ; 39A5  running damage total
        clc                             ; 39A8  prepare the addition
        adc     #$0A                    ; 39A9  +10: a target in the water has no cover at all
        sta     combatDamage            ; 39AB  store the total again
        jmp     L_39BF                  ; 39AE  the river bonus and the slope bonus are exclusive

L_39B1:
        cmp     #$03                    ; 39B1  class 3 = lower contour
        beq     L_39B9                  ; 39B3  yes
        cmp     #$05                    ; 39B5  class 5 = upper contour
        bne     L_39BF                  ; 39B7  any other class gives no bonus
L_39B9:
        inc     combatDamage            ; 39B9  +1 ...
        inc     combatDamage            ; 39BC  ... +1 = +2 for a target caught on an open slope

; ----------------------------------------------------------------------
; Facing bonus.  Facing codes are 0 north, 1 south, 2 east, 3 west, so EOR 1 gives the opposite
; direction: the same facing means the target has its back to us, the opposite facing means it is
; looking straight at us, and the other two are a flank shot.  Boomers lob shells and get no bonus at
; all.
; ----------------------------------------------------------------------
L_39BF:
        lda     attackerType            ; 39BF  attacker type
        cmp     #$02                    ; 39C2  BOOMER?
        beq     L_39F0                  ; 39C4  artillery gets no facing bonus
        lda     unitFlagsTable,x        ; 39C6  attacker's facing, bits 0-1 of $F708
        and     #$03                    ; 39C9  facing bits only
        sta     scratch1A               ; 39CB  keep it
        lda     unitFlagsTable,y        ; 39CD  target's facing
        and     #$03                    ; 39D0  facing bits only
        cmp     scratch1A               ; 39D2  both looking the same way?
        bne     L_39E1                  ; 39D4  no
        lda     combatDamage            ; 39D6  running damage total
        clc                             ; 39D9  prepare the addition
        adc     #$14                    ; 39DA  +20: the target has its back to the shooter
        sta     combatDamage            ; 39DC  store the total again
        bne     L_39F0                  ; 39DF  (always taken, the total cannot be 0 here)
L_39E1:
        eor     #$01                    ; 39E1  the facing opposite to the target's
        cmp     scratch1A               ; 39E3  is the target looking straight at the shooter?
        beq     L_39F0                  ; 39E5  head on -> no bonus
        lda     combatDamage            ; 39E7  the remaining two facings are a flank shot
        clc                             ; 39EA  prepare the addition
        adc     #$0A                    ; 39EB  +10
        sta     combatDamage            ; 39ED  store the total again

; ----------------------------------------------------------------------
; Apply the shot.
; ----------------------------------------------------------------------
L_39F0:
        lda     combatDamage            ; 39F0  final total
        bmi     L_39F7                  ; 39F3  the modifiers can drive it below zero
        bne     L_39F9                  ; 39F5  or exactly to zero
L_39F7:
        lda     #$02                    ; 39F7  every hit does at least 2 points (before halving)
L_39F9:
        ldy     combatScratch           ; 39F9  the target unit
        lsr     a                       ; 39FC  only half of the accumulated total is really applied
        jsr     applyHitToUnit          ; 39FD  damage the target, score it and maybe end the game

; ----------------------------------------------------------------------
; Draw the shot, but only while the player is actually looking at the battlefield and the comcen
; display is alive.  Overlay B draws it as fat pixels in the 7x5 zoom window and drawShotLineSprite
; draws the same shot on the 4-pixel-per-cell overview map.
; ----------------------------------------------------------------------
        lda     currentScreen           ; 3A00  0 = the battlefield screen (F1) is the one on show
        bne     L_3A4A                  ; 3A03  radar, drone or menu screen -> no shot graphics
        bit     comcenStunStatus        ; 3A05  $922A = $FF (so bit 6 is set) while the comcen is knocked out
        bvs     L_3A4A                  ; 3A08  blacked-out display -> draw nothing
        ldy     combatScratch           ; 3A0A  the target
        lda     unitColTable,y          ; 3A0D  its column
        sta     combatTargetCol         ; 3A10  saved, because the overlay routine destroys zp_92-zp_95
        sta     lineEndCol              ; 3A13  line walker target column
        lda     unitRowTable,y          ; 3A15  its row
        sta     combatTargetRow         ; 3A18  saved copy
        sta     lineEndRow              ; 3A1B  line walker target row
        ldx     combatAttackerUnit      ; 3A1D  the attacker
        lda     unitColTable,x          ; 3A20  its column
        sta     combatAttackerCol       ; 3A23  saved copy
        sta     lineCurCol              ; 3A26  line walker start column
        lda     unitRowTable,x          ; 3A28  its row
        sta     combatAttackerRow       ; 3A2B  saved copy
        sta     lineCurRow              ; 3A2E  line walker start row
        jsr     drawShotLineInViewport  ; 3A30  overlay B: white fat-pixel shot line in the 7x5 zoom window  (also mapGenerator6F00:runMapTypeMenu)
        lda     combatTargetRow         ; 3A33  put the four endpoints back for the sprite version
        sta     lineEndRow              ; 3A36  target row
        lda     combatTargetCol         ; 3A38  saved target column
        sta     lineEndCol              ; 3A3B  target column
        lda     combatAttackerRow       ; 3A3D  saved attacker row
        sta     lineCurRow              ; 3A40  attacker row
        lda     combatAttackerCol       ; 3A42  saved attacker column
        sta     lineCurCol              ; 3A45  attacker column
        jsr     drawShotLineSprite      ; 3A47  and draw the same shot on the small overview map

; ----------------------------------------------------------------------
; Sound: a kill overrides the shot sound patched in at $3905.
; ----------------------------------------------------------------------
L_3A4A:
        ldy     combatScratch           ; 3A4A  the target
loadShotSoundId:
        lda     #$FF                    ; 3A4D  operand = sound 10 (shot) or 11 (boomer shell), patched at $3905
        ldx     unitTypeTable,y         ; 3A4F  bit 7 of the type byte = the target was destroyed
        bpl     L_3A56                  ; 3A52  still alive -> ordinary shot sound
        lda     #$09                    ; 3A54  sound 9 = kill
L_3A56:
        jsr     playSound               ; 3A56  queue it
L_3A59:
        rts                             ; 3A59  shared exit, also used by the 'spy/comcen cannot fire' case


; ----------------------------------------------------------------------
; drawShotLineSprite - Draws the shot on the 4-pixel-per-cell overview map using hardware sprite 3.
; Both cells are converted to sprite pixel coordinates; because a sprite only covers 24x21 pixels the
; start point is walked toward the target until the rest of the line fits that box.  Sprite 3 is then
; placed on the top left corner of the box (+2 pixels so the line sits in the middle of the 4x4 cell)
; in white, the segment is plotted into the sprite shape (dotted for a BOOMER) with a small diamond at
; the impact end, and shotLineTimer gives the IRQ 8 frames before it hides the sprite again.
; In:  zp_92/zp_93 = attacker row/column, zp_94/zp_95 = target row/column (map cells), attackerType
;     $923D
; Out: sprite 3 shape at $8AC0, position $9013/$901B, colour $900B = 1 (white), bit 3 of the sprite
;     enable shadow $9022, shotLineTimer $91C8 = 8; zp_48 and the whole line walker state clobbered
; Called from: resolveAttack ($3A47)
; Hide sprite 3 and wipe its shape before rebuilding it.
; ----------------------------------------------------------------------
drawShotLineSprite:
        lda     spriteEnableShadow      ; 3A5A  shadow of VIC_SPR_ENA $D015
        and     #$F7                    ; 3A5D  clear bit 3: sprite 3 off while its shape is rebuilt
        sta     spriteEnableShadow      ; 3A5F  write the shadow back
        ldy     #$3F                    ; 3A62  64 bytes of sprite data
        lda     #$00                    ; 3A64  blank byte
L_3A66:
        sta     sprite3Shape,y          ; 3A66  clear sprite 3's shape at $8AC0
        dey                             ; 3A69  next byte
        bpl     L_3A66                  ; 3A6A  until all 64 are cleared

; ----------------------------------------------------------------------
; Convert both map cells into sprite pixel coordinates (column*4+$1F, row*4+$39).
; ----------------------------------------------------------------------
        ldx     lineEndCol              ; 3A6C  target column
        ldy     lineEndRow              ; 3A6E  target row
        jsr     mapCellToSpriteXYRaw    ; 3A70  -> sprite pixel position on the overview map
        stx     lineEndCol              ; 3A73  from here on the line walker works in pixels, not cells
        sty     lineEndRow              ; 3A75  in sprite pixels as well
        ldx     lineCurCol              ; 3A77  attacker column
        ldy     lineCurRow              ; 3A79  attacker row
        jsr     mapCellToSpriteXYRaw    ; 3A7B  -> sprite pixel position
        stx     lineCurCol              ; 3A7E  line walker start, in pixels
        sty     lineCurRow              ; 3A80  in sprite pixels as well
        jsr     initLineStepper         ; 3A82  set up the Bresenham walk over the pixel line
        lda     #$00                    ; 3A85  step counter ...
        sta     mapCellPtr              ; 3A87  counts the pixels dropped off the far end - nothing ever reads it (leftover)

; ----------------------------------------------------------------------
; A sprite is only 24x21 pixels, so walk the start point toward the target until what is left of the
; line fits inside one sprite; the tail nearest the shooter simply is not drawn.
; ----------------------------------------------------------------------
L_3A89:
        lda     lineEndRow              ; 3A89  how tall is the remaining segment?
        sec                             ; 3A8B  prepare the subtraction
        sbc     lineCurRow              ; 3A8C  end row - current row
        bcs     L_3A94                  ; 3A8E  positive already
        eor     #$FF                    ; 3A90  |dy| ...
        adc     #$01                    ; 3A92  ... two's complement
L_3A94:
        cmp     #$14                    ; 3A94  a sprite is 21 pixel rows tall
        bcs     L_3AA7                  ; 3A96  still too tall -> drop one more step
        lda     lineEndCol              ; 3A98  how wide is it?
        sec                             ; 3A9A  prepare the subtraction
        sbc     lineCurCol              ; 3A9B  end column - current column
        bcs     L_3AA3                  ; 3A9D  positive already
        eor     #$FF                    ; 3A9F  |dx| ...
        adc     #$01                    ; 3AA1  ... two's complement
L_3AA3:
        cmp     #$17                    ; 3AA3  a sprite is 24 pixel columns wide
        bcc     L_3AB1                  ; 3AA5  the rest of the line fits -> draw it
L_3AA7:
        jsr     stepLine                ; 3AA7  walk one pixel closer to the target
        beq     L_3AB1                  ; 3AAA  the whole line has been consumed
        inc     mapCellPtr              ; 3AAC  count the dropped step (dead store)
        jmp     L_3A89                  ; 3AAE  test the shortened line again


; ----------------------------------------------------------------------
; Put sprite 3 on the top left corner of the remaining segment.
; ----------------------------------------------------------------------
L_3AB1:
        ldx     lineCurCol              ; 3AB1  assume the current point is the left edge
        lda     lineStepCol             ; 3AB3  which way does the walk run?
        bpl     L_3AB9                  ; 3AB5  to the right -> the current point really is leftmost
        ldx     lineEndCol              ; 3AB7  to the left -> the target end is leftmost
L_3AB9:
        ldy     lineCurRow              ; 3AB9  same for the top edge
        lda     lineStepRow             ; 3ABB  which way vertically?
        bpl     L_3AC1                  ; 3ABD  downwards -> the current point is topmost
        ldy     lineEndRow              ; 3ABF  upwards -> the target end is topmost
L_3AC1:
        cpx     lineEndCol              ; 3AC1  a perfectly vertical line?
        bne     L_3AC6                  ; 3AC3  no
        dex                             ; 3AC5  shift the sprite one pixel left so the line is not on its edge column
L_3AC6:
        cpy     lineEndRow              ; 3AC6  a perfectly horizontal line?
        bne     L_3ACB                  ; 3AC8  no
        dey                             ; 3ACA  shift it one pixel up
L_3ACB:
        stx     sprite3XShadow          ; 3ACB  $9013 = shadow of sprite 3's X position
        sty     sprite3YShadow          ; 3ACE  $901B = shadow of sprite 3's Y position

; ----------------------------------------------------------------------
; Make both endpoints relative to the sprite origin, then centre the sprite in the map cell.
; ----------------------------------------------------------------------
        lda     lineCurCol              ; 3AD1  line start column
        sec                             ; 3AD3  prepare the subtraction
        sbc     sprite3XShadow          ; 3AD4  minus the sprite origin
        sta     lineCurCol              ; 3AD7  start column inside the sprite (0-23)
        lda     lineEndCol              ; 3AD9  line end column
        sec                             ; 3ADB  prepare the subtraction
        sbc     sprite3XShadow          ; 3ADC  minus the sprite origin
        sta     lineEndCol              ; 3ADF  end column inside the sprite
        lda     lineCurRow              ; 3AE1  line start row
        sec                             ; 3AE3  prepare the subtraction
        sbc     sprite3YShadow          ; 3AE4  minus the sprite origin
        sta     lineCurRow              ; 3AE7  start row inside the sprite (0-20)
        lda     lineEndRow              ; 3AE9  line end row
        sec                             ; 3AEB  prepare the subtraction
        sbc     sprite3YShadow          ; 3AEC  minus the sprite origin
        sta     lineEndRow              ; 3AEF  end row inside the sprite
        lda     sprite3XShadow          ; 3AF1  sprite X position
        clc                             ; 3AF4  prepare the addition
        adc     #$02                    ; 3AF5  +2 pixels: a map cell is 4x4 pixels, so this centres the line inside it
        sta     sprite3XShadow          ; 3AF7  store the sprite X position
        lda     sprite3YShadow          ; 3AFA  sprite Y position
        clc                             ; 3AFD  prepare the addition
        adc     #$02                    ; 3AFE  same +2 vertically
        sta     sprite3YShadow          ; 3B00  store the sprite Y position

; ----------------------------------------------------------------------
; Plot the segment into the sprite shape and show it for 8 frames.
; ----------------------------------------------------------------------
        lda     #$01                    ; 3B03  colour 1 = white
        sta     sprite3ColourShadow     ; 3B05  $900B = shadow of sprite 3's colour register $D02A
        lda     attackerType            ; 3B08  attacker type
        cmp     #$02                    ; 3B0B  BOOMER?
        bne     L_3B11                  ; 3B0D  no -> solid line
        lda     #$80                    ; 3B0F  bit 7 = draw the line dotted (a lobbed shell)
L_3B11:
        jsr     drawLineInPathSprite    ; 3B11  plot the segment into sprite 3's shape
        jsr     plotPathCross           ; 3B14  small diamond at the impact end
        lda     #$08                    ; 3B17  sprite 3
        jsr     enableSprites           ; 3B19  switch it on
        lda     #$08                    ; 3B1C  8 frames
        sta     shotLineTimer           ; 3B1E  the IRQ hides the sprite again when this runs out
        rts                             ; 3B21  back to resolveAttack


; ----------------------------------------------------------------------
; applyHitToUnit - Takes A points of damage off unit Y and deals with everything that follows.  For
; one of the local player's own units it warns when a spy is shot at for the first time (message $0E)
; and shakes the screen with sound 8 when the player's own comcen is hit.  Either comcen halves the
; damage, caps it at the energy it has left, has one of its systems knocked out at random when it is
; ours, and scores 2 points per energy point for the attacking side.  Then the energy is reduced and
; the hit recorded; a unit that runs out of energy is marked destroyed, while a comcen instead ends
; the game through endGame.
; In:  A = damage points, Y = target unit
; Out: hitDamage $9240 = the damage actually applied; the target's energy $F9C4, movement points
;     $FA28, hit effect $FA8C, display flags $FB54 and type byte $F76C; scores $92A2-$92A5 and $9208;
;     screenShakeTimer $92C8; gameEndReason $0BA8; sounds, messages and a redraw of the cell
; Called from: resolveAttack ($39FD), cmdMissileStrike ($5413), the drone blast in overlay B ($7312);
;     the alternate entry applyHitToUnitNoAlert ($3B52) from destroyUnit ($5465)
; Reactions that only happen for the local player's own units.  The branch opcode at $3B2C is patched
; per side by overlay A ($7BEC), so 'below unit 50' means 'mine' for side 0 and 'the opponent's' for
; side 1.
; ----------------------------------------------------------------------
applyHitToUnit:
        sta     hitDamage               ; 3B22  $9240 = the damage points to apply
        lda     unitTypeTable,y         ; 3B25  target's type byte
        and     #$07                    ; 3B28  0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY, 4 COMCEN
        cpy     #$32                    ; 3B2A  unit 50 = the first unit of side 1
D_3B2C:
        bcs     L_3B5A                  ; 3B2C  opcode patched per side ($7BEC): the opponent's units skip the alerts below
        cmp     #$03                    ; 3B2E  one of ours - is it a SPY?
        bne     L_3B42                  ; 3B30  no
        lda     unitEnergyTable,y       ; 3B32  its energy byte
        and     #$3F                    ; 3B35  bits 0-5 = energy 0-50
        cmp     #$32                    ; 3B37  still untouched at 50?
        bne     L_3B40                  ; 3B39  already damaged -> warn only on the very first hit
        lda     #$0E                    ; 3B3B  message $0E 'SPY UNDER ATTACK!'
        jsr     queueMessageUnlessFilm  ; 3B3D  queue it (dropped during solo/film playback)
L_3B40:
        lda     #$03                    ; 3B40  restore the type value the message call clobbered
L_3B42:
        cmp     #$04                    ; 3B42  is this our own COMCEN being hit?
        bne     L_3B5A                  ; 3B44  no -> straight on
        lda     #$08                    ; 3B46  8 frames
        sta     screenShakeTimer        ; 3B48  $92C8: shake the screen while the comcen is being hit
        jsr     playSound               ; 3B4B  sound 8 = own comcen hit
        lda     #$04                    ; 3B4E  restore the type value
        bne     L_3B5A                  ; 3B50  (always taken)

; ----------------------------------------------------------------------
; applyHitToUnitNoAlert - Alternate entry of applyHitToUnit that skips the block above, i.e. the spy
; warning, the screen shake and the comcen hit sound that only fire for the local player's own units.
; Everything else - the comcen half-damage and scoring, the energy loss and the destruction handling -
; is identical.
; In:  A = damage points, Y = target unit
; Out: as applyHitToUnit
; Called from: destroyUnit ($5465) with A = $34, i.e. more than any unit's 50 energy, so the SELF
;     DESTRUCT command always kills
; ----------------------------------------------------------------------
applyHitToUnitNoAlert:
        sta     hitDamage               ; 3B52  alternate entry used by SELF DESTRUCT ($5465): damage without the own-unit alerts
        lda     unitTypeTable,y         ; 3B55  target's type byte
        and     #$07                    ; 3B58  type 0-4

; ----------------------------------------------------------------------
; A hit on either comcen: half damage, capped at what is left, and the only thing in the game that
; scores points.  The score table is indexed by the damaged side - slot X holds the points scored
; against side X, which is the other side's running total (see $2965).
; ----------------------------------------------------------------------
L_3B5A:
        cmp     #$04                    ; 3B5A  type 4 = COMCEN
        bne     L_3B93                  ; 3B5C  an ordinary unit -> straight to the damage
        cpy     comcenUnit              ; 3B5E  $9236 = the local player's own comcen (unit 49 or 99)
        bne     L_3B69                  ; 3B61  the opponent's comcen -> no system damage rolled here
        jsr     rollComcenSystemDamage  ; 3B63  knock out one of our comcen's systems at random
        ldy     comcenUnit              ; 3B66  the call clobbers Y
L_3B69:
        lsr     hitDamage               ; 3B69  a comcen only ever takes half damage
        lda     unitEnergyTable,y       ; 3B6C  its energy byte
        and     #$3F                    ; 3B6F  energy it has left
        cmp     hitDamage               ; 3B71  more damage than energy?
        bcs     L_3B79                  ; 3B74  no
        sta     hitDamage               ; 3B76  never score more than the comcen still has
L_3B79:
        ldx     #$00                    ; 3B79  X = the side that was hit
        cpy     #$32                    ; 3B7B  unit 50 = first unit of side 1
        bcc     L_3B80                  ; 3B7D  side 0
        inx                             ; 3B7F  units 50-99 belong to side 1
L_3B80:
        inc     lastShownClock          ; 3B80  make the clock/score line redraw itself ($C519 compares this with the game clock)
        lda     hitDamage               ; 3B83  the damage actually applied
        asl     a                       ; 3B86  2 points per energy point taken off a comcen
        clc                             ; 3B87  prepare the addition
        adc     sideScoreLo,x           ; 3B88  slot X = points scored against side X = the attacking side's total
        sta     sideScoreLo,x           ; 3B8B  16-bit score, low byte
        bcc     L_3B93                  ; 3B8E  no carry
        inc     sideScoreHi,x           ; 3B90  carry into the high byte of the 16-bit score

; ----------------------------------------------------------------------
; Take the energy off and record the hit.
; ----------------------------------------------------------------------
L_3B93:
        lda     hitDamage               ; 3B93  (reloading A is redundant - reduceUnitEnergy reads $9240 itself)
        jsr     reduceUnitEnergy        ; 3B96  energy = energy - damage, with a floor of 0
        lda     hitDamage               ; 3B99  the damage again
        jsr     recordUnitHit           ; 3B9C  flash the unit, pin it down and flag a status refresh
        lda     unitEnergyTable,y       ; 3B9F  its energy byte
        and     #$3F                    ; 3BA2  any energy left?
        bne     L_3BDB                  ; 3BA4  still alive -> just redraw it

; ----------------------------------------------------------------------
; The unit ran out of energy.  An ordinary unit is marked destroyed and gets the death flash; a comcen
; never carries the dead bit - losing it ends the game instead.
; ----------------------------------------------------------------------
        lda     unitTypeTable,y         ; 3BA6  type byte
        ora     #$80                    ; 3BA9  bit 7 = destroyed
        sta     unitTypeTable,y         ; 3BAB  the unit is dead
        and     #$07                    ; 3BAE  type 0-4
        cmp     #$04                    ; 3BB0  was it a COMCEN?
        bne     L_3BD6                  ; 3BB2  an ordinary unit -> death flash
        lda     unitTypeTable,y         ; 3BB4  type byte again
        and     #$7F                    ; 3BB7  a comcen never carries the dead bit ...
        sta     unitTypeTable,y         ; 3BB9  ... it stays on the map
        lda     unitFlagsTable,y        ; 3BBC  flags of the destroyed comcen
        and     #$20                    ; 3BBF  bit 5 = its side
        clc                             ; 3BC1  prepare the addition
        adc     #$20                    ; 3BC2  $20 = side 0 knocked out, $40 = side 1 knocked out
        ora     #$80                    ; 3BC4  bit 7 = the game ended by a knockout
        ora     gameEndReason           ; 3BC6  keep any reason that was already set
        sta     gameEndReason           ; 3BC9  $0BA8 = the game end reason
        ldx     #$01                    ; 3BCC  assume the opponent's comcen fell
        cpy     #$32                    ; 3BCE  unit 50 = first unit of side 1
D_3BD0:
        bcs     L_3BD3                  ; 3BD0  opcode patched per side ($7BEF)
        dex                             ; 3BD2  X = 0 when it was our own comcen that fell
L_3BD3:
        jmp     endGame                 ; 3BD3  halt everything, reveal the units and show the result

L_3BD6:
        lda     #$85                    ; 3BD6  $80 = a hit effect is pending, 5 = the 'destroyed' flash
        sta     unitHitFlashTable,y     ; 3BD8  $FA8C = hit effect of the unit
L_3BDB:
        jsr     drawUnitAtPosition      ; 3BDB  redraw the unit's cell on both map panels
        rts                             ; 3BDE  back to the caller


; ----------------------------------------------------------------------
; reduceUnitEnergyBy - Stores A as the damage in hitDamage $9240 and falls into reduceUnitEnergy.
; In:  A = energy points to take off, Y = unit
; Out: see reduceUnitEnergy
; Called from: resolveAttack ($3947, the blitz charge the attacker pays for) and $3EF8 (1 point per
;     blitz step)
; ----------------------------------------------------------------------
reduceUnitEnergyBy:
        sta     hitDamage               ; 3BDF  $9240 = the energy points to take off

; ----------------------------------------------------------------------
; reduceUnitEnergy - Subtracts hitDamage $9240 from the energy of unit Y, keeping the two visibility
; flags in bits 6-7 and stopping at 0.
; In:  Y = unit, hitDamage $9240
; Out: $F9C4[Y] updated; the ORA operand orVisibilityBits+1 is patched with the saved flags
; Called from: applyHitToUnit ($3B96); reduceUnitEnergyBy falls into it
; ----------------------------------------------------------------------
reduceUnitEnergy:
        lda     unitEnergyTable,y       ; 3BE2  energy byte: bits 0-5 = 0-50, bits 6-7 = the spotted/visible flags
        pha                             ; 3BE5  keep the whole byte for a moment
        and     #$C0                    ; 3BE6  isolate the two visibility flags
        sta     orVisibilityBits+1      ; 3BE8  patch them into the ORA below
        pla                             ; 3BEB  the byte back off the stack
        and     #$3F                    ; 3BEC  energy value only
        sec                             ; 3BEE  prepare the subtraction
        sbc     hitDamage               ; 3BEF  minus the damage
        bcs     orVisibilityBits        ; 3BF2  no borrow -> the result is still positive
        lda     #$00                    ; 3BF4  energy never goes below 0
orVisibilityBits:
        ora     #$FF                    ; 3BF6  operand = the two visibility bits saved at $3BE6
        sta     unitEnergyTable,y       ; 3BF8  energy and visibility flags back into the array
        rts                             ; 3BFB  back to the caller


; ----------------------------------------------------------------------
; recordUnitHit - Records the visible effect of a hit on unit Y: the damage, capped at 20, is stored
; as $80|damage in the hit-effect byte $FA8C that drives the flash and the PINNED status word, the
; same number of movement points is taken off the signed counter $FA28 (clamped at -127), and bit 1 of
; the display flags asks for the console entry to be refreshed.
; In:  A = damage points, Y = unit
; Out: $FA8C[Y] = $80|damage, $FA28[Y] reduced, $FB54[Y] bit 1 set; the SBC operand
;     subtractPinnedDamage+1 is patched
; Called from: applyHitToUnit ($3B9C)
; ----------------------------------------------------------------------
recordUnitHit:
        cmp     #$14                    ; 3BFC  more than 20 points?
        bcc     L_3C02                  ; 3BFE  no
        lda     #$14                    ; 3C00  the effect saturates at 20 points
L_3C02:
        sta     subtractPinnedDamage+1  ; 3C02  patch the subtraction below
        ora     #$80                    ; 3C05  bit 7 = a hit effect is pending on this unit
        sta     unitHitFlashTable,y     ; 3C07  $FA8C drives the hit flash and the PINNED status word
        lda     unitMovePointsTable,y   ; 3C0A  $FA28 = signed movement points
        sec                             ; 3C0D  prepare the subtraction
subtractPinnedDamage:
        sbc     #$FF                    ; 3C0E  operand = the damage: being shot at pins the unit down
        bvc     L_3C14                  ; 3C10  no signed overflow
        lda     #$81                    ; 3C12  clamp at -127
L_3C14:
        sta     unitMovePointsTable,y   ; 3C14  movement points back into the array
        lda     unitDisplayFlagsTable,y ; 3C17  display flags
        ora     #$02                    ; 3C1A  bit 1 = the unit's console entry needs refreshing
        sta     unitDisplayFlagsTable,y ; 3C1C  display flags back into the array
        rts                             ; 3C1F  back to applyHitToUnit


; ----------------------------------------------------------------------
; orderAttackUnitAtCursor - Fire-button action of the attack cursor.  It reads the map cell under the
; battlefield cursor (always the centre of the 7x5 view, viewOriginCol+3 / viewOriginRow+2); if an
; enemy unit stands there it queues command $87 SET TARGET carrying the selected unit and that target,
; otherwise it just beeps.
; In:  zp_A5/zp_A6 = view origin, selectedUnit $90F8, the map, unit flags $F708
; Out: cmdParam1/cmdParam2 (zp_74/zp_75) and a 3-byte command $87 in the outgoing ring, or sound 3
; Called from: the target cursor loop ($21F2, with cursorTargetMode zp_BB > 0)
; ----------------------------------------------------------------------
orderAttackUnitAtCursor:
        lda     viewOriginCol           ; 3C20  left edge of the 7x5 battlefield view
        clc                             ; 3C22  prepare the addition
        adc     #$03                    ; 3C23  the cursor sits 3 cells in ...
        tax                             ; 3C25  -> X = cursor column
        lda     viewOriginRow           ; 3C26  top edge of the view
        clc                             ; 3C28  prepare the addition
        adc     #$02                    ; 3C29  ... and 2 cells down: the centre cell
        tay                             ; 3C2B  -> Y = cursor row
        jsr     readMapCell             ; 3C2C  read the cell under the cursor
        bpl     L_3C4B                  ; 3C2F  bare terrain -> nothing to shoot at
        tay                             ; 3C31  Y = $80|index, which the base-$80 alias tables undo
        ldx     selectedUnit            ; 3C32  the unit the order is for
        lda     unitFlagsTableAlias,y   ; 3C35  flags of the unit under the cursor ($F708-$80)
        eor     unitFlagsTable,x        ; 3C38  compare them with the selected unit's flags
        and     #$20                    ; 3C3B  bit 5 = side
        beq     L_3C4B                  ; 3C3D  same side -> one of ours, not a target
        stx     cmdParam1               ; 3C3F  argument 1 = the unit that will fire
        tya                             ; 3C41  the target's cell byte
        and     #$7F                    ; 3C42  strip the map's 'unit here' marker bit
        sta     cmdParam2               ; 3C44  argument 2 = the target unit
        lda     #$87                    ; 3C46  command $87 SET TARGET
        jmp     queueCmd3               ; 3C48  queue the code plus its two arguments for both machines

L_3C4B:
        lda     #$03                    ; 3C4B  sound 3 = error beep
        jmp     playSound               ; 3C4D  nothing was ordered


; ----------------------------------------------------------------------
; setBoomerTargetCmd - Execution half of command $87 SET TARGET.  The boomer's ordinal among all
; boomers is the index into boomerTargetTable, where the target unit is stored with bit 7 set to mark
; it as a lock the player asked for.  The listing still shows these 13 bytes as data; they are entered
; by the JMP $3C50 at $5182 inside the command dispatcher.
; In:  cmdParam0 zp_73 = boomer unit index, cmdParam2 zp_75 = target unit index
; Out: boomerTargetTable $91DC[ordinal] = $80|target; Y = the ordinal, X = the boomer index
; Called from: cmdSetTarget ($5182), the handler of command $87 on both machines
; ----------------------------------------------------------------------
setBoomerTargetCmd:
        ldx     cmdParam0               ; 3C50  boomer unit index
        jsr     countBoomersBelow       ; 3C52  Y = its ordinal among all boomers = its slot in $91DC
        lda     cmdParam2               ; 3C55  target unit index
        ora     #$80                    ; 3C57  bit 7 = the player locked this target on purpose
        sta     boomerTargetTable,y     ; 3C59  one entry per boomer
        rts                             ; 3C5C  back to the command dispatcher


; ----------------------------------------------------------------------
; countBoomersBelow - Counts the BOOMERs (type 2) with an index below X, which is the ordinal of
; boomer X among all boomers and therefore its slot in boomerTargetTable.  The limit is patched into
; the CPX at $3C6F.
; In:  X = unit index (exclusive upper limit)
; Out: Y = the number of boomers below X, X = the input value, C = 1; the operand countBoomersLimit+1
;     is patched
; Called from: setBoomerTargetCmd ($3C52), getBoomerTarget ($3C7E)
; ----------------------------------------------------------------------
countBoomersBelow:
        stx     countBoomersLimit+1     ; 3C5D  patch the loop limit with the unit we are looking for
        ldy     #$00                    ; 3C60  boomers counted so far
        ldx     #$00                    ; 3C62  start at unit 0
L_3C64:
        lda     unitTypeTable,x         ; 3C64  type byte of this unit
        and     #$07                    ; 3C67  strip the dead/dug-in bits
        cmp     #$02                    ; 3C69  type 2 = BOOMER
        bne     L_3C6E                  ; 3C6B  not a boomer
        iny                             ; 3C6D  count it
L_3C6E:
        inx                             ; 3C6E  next unit
countBoomersLimit:
        cpx     #$FF                    ; 3C6F  operand = the unit index passed in
        bcc     L_3C64                  ; 3C71  count every boomer below it
        rts                             ; 3C73  Y = the boomer's slot in boomerTargetTable


; ----------------------------------------------------------------------
; getBoomerTarget - Reads the target lock of unit X.  For a BOOMER it returns C = 1, Y = the boomer's
; slot and A = its boomerTargetTable entry (bit 7 = locked by the player, bits 0-6 = the target unit);
; any other unit type returns C = 0 and a non-zero, positive A.  Callers test C and N: the unit
; options menu offers CLEAR TARGET or SET TARGET, and the update loop drops the lock when the boomer
; can no longer fire.
; In:  X = unit index
; Out: for a boomer C = 1, A = $91DC[ordinal], Y = the ordinal; otherwise C = 0 and A = type EOR 2
; Called from: $2432, selectBoomerTarget ($3C8F), the per-unit update loop ($444D), cmdClearTarget
;     ($5162), the unit options menu ($5EFF)
; ----------------------------------------------------------------------
getBoomerTarget:
        clc                             ; 3C74  C = 0 will mean 'not a boomer'
        lda     unitTypeTable,x         ; 3C75  type byte
        and     #$07                    ; 3C78  strip the dead/dug-in bits
        eor     #$02                    ; 3C7A  zero only for type 2 = BOOMER
        bne     L_3C84                  ; 3C7C  any other type: return C=0 and A = type EOR 2
        jsr     countBoomersBelow       ; 3C7E  Y = the boomer's slot in $91DC (this also returns C=1)
        lda     boomerTargetTable,y     ; 3C81  bit 7 = locked, bits 0-6 = the target unit
L_3C84:
        rts                             ; 3C84  back to the caller


; ----------------------------------------------------------------------
; selectBoomerTarget - Picks what boomer Y shoots at this round.  It first runs the weapon-range scan
; (scanVisibleEnemies with X = $80) to build the candidate lists, then walks them: if the target the
; player locked on is still listed it is kept and nothing else is looked at; otherwise an enemy COMCEN
; wins outright and, failing that, the nearest candidate is taken - and the now stale lock in
; boomerTargetTable is cleared.
; In:  Y = boomer unit index; boomerTargetTable $91DC; the candidate lists $0400/$0432/$0464 and zp_2F
;     produced by the scan
; Out: fireTargetUnit $91F0 = the chosen target ($FF = none), fireTargetDistance $91F1 = its squared
;     distance, targetDirection zp_6B = the direction 0-3 to turn to; $91DC[ordinal] cleared when the
;     lock was lost; zp_18/zp_19/zp_1A scratch; operand loadBoomerUnit+1 patched
; Called from: the per-unit update loop ($4425) for units of type 2
; ----------------------------------------------------------------------
selectBoomerTarget:
        sty     loadBoomerUnit+1        ; 3C85  remember the boomer: the scan below needs X and Y for itself
        ldx     #$80                    ; 3C88  bit 7 = weapon-range scan, not a spotting scan
        jsr     scanVisibleEnemies      ; 3C8A  build the candidate lists $0400/$0432/$0464
loadBoomerUnit:
        ldx     #$FF                    ; 3C8D  operand = the boomer unit index
        jsr     getBoomerTarget         ; 3C8F  A = its lock byte, Y = its slot in $91DC
        sty     scratch1A               ; 3C92  keep the slot for the 'lost the target' case below
        eor     #$80                    ; 3C94  strip the lock bit -> a plain unit index ($80 when nothing is locked)
        sta     scratch19               ; 3C96  the unit this boomer is currently locked on
        lda     #$FF                    ; 3C98  $FF ...
        sta     fireTargetDistance      ; 3C9A  ... no best distance yet
        sta     fireTargetUnit          ; 3C9D  $91F0 = no target chosen yet
        ldx     scanResultCount         ; 3CA0  index of the last candidate
        stx     scratch18               ; 3CA2  the loop walks the list backwards
        bmi     L_3CD9                  ; 3CA4  nothing within weapon range at all

; ----------------------------------------------------------------------
; Walk the candidate list backwards looking for the best target.
; ----------------------------------------------------------------------
L_3CA6:
        ldx     scratch18               ; 3CA6  current candidate
        ldy     checksumXorConstant,x   ; 3CA8  its unit index, or $FF where scanVisibleEnemies rejected the entry  (also trainerPlaybook0200:play3Col12)
        bmi     L_3CD5                  ; 3CAB  rejected entry -> skip it
        cpy     scratch19               ; 3CAD  is this the target the player locked on?
        beq     L_3CE2                  ; 3CAF  yes -> keep firing at it and stop looking
        cpy     #$31                    ; 3CB1  unit 49 = side 0's comcen
        beq     L_3CB9                  ; 3CB3  yes
        cpy     #$63                    ; 3CB5  unit 99 = side 1's comcen
        bne     L_3CBF                  ; 3CB7  an ordinary unit -> compare distances
L_3CB9:
        lda     #$00                    ; 3CB9  a comcen outranks everything ...
        sta     scratch18               ; 3CBB  ... and the DEC below ends the search right after this entry
        beq     L_3CCA                  ; 3CBD  (always taken; note that the comcen's distance is never stored)
L_3CBF:
        lda     play3Row12,x            ; 3CBF  squared distance of this candidate
        cmp     fireTargetDistance      ; 3CC2  against the best so far
        bcs     L_3CD5                  ; 3CC5  farther away -> keep the old one
        sta     fireTargetDistance      ; 3CC7  a new nearest candidate
L_3CCA:
        lda     checksumXorConstant,x   ; 3CCA  its unit index  (also trainerPlaybook0200:play3Col12)
        sta     fireTargetUnit          ; 3CCD  $91F0 = the unit that will be fired at
        lda     play4Grp12,x            ; 3CD0  direction 0-3 toward it
        sta     targetDirection         ; 3CD3  zp_6B, copied into the unit's facing bits at $4464
L_3CD5:
        dec     scratch18               ; 3CD5  next candidate (the list is walked backwards)
        bpl     L_3CA6                  ; 3CD7  until the whole list has been looked at

; ----------------------------------------------------------------------
; Reached whenever the locked target was not in the list any more (and also when there never was one):
; the lock is dropped, so the unit options menu offers SET TARGET again.
; ----------------------------------------------------------------------
L_3CD9:
        ldy     scratch1A               ; 3CD9  the boomer's slot in $91DC
        lda     #$00                    ; 3CDB  0 = no target locked
        sta     boomerTargetTable,y     ; 3CDD  clear the stale lock
        beq     L_3CF0                  ; 3CE0  (always taken)

; ----------------------------------------------------------------------
; The locked target is still within range: keep it and ignore everything else in the list.
; ----------------------------------------------------------------------
L_3CE2:
        sty     fireTargetUnit          ; 3CE2  Y still holds the locked unit
        lda     play3Row12,x            ; 3CE5  its squared distance
        sta     fireTargetDistance      ; 3CE8  $91F1 = distance to the locked target
        lda     play4Grp12,x            ; 3CEB  direction 0-3 toward it
        sta     targetDirection         ; 3CEE  zp_6B = direction to turn to
L_3CF0:
        rts                             ; 3CF0  back to the per-unit update loop


; ringAnimStep - one byte of state for animateRingBurst below: the radius index of the circle frame
; currently being drawn into or erased from sprite 1 (1..A-1 going out, A-1..1 coming back).
ringAnimStep:
        .byte   $00                     ; 3CF1  .  radius index of the ring frame being drawn/erased (0 when the effect is idle)

; ----------------------------------------------------------------------
; animateRingBurst - Expanding-then-contracting ring drawn into hardware sprite 1.  It clears the
; sprite shape, switches the sprite on, draws circle outlines of radius 1,2,..,A-1 into it
; (spritePlotMode $40 = OR the pixels in), plays the explosion sound at full size and then erases the
; same frames in reverse order (spritePlotMode 0 = AND them out) before hiding the sprite again.
; In:  A = number of radius steps, patched into the CPX operand at L_3D24+1 (drawCircleInSprite only
;     has point lists for radii 1-10); sprite 1 must already be positioned through the shadow
;     registers $9011/$9019.
; Out: sprite 1 shape built and cleared again, spriteEnableShadow bit 1 set and then cleared,
;     ringAnimStep = 0, spritePlotMode left at 0, sprite1ColourShadow = $12, unusedByte7D = 0 (dead
;     store), sound $17 played.  Every wait runs through runGameFrames, so the battle keeps ticking
;     during the animation.
; Called from: the $6F00 overlay only - $767B with A=3 (the ring that marks a unit being placed / the
;     kick-off) and $7A0E with A=$0B (the comcen knock-out explosion).
; ----------------------------------------------------------------------
animateRingBurst:
        sta     L_3D24+1                ; 3CF2  patch the loop limit: A becomes the operand of the CPX at $3D24, i.e. the largest radius + 1
        lda     #$12                    ; 3CF5  $12 = packed flash colour pair (high nibble 1 = white, low nibble 2 = red)
        sta     sprite1ColourShadow     ; 3CF7  sprite 1 colour shadow; the IRQ alternates the two nibbles so the ring flashes white/red
        jsr     leftoverSetC397         ; 3CFA  wait until the raster is below the visible screen so building the shape does not tear (the $C000 overlay resident here is waitRasterBelowScreen; the label is the boot loader's leftover at the same address)  (also textEngineC000:waitRasterBelowScreen)
        lda     #$01                    ; 3CFD  sprite number 1 (its shape lives at $8A00 + 1*64 = $8A40)
        jsr     clearSpriteShape        ; 3CFF  wipe the 64 shape bytes before the first frame is plotted
        lda     #$02                    ; 3D02  bit mask %00000010 = sprite 1
        jsr     enableSprites           ; 3D04  OR it into spriteEnableShadow ($9022) so the IRQ turns sprite 1 on
        lda     #$40                    ; 3D07  plot mode $40 = set (OR) the pixels of the circle
        sta     spritePlotMode          ; 3D09  drawCircleInSprite reads spritePlotMode for every plotted point
        ldx     #$01                    ; 3D0C  start with radius 1 (a filled 3x3 dot)
        stx     ringAnimStep            ; 3D0E  keep the radius index in ringAnimStep as well as in X

; ----------------------------------------------------------------------
; Growing phase: draw radius 1, 2, ... up to the caller's limit minus 1.
; ----------------------------------------------------------------------
ringExpandLoop:
        lda     #$01                    ; 3D11  sprite number 1 (drawCircleInSprite takes the sprite in A, the radius in X)
        jsr     drawCircleInSprite      ; 3D13  plot the circle of radius X into sprite 1
        lda     ringAnimStep            ; 3D16  run 1 game frame on odd radii and none on even ones ...
        and     #$01                    ; 3D19  ... so the ring grows about one step every other frame
        jsr     runGameFrames           ; 3D1B  let the engine advance while the ring expands
        inc     ringAnimStep            ; 3D1E  next radius
        ldx     ringAnimStep            ; 3D21  reload it into X for the compare and for the next drawCircleInSprite
L_3D24:
        cpx     #$FF                    ; 3D24  operand patched at $3CF2 with the caller's step count
        bcc     ringExpandLoop          ; 3D26  keep growing while the radius is below the requested maximum
        lda     #$00                    ; 3D28  dead store: nothing anywhere reads zp $7D
        sta     unusedByte7D            ; 3D2A  (a leftover of an earlier version of the effect)
        lda     #$17                    ; 3D2C  sound $17 = the explosion noise, played at full ring size
        jsr     playSound               ; 3D2E  queue it on a SID voice
        lda     #$05                    ; 3D31  hold the largest ring for 5 frames
        jsr     runGameFrames           ; 3D33  the engine keeps running while we wait
        lda     #$00                    ; 3D36  plot mode 0 = clear (AND out) the pixels again
        sta     spritePlotMode          ; 3D38  so the same circle calls now erase what they drew
        dec     ringAnimStep            ; 3D3B  step back to the last radius that was actually drawn
        ldx     ringAnimStep            ; 3D3E  X = that radius

; ----------------------------------------------------------------------
; Shrinking phase: erase the frames in reverse order, largest first, with no frame delay.
; ----------------------------------------------------------------------
ringShrinkLoop:
        lda     #$01                    ; 3D41  sprite number 1
        jsr     drawCircleInSprite      ; 3D43  erase the circle of radius X
        dec     ringAnimStep            ; 3D46  next smaller radius
        ldx     ringAnimStep            ; 3D49  X = radius for the next pass
        bne     ringShrinkLoop          ; 3D4C  stop once radius 1 has been erased
        jsr     hideSprite1             ; 3D4E  clear bit 1 of the sprite enable shadow and of VIC_SPR_ENA at once
        lda     #$01                    ; 3D51  sprite number 1
        jmp     clearSpriteShape        ; 3D53  tail call: blank its 64 shape bytes and return to the caller


; comcenSystemIndex - loop counter of rollComcenSystemDamage: the comcen system being rolled for,
; counting 3, 2, 1 (1 = BATTLE, 2 = RADAR, 3 = DRONE; index 0 is the derived energy status and is
; never damaged here).
comcenSystemIndex:
        .byte   $00                     ; 3D56  .  the comcen system index 3..1 while the damage roll runs

; ----------------------------------------------------------------------
; rollComcenSystemDamage - Rolls for collateral damage to the local comcen's three repairable systems
; after it has been hit.  It runs only in a live game with the DAMAGE rule switched on; for each
; system that is still healthy it sets the status to $80 (damaged) when a random 6-bit number is
; smaller than the damage of the hit, so a big hit is much more likely to knock a system out.
; In:  hitDamage $9240 = the damage the hit just did; filmPlaybackMode $0B9B bit 7 (film / solo
;     replay), gameTypeOptions $0BA3 bit 4 (the DAMAGE option), gameEndReason $0BA8 (0 = a game is
;     running).
; Out: comcenSystemStatus[1..3] ($9242-$9244) may become $80 = damaged; comcenSystemIndex left 0; A/Y
;     clobbered.  The system states are never sent over the link - each machine damages and repairs
;     its own comcen locally.
; Called from: applyHitToUnit at $3B63, only when the unit that was hit is the local comcen.
; ----------------------------------------------------------------------
rollComcenSystemDamage:
        lda     filmPlaybackMode        ; 3D57  bit 7 set = watching a game film / solo replay ...
        bmi     L_3D89                  ; 3D5A  ... in which case no new damage may be rolled
        lda     gameTypeOptions         ; 3D5C  the rule options of this game
        and     #$10                    ; 3D5F  bit 4 = the DAMAGE option ('comcen systems can be damaged')
        beq     L_3D89                  ; 3D61  the systems are immune when the option is off
        lda     gameEndReason           ; 3D63  non-zero = the battle has ended (or has not started)
        bne     L_3D89                  ; 3D66  no damage rolls outside a running game
        lda     #$03                    ; 3D68  start at system 3 (DRONE)
        sta     comcenSystemIndex       ; 3D6A  comcenSystemIndex is the loop counter

; ----------------------------------------------------------------------
; Roll once for each of the three repairable systems 3, 2, 1 (DRONE, RADAR, BATTLE).
; ----------------------------------------------------------------------
comcenSystemRollLoop:
        ldy     comcenSystemIndex       ; 3D6D  Y = system index
        lda     comcenSystemStatus,y    ; 3D70  $9241+Y: 0 = healthy, $40 = degraded, $80 = damaged
        bmi     L_3D84                  ; 3D73  already damaged, nothing left to break
        jsr     nextGameRandom          ; 3D75  next pseudo random byte
        and     #$3F                    ; 3D78  keep 6 bits: 0..63
        cmp     hitDamage               ; 3D7A  compare it against the damage of the hit (energy runs 0-50)
        bcs     L_3D84                  ; 3D7D  roll too high: this system survives
        lda     #$80                    ; 3D7F  $80 = damaged
        sta     comcenSystemStatus,y    ; 3D81  mark the system as knocked out
L_3D84:
        dec     comcenSystemIndex       ; 3D84  next lower system
        bne     comcenSystemRollLoop    ; 3D87  stop after system 1; index 0 is the energy display status, not a system
L_3D89:
        rts                             ; 3D89  return


; ----------------------------------------------------------------------
; initAllUnits - Resets all 100 unit records from their start positions and puts every living unit
; back on the battlefield map, then clears the 16 per-group state bytes.  Run once when a battle
; starts, after the start-position template has been copied into the unit arrays.
; In:  unitColTable/unitRowTable hold the start coordinates, unitTypeTable the types and
;     unitFlagsTable the group bits taken from the template.
; Out: every per-unit array reset by initUnitRecord/placeUnitOnMap, map cells at the unit positions
;     set to $80|index, groupStatusTable $91F4-$9203 zeroed; scratch18 = $FF.
; Called from: overlay A (the setup phase) at $7AEF.
; ----------------------------------------------------------------------
initAllUnits:
        ldy     #$63                    ; 3D8A  start at unit 99, the last unit of side 1
        sty     scratch18               ; 3D8C  initUnitRecord takes the unit index in scratch18

; ----------------------------------------------------------------------
; Reset units 99 down to 0.
; ----------------------------------------------------------------------
initUnitLoop:
        jsr     initUnitRecord          ; 3D8E  reset one unit and place it on the map
        dec     scratch18               ; 3D91  next lower unit index
        bpl     initUnitLoop            ; 3D93  loop until scratch18 goes negative (unit 0 done)
        ldy     #$0F                    ; 3D95  16 group slots (group key = unitFlagsTable bits 2-5, i.e. 7 groups x 2 sides)

; ----------------------------------------------------------------------
; Clear the 16 groupStatusTable entries (slowest speed class in bits 0-2, worst terrain class in bits
; 3-5).
; ----------------------------------------------------------------------
clearGroupStatusLoop:
        lda     #$00                    ; 3D97  no speed or terrain class recorded yet
        sta     groupStatusTable,y      ; 3D99  $91F4+Y
        dey                             ; 3D9C  next group slot
        bpl     clearGroupStatusLoop    ; 3D9D  until all 16 are cleared
        rts                             ; 3D9F  return


; ----------------------------------------------------------------------
; initUnitRecord - Resets the order state of one unit: its path position and its destination are
; snapped to the cell it stands on, the destination is marked 'idle', the zone-scored bits and the
; stun counter are cleared, the group membership is kept and the facing and side bits are rebuilt.
; Falls straight through into placeUnitOnMap, which finishes the reset and puts the unit on the map.
; In:  scratch18 = unit index (loaded into X here).
; Out: unitPathCol/Row = unitCol/Row (cloak and blitz bits cleared), unitDestCol = col|$80
;     (stationary), unitDestRow = row, unitTypeTable bits 3-4 (zone scored) cleared, unitStunTable =
;     0, unitFlagsTable = group bits + facing + side bit; then everything placeUnitOnMap does.
; Called from: initAllUnits ($3D8E) for every unit at game start and recycleDeadUnits ($3E94) for a
;     robot respawning at the recycler.
; ----------------------------------------------------------------------
initUnitRecord:
        ldx     scratch18               ; 3DA0  X = unit index (the caller passes it in scratch18)
        lda     unitColTable,x          ; 3DA2  the unit's current map column
        sta     unitPathColTable,x      ; 3DA5  path position = map position; bits 6-7 (cloak) are cleared as a side effect
        ora     #$80                    ; 3DA8  bit 7 of unitDestCol = 'at its destination / stationary'
        sta     unitDestColTable,x      ; 3DAA  destination column = where it stands, marked idle
        lda     unitRowTable,x          ; 3DAD  its current map row
        sta     unitPathRowTable,x      ; 3DB0  path row = map row; bits 6-7 (blitz) cleared with it
        sta     unitDestRowTable,x      ; 3DB3  destination row = current row (bit 7 = 'repairing' cleared too)
        lda     unitTypeTable,x         ; 3DB6  type byte: bits 0-2 type, bits 3-4 zone scored, bit 6 dug in, bit 7 dead
        and     #$E7                    ; 3DB9  $E7 keeps everything except bits 3 and 4 = the two 'already scored this zone' flags
        sta     unitTypeTable,x         ; 3DBB  so a reset unit can score its terrain zones again
        lda     #$00                    ; 3DBE  clear the stun / dig-in byte ...
        sta     unitStunTable,x         ; 3DC0  ... no stun counter and no dig-in progress
        lda     unitFlagsTable,x        ; 3DC3  flags: facing 0-1, group id 2-4, side 5, group member 6, blitz charge 7
        and     #$5C                    ; 3DC6  $5C keeps bits 2-4 (group id) and bit 6 (is a group member)
        sta     unitFlagsTable,x        ; 3DC8  facing, side and the one-shot charge bit are dropped
        jsr     initialFacingForUnit    ; 3DCB  A = 0 (face up) for our units, 1 (face down) for the opponent's
        ora     unitFlagsTable,x        ; 3DCE  merge the facing into bits 0-1
        cpx     #$32                    ; 3DD1  units 0-49 are side 0, 50-99 side 1 (a fixed test here, not one of the patched ones)
        bcc     L_3DD7                  ; 3DD3  side 0 leaves bit 5 clear
        ora     #$20                    ; 3DD5  bit 5 = side 1
L_3DD7:
        sta     unitFlagsTable,x        ; 3DD7  store the rebuilt flag byte and fall into placeUnitOnMap

; ----------------------------------------------------------------------
; placeUnitOnMap - Second half of the unit reset: restores the dug-in bit from the type byte, gives
; the unit a fresh movement allowance and full energy, copies the stationary and group bits into the
; display flags, remembers the terrain of the cell it stands on and writes $80|index into that map
; cell.  A unit whose type byte has bit 7 set (destroyed) becomes an empty slot instead ($FF in
; unitTypeTable and unitTerrainUnderTable) and is not put on the map.
; In:  X = scratch18 = unit index; unitColTable/unitRowTable = the cell to occupy.
; Out: unitStunTable bit 7 = dug in, unitHitFlashTable = 0, unitMovePointsTable = 10, unitEnergyTable
;     = 50 (100%), unitDisplayFlagsTable = stationary bit + group bits, unitTerrainUnderTable = the
;     terrain hidden by the unit, map cell at (col,row) = $80|index; mapCellPtr points at that cell, Y
;     = 0.
; Called from: initUnitRecord (fall-through) and overlay A at $7D83, which re-places every unit once
;     the setup phase has moved them around.
; ----------------------------------------------------------------------
placeUnitOnMap:
        lda     unitTypeTable,x         ; 3DDA  the type byte again
        and     #$40                    ; 3DDD  bit 6 = the unit has finished digging in
        asl     a                       ; 3DDF  shift it into bit 7 ...
        sta     unitStunTable,x         ; 3DE0  ... which is the 'dug in' bit of the stun / dig-in byte
        lda     #$00                    ; 3DE3  clear the hit-flash / pinned countdown ...
        sta     unitHitFlashTable,x     ; 3DE5  ... so the unit is drawn normally
        lda     #$0A                    ; 3DE8  10 movement points to start with
        sta     unitMovePointsTable,x   ; 3DEA  the signed movement-point balance ($FA28)
        lda     #$32                    ; 3DED  50 energy points = 100% (the console shows it as a percentage)
        sta     unitEnergyTable,x       ; 3DEF  energy in bits 0-5; the visibility bits 6-7 stay clear (not spotted yet)
        lda     unitDestColTable,x      ; 3DF2  the destination byte initUnitRecord has just written
        and     #$80                    ; 3DF5  keep only bit 7 = stationary
        sta     unitDisplayFlagsTable,x ; 3DF7  the display flags start as 'stationary, nothing fired, not pinned'
        jsr     syncLocalGroupFlags     ; 3DFA  copy the authoritative group bits (unitFlagsTable & $7C) into the display copy
        ldy     scratch18               ; 3DFD  Y = unit index
        ldx     unitColTable,y          ; 3DFF  X = its map column
        lda     unitRowTable,y          ; 3E02  A = its map row ...
        tay                             ; 3E05  ... which readMapCell expects in Y
        jsr     readMapCell             ; 3E06  A = the byte in that cell, mapCellPtr = its address, Y = 0
        ldx     scratch18               ; 3E09  X = unit index again
        sta     unitTerrainUnderTable,x ; 3E0B  remember the terrain the unit is about to cover
        lda     unitTypeTable,x         ; 3E0E  the type byte
        bpl     L_3E1D                  ; 3E11  bit 7 clear = the unit is alive, put it on the map
        lda     #$FF                    ; 3E13  $FF marks an unused unit slot ...
        sta     unitTypeTable,x         ; 3E15  ... in the type array ...
        sta     unitTerrainUnderTable,x ; 3E18  ... and in the terrain-under array (= 'not on the map')
        bmi     L_3E22                  ; 3E1B  always taken ($FF is negative): a destroyed unit is not drawn
L_3E1D:
        txa                             ; 3E1D  A = unit index
        ora     #$80                    ; 3E1E  an occupied map cell holds $80|index
        sta     (mapCellPtr),y          ; 3E20  claim the cell (Y is still 0 from readMapCell)
L_3E22:
        rts                             ; 3E22  return


; ----------------------------------------------------------------------
; recycleDeadUnits - Once per round each side that owns a recycler gets one destroyed robot back.  For
; a side whose recycler cell is not occupied it scans that side's 50 units downwards from the comcen
; for destroyed ones (type bit 7 set, but not the $FF empty-slot marker).  With recycler rate FULL
; (mode 0) the first destroyed unit found is respawned at once; with HALF (mode 1) two destroyed units
; of the SAME type are needed - the first is thrown away (slot set to $FF) and the second respawns.
; The new robot takes the recycler's coordinates, keeps its type, loses all group membership and is
; run through initUnitRecord.
; In:  gameEndReason $0BA8 (must be 0), recyclerCol/recyclerRow $92A8/$92AA (bit 7 set = that side has
;     no recycler, rate NONE), recyclerMode $92A6 (0 = FULL, 1 = HALF), perSideValueTable $0C8A = each
;     side's comcen index ($31/$63), the unit arrays.
; Out: at most one unit per side respawned: its unitCol/unitRow, unitTypeTable, unitFlagsTable and
;     everything initUnitRecord touches; for the local side pendingRedrawUnit = the new unit and
;     message 5 'UNIT RECYCLED.' is queued.  scratch18/19/1A/1B/1C/1D used as scratch.
; Called from: finishRound at $465A, i.e. once per completed round of 100 unit steps.
; ----------------------------------------------------------------------
recycleDeadUnits:
        lda     gameEndReason           ; 3E23  0 while a battle is in progress
        beq     L_3E29                  ; 3E26  carry on only while the game is running
        rts                             ; 3E28  no recycling once the game is over

L_3E29:
        lda     #$01                    ; 3E29  start with side 1 ...
        sta     scratch1A               ; 3E2B  ... scratch1A is the side index, counted down to 0

; ----------------------------------------------------------------------
; Outer loop over the two sides, 1 then 0.
; ----------------------------------------------------------------------
recyclerSideLoop:
        ldy     scratch1A               ; 3E2D  Y = side index
        ldx     recyclerCol,y           ; 3E2F  X = that side's recycler column; bit 7 set = no recycler (rate NONE)
        bmi     L_3E3B                  ; 3E32  no recycler for this side - N is still set, so the test at $3E3B sends us on
        lda     recyclerRow,y           ; 3E34  recycler row ...
        tay                             ; 3E37  ... readMapCell wants the row in Y and the column in X
        jsr     readMapCell             ; 3E38  A = what is standing on the recycler cell
L_3E3B:
        bmi     L_3EB5                  ; 3E3B  N set = no recycler, or a unit is parked on the recycler: skip this side
        ldy     scratch1A               ; 3E3D  Y = side index
        lda     recyclerMode,y          ; 3E3F  recycler rate: 0 = FULL (one for one), 1 = HALF (two of a type for one)
        sta     L_3E5B+1                ; 3E42  patch it into the LDY immediate at $3E5B so the rate is tested inside the loop
        ldx     comcenUnitIndexBySide,y ; 3E45  $0C8A = comcen unit index per side ($31 = 49, $63 = 99)
        stx     scratch1C               ; 3E48  scan downwards from the comcen
        ldy     #$32                    ; 3E4A  50 units per side
        sty     scratch1D               ; 3E4C  scratch1D = units left to examine
        sty     scratch19               ; 3E4E  scratch19 = type of the previous destroyed unit; 50 can never match a real type (0-4)

; ----------------------------------------------------------------------
; Scan this side's 50 units from the comcen downwards, looking for destroyed robots.
; ----------------------------------------------------------------------
recyclerScanLoop:
        ldx     scratch1C               ; 3E50  X = unit being examined
        lda     unitTypeTable,x         ; 3E52  type byte
        bpl     L_3EAF                  ; 3E55  bit 7 clear = the unit is alive, keep looking
        cmp     #$FF                    ; 3E57  $FF = empty slot (already recycled away)
        beq     L_3EAF                  ; 3E59  not a destroyed unit either
L_3E5B:
        ldy     #$FF                    ; 3E5B  operand patched at $3E42 with the recycler rate; the LDY sets Z from it
        bne     L_3E65                  ; 3E5D  rate HALF: a matching pair is needed
        and     #$07                    ; 3E5F  rate FULL: keep the plain type in bits 0-2 ...
        sta     scratch19               ; 3E61  ... as the type of the unit to respawn
        bpl     respawnUnitAtRecycler   ; 3E63  always taken (a type is 0-4): respawn this one immediately
L_3E65:
        and     #$07                    ; 3E65  rate HALF: plain type of this destroyed unit
        cmp     scratch19               ; 3E67  does it match the type of the destroyed unit found before it?
        bne     L_3EAB                  ; 3E69  no - remember this one and keep scanning
        ldx     scratch1B               ; 3E6B  X = the first unit of the pair
        jsr     removeUnitFromMap       ; 3E6D  take it off the map, restoring the terrain under it
        lda     #$FF                    ; 3E70  $FF = empty slot ...
        sta     unitTypeTable,x         ; 3E72  ... the first of the pair is consumed and gone for good

; ----------------------------------------------------------------------
; Respawn unit scratch1C of type scratch19 on the recycler cell.
; ----------------------------------------------------------------------
respawnUnitAtRecycler:
        ldx     scratch1C               ; 3E75  X = the unit that will come back
        jsr     removeUnitFromMap       ; 3E77  make sure it is not on the map anywhere
        ldy     scratch1A               ; 3E7A  Y = side index
        lda     recyclerCol,y           ; 3E7C  recycler column ...
        sta     unitColTable,x          ; 3E7F  ... becomes the new unit's column
        lda     recyclerRow,y           ; 3E82  recycler row ...
        sta     unitRowTable,x          ; 3E85  ... becomes the new unit's row
        lda     scratch19               ; 3E88  the plain type (bit 7 cleared, so the unit is alive again)
        sta     unitTypeTable,x         ; 3E8A  store it back into the type array
        lda     #$00                    ; 3E8D  no facing, no group, side bit rebuilt by initUnitRecord ...
        sta     unitFlagsTable,x        ; 3E8F  ... a recycled robot always comes back ungrouped
        stx     scratch18               ; 3E92  initUnitRecord takes the unit index in scratch18
        jsr     initUnitRecord          ; 3E94  reset its orders and put it on the map at the recycler
        lda     scratch1A               ; 3E97  which side got the replacement?
        cmp     playerSide              ; 3E99  only tell the player about his own robots
        bne     L_3EB5                  ; 3E9C  the opponent's recycling is silent here (his machine reports it)
        lda     scratch18               ; 3E9E  the new unit ...
        sta     pendingRedrawUnit       ; 3EA0  ... is queued for a redraw by the next updateGameFrame
        lda     #$05                    ; 3EA3  message 5 = 'UNIT RECYCLED.'
        jsr     queueMessageUnlessFilm  ; 3EA5  queue it (dropped while a film is playing)
        jmp     L_3EB5                  ; 3EA8  one respawn per side per round: go to the next side

L_3EAB:
        sta     scratch19               ; 3EAB  remember this destroyed unit's type ...
        stx     scratch1B               ; 3EAD  ... and its index, as the first half of a possible pair
L_3EAF:
        dec     scratch1C               ; 3EAF  next lower unit index
        dec     scratch1D               ; 3EB1  one of the 50 units done
        bne     recyclerScanLoop        ; 3EB3  keep scanning until this side's 50 units are exhausted
L_3EB5:
        dec     scratch1A               ; 3EB5  next side
        bmi     L_3EBC                  ; 3EB7  both sides done
        jmp     recyclerSideLoop        ; 3EB9  round two, for side 0

L_3EBC:
        rts                             ; 3EBC  return


; ----------------------------------------------------------------------
; removeUnitFromMap - Lifts unit X off the battlefield map: the terrain byte the unit was hiding is
; written back into its cell, the unit is marked as 'not on the map' (unitTerrainUnderTable = $FF) and
; its hit-flash/pinned countdown is cleared.  Does nothing when the unit is not on the map already.
; In:  X = unit index; unitTerrainUnderTable[X] = the saved terrain ($FF = not on the map);
;     unitColTable/unitRowTable = its cell.
; Out: map cell restored to terrain, unitTerrainUnderTable[X] = $FF, unitHitFlashTable[X] = 0,
;     mapCellPtr points at the cell, Y = 0.
; Called from: recycleDeadUnits ($3E6D and $3E77) and overlay A at $7D9A (clearing the board before
;     the units are re-placed).
; ----------------------------------------------------------------------
removeUnitFromMap:
        lda     unitTerrainUnderTable,x ; 3EBD  the terrain byte hidden under the unit
        bmi     L_3EE1                  ; 3EC0  $FF = the unit is not on the map, nothing to restore

; ----------------------------------------------------------------------
; removeUnitLeavingCell - Same as removeUnitFromMap but the caller chooses what stays behind in the
; unit's cell (A) instead of the saved terrain.  Used when a destroyed unit should leave a
; wreck/marker glyph rather than plain terrain.
; In:  A = byte to write into the unit's map cell, X = unit index.
; Out: map cell = A, unitTerrainUnderTable[X] = $FF, unitHitFlashTable[X] = 0, Y = 0; the LDX operand
;     at L_3ED7+1 is patched with the unit index.
; Called from: updateHitFlashTimers at $4894, when the flash of a destroyed unit expires.
; ----------------------------------------------------------------------
removeUnitLeavingCell:
        stx     L_3ED7+1                ; 3EC2  park the unit index in the LDX operand at $3ED7 (X is needed for the column)
        pha                             ; 3EC5  save the byte to leave in the cell
        ldy     unitRowTable,x          ; 3EC6  Y = the unit's map row
        lda     unitColTable,x          ; 3EC9  A = its map column ...
        tax                             ; 3ECC  ... readMapCell wants the column in X
        jsr     readMapCell             ; 3ECD  sets mapCellPtr to the unit's cell (and returns Y = 0)
        pla                             ; 3ED0  get the byte to leave behind back
        ldy     #$00                    ; 3ED1  index 0 of the cell pointer
        sta     (mapCellPtr),y          ; 3ED3  write it into the map
        lda     #$FF                    ; 3ED5  $FF = this unit is no longer on the map
L_3ED7:
        ldx     #$FF                    ; 3ED7  operand patched at $3EC2 with the unit index
        sta     unitTerrainUnderTable,x ; 3ED9  mark the unit as off the map
        lda     #$00                    ; 3EDC  clear the hit-flash / pinned countdown ...
        sta     unitHitFlashTable,x     ; 3EDE  ... so nothing tries to redraw the vacated cell later
L_3EE1:
        rts                             ; 3EE1  return


; ----------------------------------------------------------------------
; applyBlitzStepCost - Charges a blitzing unit for the step it has just taken: it loses one energy
; point and gains 25 movement points (SET BLITZ = 'move extra fast'), and its next shot gets the
; charge bonus (unitFlagsTable bit 7).  A unit that has arrived or that has run out of energy pays
; nothing.  When the blitzing unit is the local comcen in a live battle it also starts the comcen
; engine sound.
; In:  Y = unit index; the caller has already checked that the blitz bit (unitPathRowTable bit 7) is
;     set; gamePhase zp_B1, filmPlaybackMode $0B9B, comcenUnit $9236, D_67CA = the sound currently on
;     SID voice 2.
; Out: unitFlagsTable[Y] bit 7 set, one energy point taken through reduceUnitEnergyBy (hitDamage = 1),
;     unitMovePointsTable[Y] += 25 saturating at $7F; possibly sound $16.
; Called from: processUnitStep at $433C after a successful move step, and by the blitz command
;     handlers at $5130 / $5094.
; ----------------------------------------------------------------------
applyBlitzStepCost:
        lda     unitDestColTable,y      ; 3EE2  destination byte, bit 7 = the unit is already where it wanted to go
        bmi     L_3F26                  ; 3EE5  a stationary unit cannot blitz
        lda     unitEnergyTable,y       ; 3EE7  energy byte
        and     #$3F                    ; 3EEA  bits 0-5 = energy 0-50, bits 6-7 are visibility
        beq     L_3F26                  ; 3EEC  no energy left, the blitz fizzles
        lda     unitFlagsTable,y        ; 3EEE  unit flags
        ora     #$80                    ; 3EF1  bit 7 = one-shot charge bonus applied to the next attack
        sta     unitFlagsTable,y        ; 3EF3  store it back
        lda     #$01                    ; 3EF6  one energy point is the price of a blitz step
        jsr     reduceUnitEnergyBy      ; 3EF8  hitDamage = 1, then subtract it from unit Y's energy
        lda     gamePhase               ; 3EFB  0 only during the real-time battle
        ora     filmPlaybackMode        ; 3EFD  and only when this is not a film / solo replay
        bne     L_3F19                  ; 3F00  no engine sound outside a live battle
        cpy     comcenUnit              ; 3F02  only the local player's own comcen makes engine noise
        bne     L_3F19                  ; 3F05  any other unit is silent
        lda     sndVoice2SoundId        ; 3F07  the sound id currently playing on SID voice 2
        beq     L_3F14                  ; 3F0A  voice free
        cmp     #$14                    ; 3F0C  $14 = comcen moving (normal speed)
        beq     L_3F14                  ; 3F0E  the engine is already running, restart it as a blitz
        cmp     #$16                    ; 3F10  $16 = comcen blitzing
        bne     L_3F19                  ; 3F12  some other effect owns the voice - do not interrupt it
L_3F14:
        lda     #$16                    ; 3F14  sound $16 = comcen engine at blitz speed
        jsr     playSound               ; 3F16  request it
L_3F19:
        lda     unitMovePointsTable,y   ; 3F19  current movement-point balance (signed)
        clc                             ; 3F1C  clear the carry for the addition ...
        adc     #$19                    ; 3F1D  +25 points buys roughly one extra step
        bvc     L_3F23                  ; 3F1F  no signed overflow, keep the sum
        lda     #$7F                    ; 3F21  clamp at +127 so the balance stays positive
L_3F23:
        sta     unitMovePointsTable,y   ; 3F23  store the new balance
L_3F26:
        rts                             ; 3F26  return


; ----------------------------------------------------------------------
; drawRecyclerOnMap - Puts the recycler glyph ($67) on the battlefield map at side Y's recycler
; position and hands the caller everything drawMapCell needs to show it.  If a unit happens to be
; standing on the recycler cell the glyph is stored in that unit's 'terrain under the unit' entry
; instead, so it appears as soon as the unit leaves, and the unit's own map byte is returned for the
; redraw.  Returns immediately when that side has no recycler.
; In:  Y = side index (0 or 1); recyclerCol $92A8 / recyclerRow $92AA (bit 7 of the column set = no
;     recycler).
; Out: X = column, Y = row, A = the byte to draw ($67, or $80|unit when a unit is on the cell); the
;     map cell or unitTerrainUnderTable updated; the three immediate operands at $3F51/$3F53/$3F55 are
;     patched with the return values.
; Called from: checkEnemyRecyclerSpotted ($3F86), the recycler-placement command handler ($550D),
;     overlay A ($7B92, $7D6A) and the modem driver ($EE47, which jumps here to draw our own recycler
;     at game start).
; ----------------------------------------------------------------------
drawRecyclerOnMap:
        lda     #$67                    ; 3F27  $67 = the recycler glyph in the terrain character set
        sta     L_3F54+1                ; 3F29  default return value: patch it into the LDA at $3F54
        ldx     recyclerCol,y           ; 3F2C  X = recycler column; bit 7 set = this side has no recycler
        bpl     L_3F32                  ; 3F2F  there is one, go on
        rts                             ; 3F31  rate NONE: nothing to draw

L_3F32:
        stx     L_3F50+1                ; 3F32  return the column through the LDX operand at $3F50
        lda     recyclerRow,y           ; 3F35  recycler row ...
        tay                             ; 3F38  ... into Y for readMapCell
        sty     L_3F52+1                ; 3F39  return the row through the LDY operand at $3F52
        jsr     readMapCell             ; 3F3C  A = current contents of the recycler cell, mapCellPtr = its address
        bpl     L_3F4C                  ; 3F3F  bit 7 clear = the cell is free terrain
        sta     L_3F54+1                ; 3F41  a unit is standing there: return its map byte so the caller redraws the unit
        tay                             ; 3F44  Y = $80|unit index
        lda     #$67                    ; 3F45  the recycler glyph ...
        sta     unitTerrainUnderAlias,y ; 3F47  ... goes into that unit's saved-terrain slot ($FA70+$80+unit = $FAF0+unit) so it reappears when the unit moves off
        bne     L_3F50                  ; 3F4A  always taken ($67 is not zero)
L_3F4C:
        lda     #$67                    ; 3F4C  free cell: write the recycler glyph ...
        sta     (mapCellPtr),y          ; 3F4E  ... straight into the map (Y = 0 from readMapCell)
L_3F50:
        ldx     #$FF                    ; 3F50  operand patched at $3F32: X = recycler column
L_3F52:
        ldy     #$FF                    ; 3F52  operand patched at $3F39: Y = recycler row
L_3F54:
        lda     #$FF                    ; 3F54  operand patched at $3F29/$3F41: A = the cell value to draw
        rts                             ; 3F56  return with X/Y/A set up for drawMapCell


; ----------------------------------------------------------------------
; checkEnemyRecyclerSpotted - Reveals the opponent's recycler the first time one of our own uncloaked
; robots gets within 3 cells of it: it draws the recycler glyph on the map, queues message 8 'SPOTTED
; RECYCLER!' and then patches its own first byte to RTS so the test never runs again.
; In:  playerSide $0B9F, perSideValueTable $0C8A (comcen index per side), the opponent's
;     recyclerCol/recyclerRow; the area-scan lists at $0400/$0432.
; Out: the enemy recycler cell drawn, message 8 queued, the byte at $3F57 changed from NOP to RTS.
;     The byte is set back to NOP by initIrqAndInputState ($0BBD) at game start, and the modem driver
;     writes RTS there directly ($EE3E) when the opponent told us he has no recycler.
; Called from: finishRound at $4657, once per round.
; ----------------------------------------------------------------------
checkEnemyRecyclerSpotted:
        nop                             ; 3F57  self-patched: NOP while the search is live, $60 (RTS) once the recycler has been spotted
        lda     playerSide              ; 3F58  the local player's side (0 or 1)
        eor     #$01                    ; 3F5B  the other side
        tax                             ; 3F5D  X = opponent side index
        sta     L_3F84+1                ; 3F5E  patch it into the LDY operand at $3F84 for the later drawRecyclerOnMap
        ldy     comcenUnitIndexBySide,x ; 3F61  Y = the opponent's comcen unit index ($31 or $63)
        jsr     setScanFilterOppositeSide; 3F64  set the area-scan filter to 'units of the side opposite to Y', i.e. our own units
        ldy     recyclerRow,x           ; 3F67  Y = the opponent's recycler row
        lda     recyclerCol,x           ; 3F6A  A = its column; bit 7 set = he has no recycler
        bmi     L_3F96                  ; 3F6D  nothing to spot
        tax                             ; 3F6F  X = column (collectUnitsInRadius wants column in X, row in Y)
        lda     #$03                    ; 3F70  search radius 3 cells around the recycler
        jsr     collectUnitsInRadius    ; 3F72  collect our units in that square into $0400 (index) / $0432 (distance)
        bmi     L_3F96                  ; 3F75  X = $FF: no unit of ours is near the recycler

; ----------------------------------------------------------------------
; Walk the candidate list backwards looking for one robot that is not cloaked - a cloaked robot cannot
; report what it sees.
; ----------------------------------------------------------------------
recyclerSpotterLoop:
        ldy     checksumXorConstant,x   ; 3F77  candidate unit index from the scan list at $0400+X (the label is a leftover from the copy-protection table that shares this address)  (also trainerPlaybook0200:play3Col12)
        lda     unitPathColTable,y      ; 3F7A  path column byte; bit 7 set = the unit is cloaked
        bpl     L_3F84                  ; 3F7D  uncloaked: the recycler has been spotted
        dex                             ; 3F7F  next candidate
        bpl     recyclerSpotterLoop     ; 3F80  until the list is exhausted
        bmi     L_3F96                  ; 3F82  all nearby units are cloaked - try again next round
L_3F84:
        ldy     #$FF                    ; 3F84  operand patched at $3F5E: Y = the opponent's side index
        jsr     drawRecyclerOnMap       ; 3F86  put the recycler glyph into the map and return the cell in X/Y/A
        jsr     drawMapCell             ; 3F89  redraw that cell in the overview map and in the tactical view
        lda     #$08                    ; 3F8C  message 8 = 'SPOTTED RECYCLER!'
        jsr     queueMessageUnlessFilm  ; 3F8E  queue it (silent while a film is playing)
        lda     #$60                    ; 3F91  $60 = RTS opcode
        sta     checkEnemyRecyclerSpotted; 3F93  disable this routine permanently: the recycler stays visible from now on
L_3F96:
        rts                             ; 3F96  return


; ----------------------------------------------------------------------
; initialFacingForUnit - Returns the facing a unit starts with: 0 (up, towards the enemy) for the
; local player's units and 1 (down) for the opponent's, since each player sees his own side at the
; bottom of the map.
; In:  X = unit index; the branch opcode at D_3F9E is patched by overlay A ($7BD5) to BCC when the
;     player is side 0 and BCS when he is side 1, so 'mine' means index < 50 or index >= 50
;     respectively.
; Out: A = 0 or 1 (facing code, bits 0-1 of unitFlagsTable).
; Called from: initUnitRecord ($3DCB) only.
; ----------------------------------------------------------------------
initialFacingForUnit:
        lda     playerSide              ; 3F97  dead instruction: the value is thrown away by the LDA #$00 that follows (leftover of an earlier version that read the side here)
        lda     #$00                    ; 3F9A  assume facing 0 = up
        cpx     #$32                    ; 3F9C  unit index against 50, the boundary between the two sides
initialFacingSideBranch:
        bcc     L_3FA2                  ; 3F9E  opcode byte patched per player side by overlay A ($7BD5): BCC for side 0, BCS for side 1
        eor     #$01                    ; 3FA0  the opponent's units face the other way (facing 1 = down)
L_3FA2:
        rts                             ; 3FA2  return the facing code in A


; ----------------------------------------------------------------------
; mirrorMapCoordinate - Returns 39 - A, mirroring a map column or row across the 40x40 battlefield.
; Both machines run the same simulation but each player looks at the board from his own end, so every
; coordinate that crosses the link is passed through here.
; In:  A = coordinate 0..39.
; Out: A = 39 - A; C reflects the internal negate, not a result.
; Called from: moveUnitToCell ($4785, zone scoring), the command handlers ($4FEA/$4FF1), overlay A
;     ($7013), the modem driver ($ECC9) and the solo trainer (six places).
; ----------------------------------------------------------------------
mirrorMapCoordinate:
        sec                             ; 3FA3  prepare the subtraction
        sbc     #$27                    ; 3FA4  A - 39
        eor     #$FF                    ; 3FA6  one's complement ...
        clc                             ; 3FA8  clear the carry the SEC/SBC above left set ...
        adc     #$01                    ; 3FA9  ... plus one = two's complement, so A = 39 - A
        rts                             ; 3FAB  return the mirrored coordinate


; ----------------------------------------------------------------------
; haltStunnedUnit - Cancels the orders of a unit that is stunned (a drone hit stuns everything around
; it): its waypoint and destination are snapped back to the cell it stands on, it is marked stationary
; and it is dropped out of its group so the group does not wait for it.  With the UNIT MENUS rule off
; only the comcen can be stunned this way, and in a solo game the trainer's own units (index >= 50)
; are left alone.
; In:  Y = unit index; gameTypeOptions $0BA3 bit 3 = the UNIT MENUS option; comcenUnit $9236;
;     soloTrainerAtGameStart $92FE bit 7.
; Out: unitPathCol/Row = unitCol/Row, unitDestCol = col|$80 (stationary), unitDestRow = row,
;     unitFlagsTable bit 6 (group member) cleared, unitDisplayFlagsTable bit 6 cleared and bit 7
;     (stationary) set.
; Called from: processUnitStep at $4272 while the unit's stun counter is running, and from the
;     drone-screen overlay at $72DF.
; ----------------------------------------------------------------------
haltStunnedUnit:
        lda     gameTypeOptions         ; 3FAC  the rule options of this game
        and     #$08                    ; 3FAF  bit 3 = UNIT MENUS (cloak / blitz / dig in available)
        bne     L_3FB8                  ; 3FB1  with the advanced commands on, every stunned unit is halted
        cpy     comcenUnit              ; 3FB3  with them off only the local comcen reacts to a stun ...
        bne     L_3FE7                  ; 3FB6  ... every other unit keeps its orders
L_3FB8:
        lda     soloTrainerAtGameStart  ; 3FB8  bit 7 = this game was started against the solo trainer
        bpl     L_3FC1                  ; 3FBB  modem game: halt either side's units
        cpy     #$32                    ; 3FBD  units 50-99 belong to the trainer ...
        bcs     L_3FE7                  ; 3FBF  ... and it moves them itself, so leave them alone
L_3FC1:
        lda     unitColTable,y          ; 3FC1  the cell the unit stands on
        sta     unitPathColTable,y      ; 3FC4  waypoint/path column = current column (cloak bits cleared with it)
        ora     #$80                    ; 3FC7  bit 7 = at destination / stationary
        sta     unitDestColTable,y      ; 3FC9  destination column = where it stands
        lda     unitRowTable,y          ; 3FCC  current row
        sta     unitPathRowTable,y      ; 3FCF  waypoint/path row = current row (blitz bits cleared)
        sta     unitDestRowTable,y      ; 3FD2  destination row too - the unit has nowhere to go
        lda     unitFlagsTable,y        ; 3FD5  unit flags
        and     #$BF                    ; 3FD8  $BF clears bit 6 = 'member of a group'
        sta     unitFlagsTable,y        ; 3FDA  a stunned unit drops out of its group
        lda     unitDisplayFlagsTable,y ; 3FDD  the display copy of the flags
        and     #$BF                    ; 3FE0  clear its group-member bit as well ...
        ora     #$80                    ; 3FE2  ... and set bit 7 = stationary
        sta     unitDisplayFlagsTable,y ; 3FE4  store the display flags back
L_3FE7:
        rts                             ; 3FE7  return


; ----------------------------------------------------------------------
; haltAllUnitsAndReveal - Game-over clean-up: repairs the three comcen system status bytes, stops all
; 100 units (stationary, nothing fired, nothing pinned, waypoint snapped back onto the unit) and
; reveals the whole board by marking every unit as spotted.  Redraws the overview and the tactical
; view when the battlefield screen is up.
; In:  currentScreen $90FB (0 = battlefield).
; Out: comcenSystemStatus[1..3] = 0, unitDisplayFlagsTable bits 0-1 cleared and bit 7 set for all
;     units, unitPathCol/Row = unitCol/Row keeping the cloak/blitz bits, every unit's visibility bits
;     6-7 set by markAllUnitsVisible, screen redrawn.
; Called from: endGame ($402F) only.
; ----------------------------------------------------------------------
haltAllUnitsAndReveal:
        lda     #$00                    ; 3FE8  0 = system healthy ...
        sta     comcenBattleStatus      ; 3FEA  ... comcen system 1 (BATTLE)
        sta     comcenRadarStatus       ; 3FED  ... comcen system 2 (RADAR)
        sta     comcenDroneStatus       ; 3FF0  ... comcen system 3 (DRONE); the damage is irrelevant now
        ldy     #$63                    ; 3FF3  walk units 99 down to 0

; ----------------------------------------------------------------------
; Stop every unit and snap its waypoint back onto its real position.
; ----------------------------------------------------------------------
haltUnitLoop:
        lda     unitDisplayFlagsTable,y ; 3FF5  display flags of the unit
        and     #$FC                    ; 3FF8  $FC clears bit 0 (fired/reloading) and bit 1 (pinned)
        ora     #$80                    ; 3FFA  bit 7 = stationary
        sta     unitDisplayFlagsTable,y ; 3FFC  store them back
        lda     unitPathColTable,y      ; 3FFF  path column
        and     #$C0                    ; 4002  keep bits 6-7 = the cloak flags
        ora     unitColTable,y          ; 4004  merge the unit's real column in
        sta     unitPathColTable,y      ; 4007  the waypoint is now the unit's own cell
        lda     unitPathRowTable,y      ; 400A  path row
        and     #$C0                    ; 400D  keep bits 6-7 = the blitz flags
        ora     unitRowTable,y          ; 400F  merge the unit's real row in
        sta     unitPathRowTable,y      ; 4012  store the waypoint row
        dey                             ; 4015  next unit
        bpl     haltUnitLoop            ; 4016  until unit 0 has been done
        jsr     markAllUnitsVisible     ; 4018  set the visibility bits 6-7 of every unit's energy byte: the board is revealed
        lda     currentScreen           ; 401B  which screen is showing?
        bne     L_4026                  ; 401E  only the battlefield screen (0) needs redrawing
        jsr     drawOverviewMap         ; 4020  redraw the 40x40 overview map on the left
        jsr     drawViewport            ; 4023  redraw the 7x5 tactical view on the right
L_4026:
        rts                             ; 4026  return


; ----------------------------------------------------------------------
; endGame - Ends the battle once the caller has stored the reason in gameEndReason $0BA8 ($08 = the
; clock ran out, $A0/$C0 = one side's comcen was knocked out).  It drops any drone in flight, halts
; and reveals all units, works out and shows the result, and starts the countdown that takes the
; player back to the menu.
; In:  gameEndReason $0BA8; the two 16-bit score totals $92A2-$92A5 (read by computeGameResult).
; Out: droneControlFlags $920D = 0, droneArrivalFlags $92F9 = 0, all units halted and revealed, result
;     message queued, gameEndCountdown $92C9 = $78 (the IRQ bumps it every 8th frame; at $80
;     exitIfGameEnded leaves for the menu, i.e. about 64 frames later).
; Called from: applyHitToUnit at $3BD3 (comcen knocked out, via JMP) and finishRound at $460B (clock
;     ran out).
; ----------------------------------------------------------------------
endGame:
        lda     #$00                    ; 4027  clear the drone state ...
        sta     droneControlFlags       ; 4029  ... no drone is under control any more
        sta     droneArrivalFlags       ; 402C  ... and no drone is on its way in
        jsr     haltAllUnitsAndReveal   ; 402F  stop every unit and reveal the whole board
        jsr     computeGameResult       ; 4032  compare the scores, queue the WON/LOST/DRAW message and set gameOverFlags
        lda     #$78                    ; 4035  $78 = start value of the game-end countdown
        sta     gameEndCountdown        ; 4037  the IRQ counts it up every 8 frames; when it reaches $80 the game returns to the menu
        rts                             ; 403A  return


; ----------------------------------------------------------------------
; markComcenRevealed - Launching a radar sweep or a drone gives the launching comcen away: it sets
; that side's comcen-reveal timer to 6 rounds and forces the visibility bits of that comcen so the
; other player can see it on the map.  (The disassembler prints this routine as .byte because it is
; only reached through the command dispatch table.)
; In:  X = 0 for our own launch, non-zero for the opponent's; playerSide $0B9F; perSideValueTable
;     $0C8A = comcen index per side ($31/$63).
; Out: comcenRevealTimer[side] $92B2 = 6, unitEnergyTable[that comcen] bits 6-7 set (spotted +
;     visible); for the opponent's launch the comcen is also queued for redraw in pendingRedrawUnit.
;     A and Y clobbered.
; Called from: the radar command handler ($53BC) and the drone command handler ($53F1).
; ----------------------------------------------------------------------
markComcenRevealed:
        lda     playerSide              ; 403B  the local player's side
        cpx     #$00                    ; 403E  X = 0 means we launched
        beq     L_4044                  ; 4040  our own side then
        eor     #$01                    ; 4042  otherwise the opponent's side
L_4044:
        tay                             ; 4044  Y = the side whose comcen is exposed
        lda     #$06                    ; 4045  6 rounds of exposure
        sta     comcenRevealTimer,y     ; 4047  $92B2+side, ticked down once per round by finishRound
        lda     comcenUnitIndexBySide,y ; 404A  comcen unit index of that side ($31 = 49 or $63 = 99)
        tay                             ; 404D  Y = the comcen unit
        lda     unitEnergyTable,y       ; 404E  its energy byte
        ora     #$C0                    ; 4051  bits 6-7 = seen this cycle + visible
        sta     unitEnergyTable,y       ; 4053  the comcen is now drawn on the enemy's map
        cpx     #$00                    ; 4056  was this the opponent's launch?
        beq     L_405D                  ; 4058  our own comcen is already drawn, nothing to redraw
        sty     pendingRedrawUnit       ; 405A  queue the opponent's comcen for a redraw next frame
L_405D:
        rts                             ; 405D  return


; terrainMoveCostTable - movement points charged for one step, indexed by the terrain class 0-7 that
; tryStepTowardTarget derived from the cell the unit is standing on (class table at $077E), or by the
; group's worst terrain class for a grouped unit. Spies always use class 0.  A diagonal step costs 8
; points more. 20, 65, 30, 40, 20, 40, 20, 20 - class 1 (hills/water) is by far the most expensive.
; Read by tryStepTowardTarget ($45DA) and by rebuildGroupTables ($493A). Note the $077E class table
; can return 8, which reads one byte past the end into speedClassRateTable[0] = 12.
terrainMoveCostTable:
        .byte   $14,$41,$1E,$28,$14,$28,$14,$14; 405E  .A.(.(..  cost per step for terrain classes 0-7: 20,65,30,40,20,40,20,20

; speedClassRateTable - movement points handed out at the end of each round, indexed by the unit type
; for types 0-3 (GRUNT 12, RIDER 25, BOOMER 12, SPY 25); for a comcen (type 4) the index is that
; side's COMCEN SPEED setting ($92B4/$92B5) instead, and for a grouped unit it is the group's slowest
; class (groupStatusTable bits 0-2). Entry 4 = 0 means an immobile comcen. Read by finishRound ($46A4)
; and rebuildGroupTables ($4958).
speedClassRateTable:
        .byte   $0C,$19,$0C,$19,$00     ; 4066  .....  points per round by speed class 0-4: 12,25,12,25,0

; zoneScoredBitTable - the unitTypeTable bit that records 'this unit has already scored the terrain
; points of that zone': [1] = $08 for the near zone (mirrored column 10-19), [2] = $10 for the far
; zone (column 0-9).  Entry 0 ($28) is never indexed - the zone number computed in moveUnitToCell is
; only ever 1 or 2. Read by moveUnitToCell ($479A and $47DE).
zoneScoredBitTable:
        .byte   $28,$08,$10             ; 406B  (..  index 0 unused, 1 = near zone bit $08, 2 = far zone bit $10

; unitMovedFlag - a single byte, not a table: set to 1 by moveUnitToCell when it really moved the
; current unit, cleared at the top of every unit step.  processUnitStep reads it at $436F to decide
; whether the cell the unit came from has to be redrawn.
unitMovedFlag:
        .byte   $00                     ; 406E  .  1 = the current unit changed map cells during this step

; ----------------------------------------------------------------------
; waitFramesOrInput - Interruptible pause that keeps the battle running: it advances the game one
; frame at a time for up to A frames and returns early on a fresh fire-button press, on the joystick
; leaving its centre position, or when the game-end countdown starts.
; In:  A = number of frames to wait; Y = the value to hand back in Y (parked in the LDY operand at
;     L_4091+1).
; Out: Y = the entry value, A/X clobbered, the game advanced by however many frames actually ran.  The
;     remaining frame count lives in the LDA operand at L_4085+1.
; Called from: the menu/console loops at $63C5, $6457 and $64C5.
; ----------------------------------------------------------------------
waitFramesOrInput:
        sty     L_4091+1                ; 406F  remember the caller's Y in the LDY operand at $4091 so it survives the wait

; ----------------------------------------------------------------------
; One frame per pass; the remaining count is kept in the LDA operand at $4085.
; ----------------------------------------------------------------------
waitFrameLoop:
        sta     L_4085+1                ; 4072  store the frames still to wait into the LDA operand at $4085
        lda     #$01                    ; 4075  advance the game by exactly one frame ...
        jsr     runGameFrames           ; 4077  ... so the real-time battle continues during the pause
        jsr     isFireHeld              ; 407A  returns A = 0 while the fire button is held (after having been released once)
        beq     L_4091                  ; 407D  button down: stop waiting
        lda     joyDirection            ; 407F  joystick direction nibble, active low
        cmp     #$0F                    ; 4081  $0F = centred
        bne     L_4091                  ; 4083  joystick pushed: stop waiting
L_4085:
        lda     #$FF                    ; 4085  operand patched at $4072: frames still to wait
        sec                             ; 4087  prepare the subtraction ...
        sbc     #$01                    ; 4088  one frame gone
        beq     L_4091                  ; 408A  the whole wait has elapsed
        ldy     gameEndCountdown        ; 408C  non-zero once the game has ended and the menu countdown runs
        beq     waitFrameLoop           ; 408F  keep waiting while the game is still live
L_4091:
        ldy     #$FF                    ; 4091  operand patched at $406F: restore the caller's Y
        rts                             ; 4093  return


; ----------------------------------------------------------------------
; runGameFrames - Advances the game by A frames: each pass waits for one raster frame and then runs
; one updateGameFrame.  Stops early once the game-end countdown has started; A = 0 returns at once.
; This is the routine every wait loop in the game and in both overlays uses so that the real-time
; battle keeps ticking while the user interface is busy.
; In:  A = frame count; Y is preserved through the operand at L_40AF+1.
; Out: Y = the entry value, A/X clobbered, the game state advanced.
; Called from: 7 places in the main program ($17FA, $181F, $19B6, $3D1B, $3D33, $4077, $648A) and 9 in
;     the $6F00 overlays.
; ----------------------------------------------------------------------
runGameFrames:
        sty     L_40AF+1                ; 4094  park the caller's Y in the LDY operand at $40AF
        cmp     #$00                    ; 4097  zero frames requested?
        beq     L_40AF                  ; 4099  then there is nothing to do

; ----------------------------------------------------------------------
; One raster frame plus one engine update per pass.
; ----------------------------------------------------------------------
runOneFrameLoop:
        pha                             ; 409B  save the remaining count on the stack
        lda     #$01                    ; 409C  wait for exactly one frame ...
        jsr     waitFrames              ; 409E  ... by polling the raster register
        jsr     updateGameFrame         ; 40A1  run the per-frame game update
        pla                             ; 40A4  get the count back
        sec                             ; 40A5  prepare the subtraction ...
        sbc     #$01                    ; 40A6  one frame done
        beq     L_40AF                  ; 40A8  all requested frames have run
        ldy     gameEndCountdown        ; 40AA  non-zero once the game has ended
        beq     runOneFrameLoop         ; 40AD  otherwise keep running frames
L_40AF:
        ldy     #$FF                    ; 40AF  operand patched at $4094: restore the caller's Y
        rts                             ; 40B1  return


; ----------------------------------------------------------------------
; updateGameFrame - The heartbeat of the game, called once per frame from every main and
; user-interface loop.  In order it: (1) leaves for the menu if the game-end countdown has expired;
; (2) handles the film replay speed keys 1-9, or takes a short cut straight to the engine while the
; comcen knock-out blackout is running; (3) services the status line and the CLR/HOME key, which
; cycles a film's viewpoint through SHOW US / SHOW THEM / SHOW BOTH; (4) handles the RUN/STOP pause
; and switches off the shot-line and marker sprites when their timers expire; (5) performs the pending
; single-unit and whole-group redraws; (6) runs the engine - every stepIntervalFrames frames one unit
; of the round order is stepped, every tenth unit the queued and received commands are exchanged and
; applied, and after all 100 units the round is finished.
; In:  filmPlaybackMode $0B9B, comcenStunStatus $922A bit 6, lastTypedKey $9248, currentScreen $90FB,
;     gamePhase zp_B1, stepDelayTimer $90FC, stepIntervalFrames $92E3, unitOrderIndex $9162,
;     unitProcessOrder $90FD, the timers $9234/$91C8/$923E/$92C8 and the redraw requests $923A/$923B.
; Out: one unit step performed (processUnitStep) or the round finished (finishRound); map, sprites,
;     status line and message queue updated; stepDelayTimer reloaded.  May not return at all:
;     exitIfGameEnded can jump to the menu, and handlePauseKey discards the return address while the
;     game is paused.
; Called from: 12 places in the main program (including runGameFrames $40A1), the $6F00 overlays and
;     the comcen blackout loop at $0737.
; ----------------------------------------------------------------------
updateGameFrame:
        jsr     exitIfGameEnded         ; 40B2  jumps to the menu (and never returns) once gameEndCountdown has reached $80
        lda     filmPlaybackMode        ; 40B5  bit 7 set = a game film is being replayed
        bmi     L_40C5                  ; 40B8  film: the digit keys set the replay speed
        bit     comcenStunStatus        ; 40BA  bit 6 = the comcen knock-out blackout at $0709 is running
        bvc     L_40EB                  ; 40BD  normal frame: do the whole update
        jsr     handlePauseKey          ; 40BF  blackout: only the RUN/STOP pause handler runs ...
        jmp     L_41BD                  ; 40C2  ... then go straight to the engine, leaving the screen frozen


; ----------------------------------------------------------------------
; Film replay: keys '1'-'9' set the step interval (1 = slowest, 9 = fastest) and show 'SPEED n'.
; ----------------------------------------------------------------------
L_40C5:
        lda     lastTypedKey            ; 40C5  the last key the IRQ picked up (PETSCII with bit 7 set)
        sec                             ; 40C8  prepare the subtraction ...
        sbc     #$B1                    ; 40C9  $B1 = '1'|$80, the first speed key
        bcc     L_40EB                  ; 40CB  below '1': not a speed key
        cmp     #$09                    ; 40CD  nine speed keys, '1' to '9'
        bcs     L_40EB                  ; 40CF  above '9': not a speed key
        sta     scratch18               ; 40D1  A = digit - 1, i.e. 0..8
        lda     #$08                    ; 40D3  8 = the slowest interval ...
        sec                             ; 40D5  prepare the subtraction ...
        sbc     scratch18               ; 40D6  interval = 8 - (digit-1) = 9 - digit ...
        sta     stepIntervalFrames      ; 40D8  ... so '1' waits 8 frames per unit step and '9' waits none
        lda     lastTypedKey            ; 40DB  the key code again
        sta     msgSpeedDigit           ; 40DE  poke it into the 'SPEED *' template at $8730; its bit 7 doubles as the string terminator
        lda     #$07                    ; 40E1  message 7 = 'SPEED n'
        jsr     queueMessage            ; 40E3  queue it (always, even in a film)
        lda     #$FF                    ; 40E6  $FF = no key ...
        sta     lastTypedKey            ; 40E8  ... so the digit is consumed and not seen again
L_40EB:
        jsr     updateStatusMessage     ; 40EB  service the status line / message queue once per frame
        lda     gamePhase               ; 40EE  0 only during the real-time battle
        bne     L_413D                  ; 40F0  no viewpoint switching outside the battle
        lda     currentScreen           ; 40F2  screen id, 0 = the battlefield
        bne     L_413D                  ; 40F5  the CLR/HOME key only works on the battlefield screen
        lda     lastTypedKey            ; 40F7  the last key pressed
        cmp     #$8C                    ; 40FA  $8C = CLR/HOME in the game's own key table
        bne     L_413D                  ; 40FC  some other key: nothing to do here
        lda     #$FF                    ; 40FE  $FF = no key ...
        sta     lastTypedKey            ; 4100  ... consume CLR/HOME
        lda     #$3C                    ; 4103  60 frames ...
        sta     inputLockoutTimer       ; 4105  ... of input lockout so the key does not repeat
        lda     filmPlaybackMode        ; 4108  bit 7 set = we are watching a film
        bpl     L_413D                  ; 410B  in a live game CLR/HOME only eats the keypress

; ----------------------------------------------------------------------
; Cycle the film viewpoint $80 (SHOW US) - $81 (SHOW THEM) - $82 (SHOW BOTH) and back.
; ----------------------------------------------------------------------
        clc                             ; 410D  clear the carry for the increment ...
        adc     #$01                    ; 410E  next viewpoint
        cmp     #$83                    ; 4110  past SHOW BOTH?
        bcc     L_4116                  ; 4112  no, keep it
        lda     #$80                    ; 4114  wrap round to $80 = SHOW US
L_4116:
        sta     filmPlaybackMode        ; 4116  store the new playback mode (bit 7 stays set)
        and     #$03                    ; 4119  bits 0-1 = the viewpoint number 0-2
        asl     a                       ; 411B  double ...
        asl     a                       ; 411C  times 4: the words in filmViewWordTable are 4 bytes each
        tay                             ; 411D  Y = offset of 'US  ' / 'THEM' / 'BOTH'
        ldx     #$00                    ; 411E  X = destination index

; ----------------------------------------------------------------------
; Copy the 4-byte viewpoint word into the 'SHOW ****' message template at $8726.
; ----------------------------------------------------------------------
copyFilmViewWordLoop:
        lda     filmViewWordTable,y     ; 4120  one character of the viewpoint word ($86FB in the resident overlay)
        sta     msgShowViewWord,x       ; 4123  into the four asterisks of message 6
        inx                             ; 4126  next destination byte
        iny                             ; 4127  next source byte
        cpx     #$04                    ; 4128  all four characters copied?
        bcc     copyFilmViewWordLoop    ; 412A  no, keep copying
        lda     #$06                    ; 412C  message 6 = 'SHOW US/THEM/BOTH'
        jsr     queueMessage            ; 412E  queue it
        jsr     updateStatusMessage     ; 4131  and print it immediately
        jsr     patchUnitSideBranches   ; 4134  re-patch the 'is this unit mine' branches for the new viewpoint
        jsr     drawOverviewMap         ; 4137  redraw the overview map from the new point of view
        jsr     drawViewport            ; 413A  and the tactical view
L_413D:
        jsr     handlePauseKey          ; 413D  RUN/STOP pause handling (may drop the return address and skip the rest of the frame)
        lda     currentScreen           ; 4140  screen id
        bne     L_4167                  ; 4143  the view/shot-line timers only matter on the battlefield screen
        lda     viewRedrawTimer         ; 4145  countdown before the tactical view is redrawn
        beq     L_415A                  ; 4148  not armed
        and     #$7F                    ; 414A  the IRQ stops counting at $80 ...
        bne     L_415A                  ; 414C  ... so only the exact value $80 means 'delay expired'
        ldx     viewOriginCol           ; 414E  X = left edge of the view
        ldy     viewOriginRow           ; 4150  Y = top edge of the view
        jsr     drawViewport            ; 4152  redraw the 7x5 tactical view
        lda     #$00                    ; 4155  disarm the timer ...
        sta     viewRedrawTimer         ; 4157  ... until something asks for another delayed redraw
L_415A:
        lda     shotLineTimer           ; 415A  countdown of the shot-line sprite
        bne     L_4167                  ; 415D  still visible
        lda     spriteEnableShadow      ; 415F  sprite enable shadow
        and     #$F7                    ; 4162  $F7 clears bit 3 = sprite 3, the shot / path line
        sta     spriteEnableShadow      ; 4164  the line disappears when its timer runs out
L_4167:
        lda     currentScreen           ; 4167  screen id
        and     #$01                    ; 416A  only even screen ids (battlefield and the missile screen) use the marker sprite
        bne     L_417B                  ; 416C  odd screen: leave the sprites alone
        lda     markerSpriteTimer       ; 416E  countdown of the marker sprite
        bne     L_417B                  ; 4171  still running
        lda     spriteEnableShadow      ; 4173  sprite enable shadow
        and     #$EF                    ; 4176  $EF clears bit 4 = sprite 4, the repair/target marker
        sta     spriteEnableShadow      ; 4178  switch the marker off when its timer expires

; ----------------------------------------------------------------------
; Deferred redraws: one single unit and one whole group can be queued from anywhere.
; ----------------------------------------------------------------------
L_417B:
        ldy     pendingRedrawUnit       ; 417B  unit queued for a redraw ($FF = none)
        bmi     L_418D                  ; 417E  nothing queued
        lda     unitTerrainUnderTable,y ; 4180  terrain under that unit; $FF = it is not on the map
        bmi     L_4188                  ; 4183  a unit that is off the map cannot be drawn
        jsr     drawUnitAtPosition      ; 4185  draw the unit in the overview and in the tactical view
L_4188:
        lda     #$FF                    ; 4188  $FF = nothing queued ...
        sta     pendingRedrawUnit       ; 418A  ... the request has been serviced
L_418D:
        lda     pendingRedrawGroup      ; 418D  group key queued for a redraw ($FF = none)
        bmi     L_41BD                  ; 4190  nothing queued: go on to the engine
        ora     #$40                    ; 4192  bit 6 = 'is a group member', so the value matches unitFlagsTable & $7C
        sta     pendingRedrawGroup      ; 4194  store the completed group key back
        lda     #$63                    ; 4197  walk units 99 ...
        sta     scratch18               ; 4199  ... down to 0

; ----------------------------------------------------------------------
; Redraw every living unit that belongs to the queued group.
; ----------------------------------------------------------------------
groupRedrawLoop:
        ldy     scratch18               ; 419B  Y = unit index
        lda     unitTypeTable,y         ; 419D  type byte
        bmi     L_41B4                  ; 41A0  bit 7 set = dead or unused slot
        lda     unitFlagsTable,y        ; 41A2  unit flags
        and     #$7C                    ; 41A5  $7C = group id bits 2-4, side bit 5, member bit 6
        cmp     pendingRedrawGroup      ; 41A7  does this unit belong to the queued group?
        bne     L_41B4                  ; 41AA  no
        lda     unitTerrainUnderTable,y ; 41AC  terrain under the unit
        bmi     L_41B4                  ; 41AF  $FF = the unit is not on the map
        jsr     drawUnitAtPosition      ; 41B1  redraw it
L_41B4:
        dec     scratch18               ; 41B4  next unit
        bpl     groupRedrawLoop         ; 41B6  until unit 0 has been checked
        lda     #$FF                    ; 41B8  $FF = nothing queued ...
        sta     pendingRedrawGroup      ; 41BA  ... the group redraw is done

; ----------------------------------------------------------------------
; The engine proper: one unit of the round order is stepped every stepIntervalFrames frames.
; ----------------------------------------------------------------------
L_41BD:
        lda     stepDelayTimer          ; 41BD  frames still to wait before the next unit step (counted down by the IRQ)
        bne     L_420F                  ; 41C0  not time yet - nothing more to do this frame
        lda     screenShakeTimer        ; 41C2  screen-shake counter, set when the comcen is hit
        beq     L_41CD                  ; 41C5  no shake running
        jsr     shakeScreenScroll       ; 41C7  jitter the fine scroll registers by a random amount
        dec     screenShakeTimer        ; 41CA  one shake frame used up
L_41CD:
        lda     stepIntervalFrames      ; 41CD  frames between unit steps (film speed keys change this)
        ldx     #$0A                    ; 41D0  10 units between command exchanges during the battle
        ldy     gamePhase               ; 41D2  0 = the battle is running
        beq     L_41DA                  ; 41D4  battle: keep both values
        ldx     #$32                    ; 41D6  setup phase: exchange commands only every 50 units ...
        lda     #$00                    ; 41D8  ... and run the unit steps as fast as possible
L_41DA:
        sta     stepDelayTimer          ; 41DA  reload the inter-step delay
        lda     unitOrderIndex          ; 41DD  position in the round order, 0-99
        beq     L_41FB                  ; 41E0  index 0 needs no exchange (finishRound has just run one)
        ldy     #$00                    ; 41E2  high byte of the dividend = 0
        jsr     divideSigned16by8       ; 41E4  A = index / X, Y = index mod X (X is 10, or 50 in the setup phase)
        cpy     #$00                    ; 41E7  remainder zero?
        bne     L_41FB                  ; 41E9  not on a time-slice boundary - step the unit without an exchange
        sta     commandSliceIndex       ; 41EB  the time slice number (0-9) that identifies this exchange in the film
        jsr     processCommandExchange  ; 41EE  swap packets with the opponent and execute both sides' commands
        lda     linkTimeoutCounter      ; 41F1  non-zero = the link watchdog is still waiting for the opponent's packet
        bne     L_420A                  ; 41F4  the opponent is late: postpone the step and try again next frame
        lda     #$80                    ; 41F6  bit 7 = the commands of this slice have been applied ...
        sta     exchangeAppliedFlag     ; 41F8  ... which the setup and console screens poll
L_41FB:
        jsr     updateHitFlashTimers    ; 41FB  age the per-unit hit-flash timers and redraw the cells whose flash ended
        ldy     unitOrderIndex          ; 41FE  position in the round order
        ldx     unitProcessOrder,y      ; 4201  X = the unit to process, taken from the shuffled round order
        cpy     #$64                    ; 4204  100 units make one round
        bcc     processUnitStep         ; 4206  still units left: step this one (falls into processUnitStep, not a JSR)
        bcs     L_4210                  ; 4208  all 100 done: finish the round
L_420A:
        lda     #$00                    ; 420A  opponent not ready: clear the delay ...
        sta     stepDelayTimer          ; 420C  ... so the exchange is retried on the very next frame
L_420F:
        rts                             ; 420F  return

L_4210:
        jmp     finishRound             ; 4210  tick the game clock, hand out movement points and rebuild the round order


; ----------------------------------------------------------------------
; processUnitStep - Processes exactly one unit of the round order; this is an internal entry of
; updateGameFrame reached by the branch at $4206, not by a JSR, and it ends by falling into the shared
; tail at L_4537 which bumps unitOrderIndex.  Broadly it: shuffles two entries of the base order with
; the lock-step RNG so both machines agree; skips dead units and everything once the game is over;
; counts a stunned unit's stun down and halts it; turns a moving unit toward its destination; realises
; a unit whose path position has run ahead of its map position by walking back to the first free cell;
; takes one movement step with tryStepTowardTarget, paying terrain and blitz costs; redraws the cell
; it vacated; keeps the tactical view on a directed unit; and then (past this chunk, from $43A4) spots
; enemies, picks a target, fires, digs in or repairs.
; In:  X = unit index taken from unitProcessOrder[unitOrderIndex]; the unit record arrays $F640-$FBB7;
;     gamePhase zp_B1; gameEndReason $0BA8; gameOverFlags $923F.
; Out: the unit's position, path position, movement points, flags and energy updated; map cells and
;     the screen redrawn; currentUnit $90FA, scanUnitType $9228, scanUnitTerrain $9227,
;     currentUnitCloakBits $922C, currentUnitCol/Row $91DA/$91DB set for the helper routines;
;     exchangeActivityFlag $92C2 set when the unit really moved.
; Called from: updateGameFrame at $4206 (BCC), once per unit step.
; ----------------------------------------------------------------------
processUnitStep:
        stx     currentUnit             ; 4213  currentUnit is the unit index every helper in this routine reads
        lda     gameOverFlags           ; 4216  $C0 once the game is over (set by computeGameResult)
        bpl     L_421E                  ; 4219  game still running: process the unit
L_421B:
        jmp     endUnitStep             ; 421B  everything frozen: just advance the round order

L_421E:
        lda     gamePhase               ; 421E  0 = the real-time battle
        beq     L_4229                  ; 4220  battle: shuffle the round order
        lda     #$50                    ; 4222  setup phase: 80 movement points ...
        sta     unitMovePointsTable,x   ; 4224  ... so units reach their placement instantly
        bne     L_422C                  ; 4227  always taken ($50 is not zero): skip the shuffle in the setup phase
L_4229:
        jsr     shuffleUnitOrder        ; 4229  swap two random entries of the base order, using the lock-step RNG so both machines shuffle identically
L_422C:
        ldx     currentUnit             ; 422C  X = the unit being processed
        lda     unitTypeTable,x         ; 422F  type byte
        bmi     L_421B                  ; 4232  bit 7 set = destroyed or an empty slot: nothing to do
        and     #$07                    ; 4234  bits 0-2 = the type 0 GRUNT, 1 RIDER, 2 BOOMER, 3 SPY, 4 COMCEN
        sta     scanUnitType            ; 4236  the sight/attack scans and the scoring code read it from here
        lda     #$00                    ; 4239  clear the 'this unit changed cells' flag ...
        sta     unitMovedFlag           ; 423B  ... moveUnitToCell sets it again if the unit really moves
        lda     unitPathColTable,x      ; 423E  path column byte
        and     #$C0                    ; 4241  bits 6-7 = the cloak flags
        sta     currentUnitCloakBits    ; 4243  kept aside so they survive the path-column rewrite at $4350
        lda     unitTerrainUnderTable,x ; 4246  the terrain byte hidden under the unit
        sta     scanUnitTerrain         ; 4249  used for the sight bonus, the move cost and to redraw the vacated cell
        lda     gameEndReason           ; 424C  non-zero once the battle has ended
        bne     L_4275                  ; 424F  game over: skip movement and combat, go to the tail
        lda     unitStunTable,x         ; 4251  stun / dig-in byte
        and     #$0F                    ; 4254  low nibble = rounds of stun left
        beq     L_4278                  ; 4256  not stunned: normal processing

; ----------------------------------------------------------------------
; Stunned unit: count one round of stun off, keep the dig-in nibble, and cancel its orders.
; ----------------------------------------------------------------------
        tay                             ; 4258  keep the stun count in Y
        lda     unitStunTable,x         ; 4259  read the byte again ...
        and     #$F0                    ; 425C  ... and isolate the dig-in progress in bits 4-7
        sta     L_4265+1                ; 425E  patch it into the ORA operand at $4265 so it survives the decrement
        tya                             ; 4261  A = stun count
        sec                             ; 4262  prepare the subtraction ...
        sbc     #$01                    ; 4263  one round of stun served
L_4265:
        ora     #$FF                    ; 4265  operand patched at $425E: put the dig-in nibble back
        sta     unitStunTable,x         ; 4267  store the new stun/dig-in byte
        lda     #$00                    ; 426A  a stunned unit gets no movement points ...
        sta     unitMovePointsTable,x   ; 426C  ... so it cannot move even if it had orders
        ldy     currentUnit             ; 426F  Y = unit index
        jsr     haltStunnedUnit         ; 4272  cancel its orders and drop it out of its group
L_4275:
        jmp     unitStepSkipFiring      ; 4275  skip movement and combat for this unit


; ----------------------------------------------------------------------
; Moving unit: point it at its destination so the facing bits and the scans agree.
; ----------------------------------------------------------------------
L_4278:
        jsr     updateIdleFlag          ; 4278  recompute bit 7 of unitDestCol: set when the destination equals the unit's cell
        bmi     L_42A0                  ; 427B  already at its destination: no facing update needed
        ldx     currentUnit             ; 427D  X = unit index
        lda     unitDestColTable,x      ; 4280  destination column ...
        and     #$3F                    ; 4283  ... without the stationary bit
        sta     lineEndCol              ; 4285  target column of the line walker
        lda     unitDestRowTable,x      ; 4287  destination row ...
        and     #$3F                    ; 428A  ... without the repairing bit
        sta     lineEndRow              ; 428C  target row of the line walker
        jsr     initLineFromUnit        ; 428E  set the line walker up from the unit's cell; A = principal direction 0-3
        sta     scratch18               ; 4291  keep the direction
        ldx     currentUnit             ; 4293  X = unit index
        lda     unitFlagsTable,x        ; 4296  unit flags
        and     #$FC                    ; 4299  $FC clears the facing bits 0-1
        ora     scratch18               ; 429B  insert the new facing
        sta     unitFlagsTable,x        ; 429D  the unit now faces the way it is walking
L_42A0:
        jsr     updateComcenMoveSound   ; 42A0  start or stop the local comcen's engine sound to match its movement
        ldx     currentUnit             ; 42A3  X = unit index
        lda     unitColTable,x          ; 42A6  the column the unit is standing on ...
        sta     lineEndCol              ; 42A9  ... as the line walker's target ...
        sta     currentUnitCol          ; 42AB  ... and remembered so the vacated cell can be redrawn at $4377
        lda     unitRowTable,x          ; 42AE  the row it is standing on ...
        sta     lineEndRow              ; 42B1  ... as the line walker's target row ...
        sta     currentUnitRow          ; 42B3  ... and remembered for the redraw
        lda     unitDestRowTable,x      ; 42B6  destination row ...
        and     #$3F                    ; 42B9  ... without the repairing bit
        cmp     lineEndRow              ; 42BB  is the unit already on its destination row?
        bne     L_42CB                  ; 42BD  no: it still has somewhere to go
        lda     unitDestColTable,x      ; 42BF  destination column ...
        and     #$3F                    ; 42C2  ... without the stationary bit
        cmp     lineEndCol              ; 42C4  and on its destination column?
        bne     L_42CB                  ; 42C6  no: it still has somewhere to go
        jmp     L_436F                  ; 42C8  the unit has arrived: no movement this step


; ----------------------------------------------------------------------
; A unit may step onto an occupied cell: then only its path position (F898/F8FC) moves while its map
; position stays behind.  Here the map position is dragged forward again, one cell at a time, to the
; first free cell on the way to the path position.
; ----------------------------------------------------------------------
L_42CB:
        lda     unitPathRowTable,x      ; 42CB  path row ...
        and     #$3F                    ; 42CE  ... without the blitz bits ...
        sta     lineCurRow              ; 42D0  ... as the line walker's current row
        lda     unitPathColTable,x      ; 42D2  path column ...
        and     #$3F                    ; 42D5  ... without the cloak bits ...
        sta     lineCurCol              ; 42D7  ... as the line walker's current column
        cmp     lineEndCol              ; 42D9  path column against the unit's real column
        bne     walkBackToFreeCellLoop  ; 42DB  different: the unit is out of step with its path
        lda     lineCurRow              ; 42DD  compare the rows too
        cmp     lineEndRow              ; 42DF  compare the path row with the unit's real row
        beq     L_42FB                  ; 42E1  path position equals map position: nothing to catch up

; ----------------------------------------------------------------------
; Walk from the path position back toward the map position until a free cell turns up.
; ----------------------------------------------------------------------
walkBackToFreeCellLoop:
        ldx     lineCurCol              ; 42E3  X = column of the cell being tested
        ldy     lineCurRow              ; 42E5  Y = its row
        jsr     readMapCell             ; 42E7  A = the cell contents, mapCellPtr = its address
        bpl     L_42F6                  ; 42EA  bit 7 clear: this cell is free, the unit can materialise here
        jsr     initLineStepper         ; 42EC  re-aim the walker from this cell at the unit's real cell
        jsr     stepLine                ; 42EF  step one cell closer
        bne     walkBackToFreeCellLoop  ; 42F2  still cells left to try
        beq     L_42FB                  ; 42F4  arrived at the unit's own cell without finding a gap
L_42F6:
        sta     scratch19               ; 42F6  keep the terrain byte of the free cell for moveUnitToCell
        jsr     moveUnitToCell          ; 42F8  move the unit's map position into that cell

; ----------------------------------------------------------------------
; Now take one real step from the path position toward the destination.
; ----------------------------------------------------------------------
L_42FB:
        ldy     currentUnit             ; 42FB  Y = unit index
        lda     unitDestColTable,y      ; 42FE  destination column (the stationary bit is clear here, we tested it above) ...
        sta     lineEndCol              ; 4301  ... is the walker's target column
        lda     unitDestRowTable,y      ; 4303  destination row ...
        sta     lineEndRow              ; 4306  ... is the walker's target row
        lda     unitPathColTable,y      ; 4308  path column ...
        and     #$3F                    ; 430B  ... without the cloak bits ...
        sta     lineCurCol              ; 430D  ... is where the step starts
        lda     unitPathRowTable,y      ; 430F  path row ...
        and     #$3F                    ; 4312  ... without the blitz bits ...
        sta     lineCurRow              ; 4314  ... is where the step starts
        jsr     tryStepTowardTarget     ; 4316  try one step: C=1 and A = points left if it is allowed, C=0 if not
        bcs     L_4329                  ; 4319  step accepted
        txa                             ; 431B  X = $FF when the cell was blocked or off the map, = the unit index when the points ran out
        bpl     L_4326                  ; 431C  not enough movement points: just wait for the next round
        ldx     currentUnit             ; 431E  blocked: X = unit index again
        lda     #$0F                    ; 4321  park the balance at 15 points ...
        sta     unitMovePointsTable,x   ; 4323  ... so the unit retries soon instead of banking points
L_4326:
        jmp     L_436F                  ; 4326  no movement this step

L_4329:
        ldy     currentUnit             ; 4329  Y = unit index
        sta     unitMovePointsTable,y   ; 432C  store the balance left after paying for the step
        lda     gamePhase               ; 432F  0 = the real-time battle
        bne     L_433F                  ; 4331  no blitz costs during the setup phase
        lda     unitPathRowTable,y      ; 4333  path row bit 7 = blitz active
        bpl     L_433F                  ; 4336  not blitzing
        lda     scratch19               ; 4338  cell byte returned by tryStepTowardTarget; 0 = no step was actually taken
        beq     L_433F                  ; 433A  nothing moved, nothing to pay for
        jsr     applyBlitzStepCost      ; 433C  pay one energy point for +25 movement points and arm the charge bonus
L_433F:
        lda     scratch19               ; 433F  the cell the unit stepped into
        bmi     L_4348                  ; 4341  bit 7 set = another unit is standing there: only the path position advances
        beq     L_436F                  ; 4343  0 = no step taken at all
        jsr     moveUnitToCell          ; 4345  free cell: really move the unit and redraw both cells
L_4348:
        ldx     currentUnit             ; 4348  X = unit index
        lda     lineCurCol              ; 434B  new path column ...
        ora     currentUnitCloakBits    ; 434D  ... with the cloak bits put back ...
        sta     unitPathColTable,x      ; 4350  ... stored as the unit's new path position
        lda     unitPathRowTable,x      ; 4353  old path row
        and     #$C0                    ; 4356  keep bits 6-7 = the blitz flags
        ora     lineCurRow              ; 4358  merge the new row in
        sta     unitPathRowTable,x      ; 435A  store the new path row
        lda     #$01                    ; 435D  1 = something happened this turn ...
        sta     exchangeActivityFlag    ; 435F  ... which the setup phase uses to decide whether the clock ticks
        lda     lineStepsLeft           ; 4362  steps the line walker still has to go
        bne     L_436F                  ; 4364  the unit is still on its way
        lda     scratch19               ; 4366  it has reached its destination cell - is that cell occupied?
        bpl     L_436F                  ; 4368  no, the unit is really there
        lda     #$64                    ; 436A  100 movement points ...
        sta     unitMovePointsTable,x   ; 436C  ... so a unit blocked on its own destination keeps pushing every round

; ----------------------------------------------------------------------
; Common tail: repaint the cell the unit left, keep the view on it, refresh its flags.
; ----------------------------------------------------------------------
L_436F:
        lda     unitMovedFlag           ; 436F  did moveUnitToCell actually move the unit?
        beq     L_4380                  ; 4372  no: the old cell still shows the unit
        ldy     currentUnitRow          ; 4374  Y = the row it started the step on
        ldx     currentUnitCol          ; 4377  X = the column it started on
        lda     scanUnitTerrain         ; 437A  A = the terrain that was hidden under it
        jsr     drawMapCell             ; 437D  repaint that cell in the overview map and in the tactical view
L_4380:
        jsr     followDirectedUnit      ; 4380  if this unit is the one being directed, scroll the view onto it
        jsr     updateIdleFlag          ; 4383  recompute the stationary bit; returns A = unitDestCol byte, Y = currentUnit
        and     #$80                    ; 4386  keep bit 7 = stationary
        sta     L_4390+1                ; 4388  patch it into the ORA operand at $4390
        lda     unitDisplayFlagsTable,y ; 438B  the display copy of the flags
        and     #$7F                    ; 438E  clear its bit 7
L_4390:
        ora     #$FF                    ; 4390  operand patched at $4388: the fresh stationary bit
        sta     unitDisplayFlagsTable,y ; 4392  so the console shows MOVE or READY correctly
        lda     unitFlagsTable,y        ; 4395  unit flags
        and     #$03                    ; 4398  bits 0-1 = the direction the unit faces
        sta     targetDirection         ; 439A  the sight and attack scans prefer targets in this direction
        lda     unitPathColTable,y      ; 439C  path column bit 7 = the unit is cloaked
        bpl     spotEnemiesInSight      ; 439F  not cloaked: go on to spot and fire (next chunk, from $43A4)
L_43A1:
        jmp     unitStepSkipFiring      ; 43A1  a cloaked unit neither spots nor fires: jump to the tail


; ----------------------------------------------------------------------
; Spotting pass (part of processUnitStep, which starts at $4213).  Only a unit that is not cloaked and
; only a running battle gets here: it looks around with scanVisibleEnemies in sight mode and makes
; every enemy it can see visible.  The three parallel 50-entry scan lists live in the shared
; $0400 scratch page: $0400 = unit index ($FF = candidate rejected), $0432 = squared distance,
; $0464 = direction 0-3.  (The $0400 label reads 'checksumXorConstant' because the boot checksum
; constant lives at the same address; at run time this page is pure scratch.)
; ----------------------------------------------------------------------
spotEnemiesInSight:
        lda     gamePhase               ; 43A4  zp_B1 = 0 only while the battle is actually running
        bne     L_43A1                  ; 43A6  setup / film / menu phase: nobody spots or fires, go to the no-fire path
        ldx     #$00                    ; 43A8  X = 0 selects the sight-range pass (see, do not shoot)
        jsr     scanVisibleEnemies      ; 43AA  Y is still currentUnit: fill $0400/$0432/$0464 with the enemies in sight
        ldx     scanResultCount         ; 43AD  zp_2F = index of the last list entry ($FF = nothing in sight)
        stx     scratch18               ; 43AF  walk the list backwards from that index
        bmi     chooseFireTarget        ; 43B1  branch if nothing was spotted at all

; ----------------------------------------------------------------------
; For every candidate the scan kept: mark it seen this visibility cycle and, if it was not visible
; before, make it visible and draw it on the map.
; ----------------------------------------------------------------------
L_43B3:
        ldy     checksumXorConstant,x   ; 43B3  $0400+n = unit index of scan result n  (also trainerPlaybook0200:play3Col12)
        bmi     L_43CA                  ; 43B6  $FF = the scan rejected this candidate (out of range / no line of sight)
        lda     unitEnergyTable,y       ; 43B8  energy byte: bits 0-5 energy 0-50, bit 6 = seen this cycle, bit 7 = visible
        ora     #$40                    ; 43BB  bit 6: ageUnitVisibility will shift this into bit 7 at the end of the round
        sta     unitEnergyTable,y       ; 43BD  store the refreshed visibility
        bmi     L_43CA                  ; 43C0  bit 7 already set: the enemy was visible before, no redraw needed
        ora     #$80                    ; 43C2  newly spotted: make it visible right now
        sta     unitEnergyTable,y       ; 43C4  store it
        jsr     drawUnitAtPosition      ; 43C7  draw unit Y on the overview map and in the 7x5 view
L_43CA:
        dec     scratch18               ; 43CA  step down to the previous list entry
        ldx     scratch18               ; 43CC  reload the index for the LDY above
        bpl     L_43B3                  ; 43CE  loop until entry 0 has been handled

; ----------------------------------------------------------------------
; Target selection.  Boomers keep a target of their own (selectBoomerTarget); everybody else runs a
; second scan in weapon-range mode and picks the nearest candidate, except that an enemy comcen is
; always preferred over anything else.
; ----------------------------------------------------------------------
chooseFireTarget:
        ldy     currentUnit             ; 43D0  scanVisibleEnemies wants the scanning unit in Y again
        lda     scanUnitType            ; 43D3  $9228 = type of the unit being stepped (0 GRUNT 1 RIDER 2 BOOMER 3 SPY 4 COMCEN)
        cmp     #$02                    ; 43D6  BOOMER?
        beq     L_4425                  ; 43D8  boomers lock onto a target and keep it
        ldx     #$80                    ; 43DA  X bit 7 set = weapon-range pass (only enemies that can be shot at)
        jsr     scanVisibleEnemies      ; 43DC  rebuild the scan lists for firing
        ldx     scanResultCount         ; 43DF  index of the last candidate
        stx     scratch18               ; 43E1  loop counter
        bmi     unitStepSkipFiring      ; 43E3  nothing in weapon range: no shot this step
        lda     #$FF                    ; 43E5  $FF = 'none' for all three running best values
        sta     fireTargetDistance      ; 43E7  $91F1 = squared distance of the best target so far
        sta     fireTargetUnit          ; 43EA  $91F0 = the chosen target unit
        sta     scratch1B               ; 43ED  $1B = direction 0-3 toward the chosen target

; ----------------------------------------------------------------------
; Loop over the candidates, keeping the nearest one; an enemy comcen (unit 49 or 99) wins immediately
; and cuts the loop short.
; ----------------------------------------------------------------------
L_43EF:
        ldx     scratch18               ; 43EF  current candidate index
        lda     checksumXorConstant,x   ; 43F1  $0400+n = the candidate's unit index  (also trainerPlaybook0200:play3Col12)
        bmi     L_441A                  ; 43F4  $FF = rejected entry, skip it
        cmp     #$31                    ; 43F6  unit 49 = side 0 comcen
        beq     L_43FE                  ; 43F8  a comcen is taken whatever the distance
        cmp     #$63                    ; 43FA  unit 99 = side 1 comcen
        bne     L_4404                  ; 43FC  an ordinary unit has to win on distance
L_43FE:
        lda     #$00                    ; 43FE  comcen found
        sta     scratch18               ; 4400  force the loop to end after this entry (DEC makes it $FF)
        beq     L_440F                  ; 4402  always taken (A = 0): take this candidate
L_4404:
        lda     play3Row12,x            ; 4404  $0432+n = squared distance of this candidate
        cmp     fireTargetDistance      ; 4407  nearer than the best so far?
        bcs     L_441A                  ; 440A  no: keep the previous best
        sta     fireTargetDistance      ; 440C  new best distance
L_440F:
        lda     checksumXorConstant,x   ; 440F  take this candidate...  (also trainerPlaybook0200:play3Col12)
        sta     fireTargetUnit          ; 4412  ...as the unit to shoot at
        lda     play4Grp12,x            ; 4415  $0464+n = direction 0-3 from the firing unit to that enemy
        sta     scratch1B               ; 4418  remember it as the facing to turn to
L_441A:
        dec     scratch18               ; 441A  next candidate down
        bpl     L_43EF                  ; 441C  until entry 0 (or the forced end after a comcen)
        lda     scratch1B               ; 441E  direction of the winner
        sta     targetDirection         ; 4420  zp_6B: the unit will be turned this way below
        jmp     L_4428                  ; 4422  join the common 'may it fire?' test

L_4425:
        jsr     selectBoomerTarget      ; 4425  boomer: keep or re-choose its lock, sets $91F0/$91F1/zp_6B

; ----------------------------------------------------------------------
; Decide whether the unit is allowed to shoot at the target that was just found.
; ----------------------------------------------------------------------
L_4428:
        ldx     fireTargetUnit          ; 4428  the chosen target
        bmi     unitStepSkipFiring      ; 442B  $FF = nothing to shoot at
        lda     scanUnitType            ; 442D  type of the firing unit
        cmp     #$03                    ; 4430  types 3 (SPY) and 4 (COMCEN) carry no weapon
        bcs     L_4457                  ; 4432  they never fire: drop the target
        ldy     currentUnit             ; 4434  the firing unit
        lda     unitPathColTable,y      ; 4437  bit 7 of $F898 = the unit is cloaked
        bmi     L_4457                  ; 443A  unreachable: a cloaked unit already branched to L_443E at $43A1
        bpl     faceAndFireAtTarget     ; 443C  always taken: keep the target and fire below

; ----------------------------------------------------------------------
; unitStepSkipFiring - joined by every unit that cannot shoot this step (setup phase, cloaked, no
; target in range).  The unit keeps the facing it had, and a boomer that can no longer reach its
; locked target loses the lock.
; ----------------------------------------------------------------------
unitStepSkipFiring:
        ldx     currentUnit             ; 443E  the unit being stepped
        lda     unitFlagsTable,x        ; 4441  authoritative flag byte
        and     #$03                    ; 4444  bits 0-1 = facing
        sta     targetDirection         ; 4446  zp_6B: keep looking the way it looked
        lda     unitPathColTable,x      ; 4448  bit 7 of $F898 = cloaked
        bmi     L_4457                  ; 444B  a cloaked unit keeps its boomer lock for later
        jsr     getBoomerTarget         ; 444D  C=1, A = boomerTargetTable entry and Y = boomer ordinal for a boomer; A = type EOR 2 (positive) otherwise
        bpl     L_4457                  ; 4450  not a boomer, or no target locked (bit 7 clear)
        lda     #$00                    ; 4452  clear the lock
        sta     boomerTargetTable,y     ; 4454  $91DC+ordinal: this boomer has lost sight of its target
L_4457:
        lda     #$FF                    ; 4457  no shot is taken this step
        sta     fireTargetUnit          ; 4459  $91F0 = none

; ----------------------------------------------------------------------
; Turn the unit toward its target and fire.  Each unit only fires on every other round: the test is
; (gameClock EOR unit index) AND 1, so half of the units shoot on even rounds and half on odd ones,
; which spreads the work and the shot animations over two rounds.
; ----------------------------------------------------------------------
faceAndFireAtTarget:
        ldy     currentUnit             ; 445C  the unit being stepped
        lda     unitFlagsTable,y        ; 445F  authoritative flag byte
        and     #$FC                    ; 4462  clear the facing bits 0-1
        ora     targetDirection         ; 4464  insert the direction of the target (or the old facing)
        sta     unitFlagsTable,y        ; 4466  store the new facing
        lda     gameClock               ; 4469  $91CB = rounds left, counts down, so its bit 0 alternates every round
        eor     currentUnit             ; 446C  stagger the units against each other
        and     #$01                    ; 446F  is this the unit's own firing round?
        bne     L_447B                  ; 4471  no: leave the fired bit alone
        lda     unitDisplayFlagsTable,y ; 4473  local display copy of the unit flags
        and     #$FE                    ; 4476  clear bit 0 = 'has fired' so the console shows READY again
        sta     unitDisplayFlagsTable,y ; 4478  store it
L_447B:
        lda     fireTargetUnit          ; 447B  is there a target?
        bmi     runStationaryUnitUpkeep ; 447E  no shot: go on to the standing-still upkeep
        lda     gameClock               ; 4480  the same parity test again
        eor     currentUnit             ; 4483  gameClock EOR unit index
        and     #$01                    ; 4486  fire only on this unit's round
        bne     runStationaryUnitUpkeep ; 4488  not this round
        lda     unitEnergyTable,y       ; 448A  energy/visibility byte of the firing unit
        ora     #$C0                    ; 448D  firing gives the unit away: seen (bit 6) and visible (bit 7)
        sta     unitEnergyTable,y       ; 448F  store it
        ldx     currentUnit             ; 4492  the firing unit
        lda     unitDisplayFlagsTable,x ; 4495  local display flags
        ora     #$01                    ; 4498  bit 0 = fired (console status FIRED / RELOAD)
        sta     unitDisplayFlagsTable,x ; 449A  store it
        ldy     fireTargetUnit          ; 449D  Y = the target unit
        jsr     resolveAttack           ; 44A0  X = attacker, Y = target: damage, sound and the shot line

; ----------------------------------------------------------------------
; Upkeep of a unit that is standing on its destination (bit 7 of unitDestCol set).  It stops blitzing,
; and if it is neither stunned nor firing it either advances its dig-in or repairs one energy point
; per round.
; ----------------------------------------------------------------------
runStationaryUnitUpkeep:
        ldy     currentUnit             ; 44A3  the unit being stepped
        lda     unitDestColTable,y      ; 44A6  bit 7 set = the unit has arrived / has no move order
        bpl     finishUnitStep          ; 44A9  still travelling: nothing to do here
        lda     unitRowTable,y          ; 44AB  map row 0-39
        sta     unitPathRowTable,y      ; 44AE  re-sync the path row and clear its bit 7 = blitz (a standing unit stops charging)
        lda     unitStunTable,y         ; 44B1  stun/dig-in byte
        and     #$0F                    ; 44B4  low nibble = rounds of stun left
        bne     finishUnitStep          ; 44B6  a stunned unit neither digs in nor repairs
        lda     unitDestRowTable,y      ; 44B8  destination row
        and     #$7F                    ; 44BB  clear bit 7 = 'repairing', it is recomputed every round
        sta     unitDestRowTable,y      ; 44BD  store it back
        sta     unitPathRowTable,y      ; 44C0  destination equals position here, so this also refreshes the path row
        lda     unitDisplayFlagsTable,y ; 44C3  local display flags
        and     #$01                    ; 44C6  bit 0 = the unit fired this round
        bne     finishUnitStep          ; 44C8  shooting cancels digging in and repairing
        lda     unitStunTable,y         ; 44CA  stun/dig-in byte
        bmi     L_44E3                  ; 44CD  bit 7 = the unit is already dug in
        and     #$E0                    ; 44CF  bits 5-7 = dig-in progress (command $9F starts it with $20)
        beq     L_44EB                  ; 44D1  progress 0 = not digging in: try to repair instead
        lda     #$01                    ; 44D3  something is still happening this round...
        sta     exchangeActivityFlag    ; 44D5  ...so the setup-phase clock keeps running ($92C2)
        lda     unitStunTable,y         ; 44D8  dig-in byte again
        clc                             ; 44DB  clear carry for the add
        adc     #$20                    ; 44DC  one more step of digging in; three steps carry into bit 7
        sta     unitStunTable,y         ; 44DE  store the progress
        bpl     finishUnitStep          ; 44E1  not finished yet
L_44E3:
        lda     unitTypeTable,y         ; 44E3  type byte
        ora     #$40                    ; 44E6  bit 6 = dug in / fortified (resolveAttack takes 25% off the damage)
        sta     unitTypeTable,y         ; 44E8  store it
L_44EB:
        cpy     #$31                    ; 44EB  unit 49 = side 0 comcen
        beq     finishUnitStep          ; 44ED  a comcen never repairs itself here (only the console REPAIR panel repairs it)
        cpy     #$63                    ; 44EF  unit 99 = side 1 comcen
        beq     finishUnitStep          ; 44F1  same for the other comcen
        lda     unitEnergyTable,y       ; 44F3  energy/visibility byte
        sta     scratch18               ; 44F6  keep the whole byte
        and     #$C0                    ; 44F8  bits 6-7 = seen / visible
        sta     scratch19               ; 44FA  save the visibility bits
        lda     scratch18               ; 44FC  the byte again
        and     #$3F                    ; 44FE  bits 0-5 = energy 0-50 (displayed as 0-100%)
        cmp     #$32                    ; 4500  already at the maximum of 50?
        bcs     finishUnitStep          ; 4502  yes: nothing to repair
        adc     #$01                    ; 4504  +1 energy point this round (C is clear after the BCS)
        ora     scratch19               ; 4506  put the visibility bits back
        sta     unitEnergyTable,y       ; 4508  store the new energy
        lda     unitDestRowTable,y      ; 450B  destination row
        ora     #$80                    ; 450E  bit 7 = repairing, the console status word becomes REPAIR
        sta     unitDestRowTable,y      ; 4510  store it

; ----------------------------------------------------------------------
; End of one unit's step: give the solo trainer a look at the unit that was just moved, clear its 'hit
; last round' marker and redraw it.
; ----------------------------------------------------------------------
finishUnitStep:
        lda     gamePhase               ; 4513  battle only
        bne     L_4529                  ; 4515  setup / film / menu: no AI hook
        lda     filmPlaybackMode        ; 4517  $0B9B bit 7 = a game film is being replayed
        bmi     L_4529                  ; 451A  the film supplies the opponent's orders, the trainer must not run
        lda     isSoloTrainer           ; 451C  $0BA5 bit 7 = PRACTICE WITH SOLO TRAINER
        bpl     L_4529                  ; 451F  modem game: the opponent thinks on his own machine
        lda     gameEndReason           ; 4521  $0BA8 <> 0 = no game running
        bne     L_4529                  ; 4524  nothing to think about
        jsr     trainerUnitStepHook     ; 4526  $E0E8 in the trainer overlay: per-unit bookkeeping of the computer opponent
L_4529:
        ldy     currentUnit             ; 4529  the unit being stepped
        lda     unitDisplayFlagsTable,y ; 452C  local display flags
        and     #$FD                    ; 452F  clear bit 1 = 'was hit during the last round' (console status PINNED)
        sta     unitDisplayFlagsTable,y ; 4531  store it
        jsr     drawUnitAtPosition      ; 4534  repaint the unit where it now stands

; ----------------------------------------------------------------------
; endUnitStep - common exit of processUnitStep (dead and frozen units jump straight here from
; $421B).  Advances the round order and refreshes the own comcen's stun status, which is also what
; triggers the COMCEN STUNNED cut-scene.
; ----------------------------------------------------------------------
endUnitStep:
        inc     unitOrderIndex          ; 4537  $9162: the next engine step takes the following entry of unitProcessOrder
        lda     comcenStunStatus        ; 453A  $922A: 0 = comcen fine, $80|n = stunned for n rounds, $FF = knocked out
        cmp     #$FF                    ; 453D  $FF is written by applyDroneBlastToCell ($730D) when the own comcen is knocked out
        bne     L_4549                  ; 453F  not knocked out
        ldy     filmPlaybackMode        ; 4541  $0B9B bit 7 = film playback
        bmi     L_4549                  ; 4544  the cut-scene is skipped while a film is replaying
        jmp     runComcenStunnedBlackout; 4546  $0709: one blacked-out round, then the message COMCEN STUNNED!

L_4549:
        ldy     comcenUnit              ; 4549  $9236 = 49 or 99, the local player's comcen
        lda     unitStunTable,y         ; 454C  its stun/dig-in byte
        and     #$0F                    ; 454F  low nibble = rounds of stun left
        beq     L_4555                  ; 4551  not stunned: status byte 0
        ora     #$80                    ; 4553  bit 7 = the comcen is stunned (read by the console and the status line)
L_4555:
        sta     comcenStunStatus        ; 4555  publish the comcen status
        rts                             ; 4558  one unit of the round has been processed


; ----------------------------------------------------------------------
; tryStepTowardTarget - walks the line stepper one cell from the current cell ($93,$92) toward the
; target ($95,$94) and decides whether currentUnit may enter that cell and can pay for the step. A
; line with no steps returns C=1 and $19 = 0 (the unit is already there).  Cells outside the 40x40 map
; are refused (C=0, X=$FF); an occupied cell may only be entered by a spy, by a cloaked unit when the
; occupant is cloaked too, when the occupant is on the same side, or when the occupant is a spy.  The
; cost is terrainMoveCostTable[class], where the class is the group's worst terrain class for a
; grouped unit, the class of the ground under the unit otherwise and always 0 for a spy, plus 8 more
; for a diagonal step.
; In:  $92/$93 = current row/column, $94/$95 = target row/column, $90FA currentUnit, unit arrays
;      $F708/$F76C/$F898/$FA28/$FAF0, $91F4 groupStatusTable, terrain class table $077E
; Out: C=1 accepted, A = movement points left after the step, $19 = the destination cell byte,
;      $92/$93 advanced onto it, zp_48/$49 point at it, X = currentUnit; C=0 refused, X=$FF when
;      the cell is off the map or blocked and X = currentUnit when the points are not there yet;
;      zp_8C-$97 and zp_A9 hold the line state, $18/$19 clobbered
; Called from: processUnitStep $4316 (the real step) and finishRound $470D (look-ahead while the
;      round order is built)
; ----------------------------------------------------------------------
tryStepTowardTarget:
        jsr     initLineStepper         ; 4559  set up the Bresenham walk from ($93,$92) to ($95,$94); A = number of steps
        bne     L_4562                  ; 455C  there is at least one cell to walk
        sta     scratch19               ; 455E  $19 = 0: there is no cell to enter (A is 0 here)
        sec                             ; 4560  C=1 = 'nothing in the way' - the unit is already on its destination
        rts                             ; 4561  done


; ----------------------------------------------------------------------
; Take one step along the line and check the cell it lands on: inside the map, and either empty or
; shareable with the unit that stands there.
; ----------------------------------------------------------------------
L_4562:
        jsr     stepLine                ; 4562  advance ($93,$92) by one cell; zp_A9 = 1 if the step was diagonal
        ldx     lineCurCol              ; 4565  candidate column
        cpx     #$28                    ; 4567  the battlefield is 40 columns wide
        bcs     refuseStep              ; 4569  >= 40 (or wrapped negative): off the map
        ldy     lineCurRow              ; 456B  candidate row
        cpy     #$28                    ; 456D  40 rows
        bcs     refuseStep              ; 456F  off the map
        jsr     readMapCell             ; 4571  A = the map byte of that cell, zp_48/$49 = its address
        ldx     currentUnit             ; 4574  the unit that wants to move
        sta     scratch19               ; 4577  $19 = the destination cell byte (bit 7 set = a unit stands there)
        lda     unitTypeTable,x         ; 4579  type byte of the mover
        and     #$07                    ; 457C  unit type 0-4
        sec                             ; 457E  prepare the subtraction
        sbc     #$03                    ; 457F  gives exactly 0 for a SPY (type 3)
        sta     scratch18               ; 4581  $18 = 0 marks the mover as a spy
        ldy     scratch19               ; 4583  Y = the cell byte $80|unit, used to index the -$80 biased unit tables
        bpl     chargeMovementCost      ; 4585  bit 7 clear = plain terrain, only the movement cost is left to check
        lda     scratch18               ; 4587  is the mover a spy?
        beq     chargeMovementCost      ; 4589  a spy may walk into any occupied cell
        lda     unitPathColTable,x      ; 458B  bit 7 of $F898 = the mover is cloaked
        bpl     L_4595                  ; 458E  not cloaked: only the side decides
        lda     unitPathColTableAlias,y ; 4590  $F818 = $F898-$80, so the raw map byte indexes the occupant's cloak flag
        bmi     chargeMovementCost      ; 4593  two cloaked units may share a cell
L_4595:
        lda     unitFlagsTableAlias,y   ; 4595  $F688 = $F708-$80: flag byte of the occupant
        eor     unitFlagsTable,x        ; 4598  compare it with the mover's flags...
        and     #$20                    ; 459B  ...in bit 5, which is the side bit
        beq     chargeMovementCost      ; 459D  same side: units of one army walk through each other
        lda     unitTypeTableAlias,y    ; 459F  $F6EC = $F76C-$80: type byte of the occupant
        and     #$07                    ; 45A2  unit type 0-4
        cmp     #$03                    ; 45A4  an enemy SPY does not block a cell
        beq     chargeMovementCost      ; 45A6  step allowed
refuseStep:
        clc                             ; 45A8  C=0 = step refused
        ldx     #$FF                    ; 45A9  X = $FF tells the caller 'off the map or blocked'
        bcc     L_45E5                  ; 45AB  always taken (C was just cleared)

; ----------------------------------------------------------------------
; Charge the step against the unit's movement-point balance.
; ----------------------------------------------------------------------
chargeMovementCost:
        lda     unitFlagsTable,x        ; 45AD  flag byte of the mover
        and     #$40                    ; 45B0  bit 6 = the unit belongs to a group
        beq     L_45C7                  ; 45B2  not in a group: use its own ground
        lda     unitFlagsTable,x        ; 45B4  flag byte again
        and     #$3C                    ; 45B7  group key in bits 2-5 (group number 0-6 plus the side bit)
        lsr     a                       ; 45B9  shift the key down...
        lsr     a                       ; 45BA  ...to a group index 0-15
        tay                             ; 45BB  index into the group table
        lda     groupStatusTable,y      ; 45BC  $91F4+group = the group's movement byte
        and     #$38                    ; 45BF  bits 3-5 = the worst terrain class any member stands on
        lsr     a                       ; 45C1  right-align it...
        lsr     a                       ; 45C2  ...three places...
        lsr     a                       ; 45C3  ...to a class 0-8; a group crosses country as slowly as its worst-placed member
        jmp     L_45D2                  ; 45C4  charge that class

L_45C7:
        ldy     unitTerrainUnderTable,x ; 45C7  terrain code hidden under the unit
        lda     D_077E,y                ; 45CA  $077E table: terrain code -> movement class 0-8
        ldy     scratch18               ; 45CD  is the mover a spy?
        bne     L_45D2                  ; 45CF  no: use the terrain class
        tya                             ; 45D1  a spy always moves at class 0 (Y = 0 here)
L_45D2:
        tay                             ; 45D2  Y = movement class
        clc                             ; 45D3  C=0 is the answer if the balance turns out to be negative
        lda     unitMovePointsTable,x   ; 45D4  accumulated movement points of the unit
        bmi     L_45E5                  ; 45D7  negative balance (set by recordUnitHit): the unit has to wait
        sec                             ; 45D9  prepare the subtraction
        sbc     terrainMoveCostTable,y  ; 45DA  $405E: 20/65/30/40/20/40/20/20 points per class
        bcc     L_45E5                  ; 45DD  not enough points saved up yet: C=0 with X = the unit index
        ldy     isDiagonalStep          ; 45DF  zp_A9 = 1 when stepLine moved in both axes
        beq     L_45E5                  ; 45E1  orthogonal step: accepted, C=1, A = points left
        sbc     #$08                    ; 45E3  a diagonal step costs 8 points more
L_45E5:
        rts                             ; 45E5  back to the caller


; ----------------------------------------------------------------------
; finishRound - end of a round, reached by JMP from updateGameFrame ($4210) once all 100 units have
; been stepped.  It ticks the game clock (in the setup phase only when something actually happened),
; ends the game when the clock reaches 0 and warns at the last quarter, keeps a knocked-out comcen
; visible for a few rounds, then - during the battle only - runs the trainer's round hook, the
; recycler, the group tables and the visibility ageing, hands every unit its movement points and
; finally builds the next round's processing order.
; In:  $0BA8 gameEndReason, $91CB gameClock, zp_B1 gamePhase, $92C2 exchangeActivityFlag, $92EF
;      lastQuarterRound, $92B2 comcenRevealTimer, $0B9B/$0BA5 opponent flags, unit arrays, $9164
;      unitBaseOrder
; Out: $91CB decremented, $92FA/message $1D at the last quarter, $F9C4 visibility, $FA28 movement
;      points, $91F4 group table, $90FD unitProcessOrder rebuilt, $9162 = 0, $90FC = 0, the $0400
;      scratch page used as round-order marks and $0464 as a stack; zp_1A/zp_1B are the stack
;      pointer and the base-order cursor
; Called from: updateGameFrame (jmp $4210); the map generator jumps into the tail at L_4657 ($7BC3)
;      after shuffling the initial order
; ----------------------------------------------------------------------
finishRound:
        lda     gameEndReason           ; 45E6  $0BA8 <> 0 = no game is running
        bne     L_462B                  ; 45E9  then the clock must not move
        lda     gameClock               ; 45EB  $91CB = rounds left, counting down
        beq     L_462B                  ; 45EE  already expired
        ldy     gamePhase               ; 45F0  0 = the battle is running
        beq     L_45FE                  ; 45F2  in the battle every round costs a tick
        ldy     exchangeActivityFlag    ; 45F4  $92C2 = something moved, dug in or was ordered this round
        beq     L_462B                  ; 45F7  setup phase: an idle round does not cost time
        ldy     #$00                    ; 45F9  consume the flag...
        sty     exchangeActivityFlag    ; 45FB  ...so the next setup round has to earn its tick again
L_45FE:
        sec                             ; 45FE  prepare the subtraction
        sbc     #$01                    ; 45FF  one round gone
        sta     gameClock               ; 4601  store the clock
        bne     L_4611                  ; 4604  time left
        lda     #$08                    ; 4606  end reason 8 = the clock ran out
        sta     gameEndReason           ; 4608  record it for endGame and the score screen
        jsr     endGame                 ; 460B  halt the units, reveal everything and show the result
        jmp     L_462B                  ; 460E  carry on with the comcen reveal timers

L_4611:
        cmp     lastQuarterRound        ; 4611  $92EF = the round at which the last quarter begins
        bne     L_462B                  ; 4614  not that round
        lda     gamePhase               ; 4616  battle or setup?
        beq     L_4621                  ; 4618  battle: warn the player
        lda     #$FF                    ; 461A  setup phase reuses the same counter as a free-running timer...
        sta     gameClock               ; 461C  ...so wrap it to 255 instead of warning
        bne     L_462B                  ; 461F  always taken (A = $FF)
L_4621:
        lda     #$1D                    ; 4621  message $1D = 'LAST QTR WARNING!'
        jsr     queueMessage            ; 4623  queue it for the status line
        lda     #$80                    ; 4626  bit 7 = last quarter
        sta     lastQuarterFlag         ; 4628  $92FA: the status line prints the quarter from here on

; ----------------------------------------------------------------------
; Per-side comcen reveal timer: endGame sets $92B2+side to 6 when that comcen is knocked out, and for
; those rounds the comcen is forced visible so both players can see it.
; ----------------------------------------------------------------------
L_462B:
        ldx     #$01                    ; 462B  side 1 first, then side 0
L_462D:
        lda     comcenRevealTimer,x     ; 462D  $92B2+side, counted down here
        beq     L_4640                  ; 4630  not revealed
        dec     comcenRevealTimer,x     ; 4632  one round less
        ldy     comcenUnitIndexBySide,x ; 4635  $0C8A: 49 for side 0, 99 for side 1 = that side's comcen unit
        lda     unitEnergyTable,y       ; 4638  energy/visibility byte of the comcen
        ora     #$C0                    ; 463B  keep both visibility bits set
        sta     unitEnergyTable,y       ; 463D  store it
L_4640:
        dex                             ; 4640  next side
        bpl     L_462D                  ; 4641  loop over sides 1 and 0
        lda     gamePhase               ; 4643  0 = the battle is running
        beq     L_464A                  ; 4645  do the full end-of-round work
        jmp     resetRoundCounters      ; 4647  setup phase: only reset the step counters


; ----------------------------------------------------------------------
; Once-per-round engine work, battle only.  L_4657 is also entered by JMP from the map generator
; ($7BC3, shuffleInitialUnitOrder) to build the very first round order, which is why the loop below
; tests gamePhase again before it hands out movement points.
; ----------------------------------------------------------------------
L_464A:
        lda     filmPlaybackMode        ; 464A  $0B9B bit 7 = a film is being replayed
        bmi     L_4657                  ; 464D  the film already contains the opponent's orders
        lda     isSoloTrainer           ; 464F  $0BA5 bit 7 = solo trainer
        bpl     L_4657                  ; 4652  modem game: the opponent thinks on his own machine
        jsr     runTrainerRoundHook     ; 4654  $E079: let the computer opponent plan this round
L_4657:
        jsr     checkEnemyRecyclerSpotted; 4657  may reveal the opponent's recycler if one of our units is close enough
        jsr     recycleDeadUnits        ; 465A  rebuild dead units at the recyclers
        jsr     rebuildGroupTables      ; 465D  recompute each group's speed and terrain class
        jsr     ageUnitVisibility       ; 4660  shift 'seen this cycle' into 'visible' and clear the seen bits
        ldy     #$63                    ; 4663  walk the units from 99 down to 0

; ----------------------------------------------------------------------
; handOutMovePoints - give every unit this round's movement points and clear its round-order mark in
; the $0400 scratch page.  A unit that is dead or standing still is pegged at 15 points; a moving unit
; adds speedClassRateTable[class] to its balance, saturating at +127.  The class is the unit type, or
; the group's slowest class for a group member, or the per-player COMCEN SPEED setting for a comcen.
; ----------------------------------------------------------------------
handOutMovePoints:
        lda     gamePhase               ; 4665  0 = battle (the map generator can enter here during setup)
        bne     L_46B2                  ; 4667  setup: no points, only clear the mark
        lda     unitTypeTable,y         ; 4669  type byte
        bmi     L_4673                  ; 466C  bit 7 = destroyed
        ldx     unitDestColTable,y      ; 466E  destination column, bit 7 = at destination / no order
        bpl     L_467A                  ; 4671  the unit still has somewhere to go: it earns points
L_4673:
        lda     #$0F                    ; 4673  standing or dead: a flat 15 points
        sta     unitMovePointsTable,y   ; 4675  store the balance
        bne     L_46B2                  ; 4678  always taken (A = $0F)
L_467A:
        and     #$07                    ; 467A  A is still the type byte: bits 0-2 = the unit type, which is also the speed class
        tax                             ; 467C  X = speed class
        lda     unitFlagsTable,y        ; 467D  flag byte
        and     #$40                    ; 4680  bit 6 = member of a group
        bne     L_4696                  ; 4682  a group moves at one common speed
        cpx     #$04                    ; 4684  type 4 = COMCEN
        bne     L_46A4                  ; 4686  ordinary unit: its type is its class
        ldx     comcenSpeedClass        ; 4688  $92B4 = the local player's COMCEN SPEED option
        cpy     comcenUnit              ; 468B  $9236 = the local player's comcen
        beq     L_46A4                  ; 468E  yes: use the own setting
        ldx     opponentComcenSpeedClass; 4690  $92B5 = the opponent's COMCEN SPEED option
        jmp     L_46A4                  ; 4693  look the rate up

L_4696:
        lda     unitFlagsTable,y        ; 4696  flag byte
        and     #$3C                    ; 4699  group key in bits 2-5
        lsr     a                       ; 469B  shift it down...
        lsr     a                       ; 469C  ...to a group index 0-15
        tax                             ; 469D  index the group table
        lda     groupStatusTable,x      ; 469E  $91F4+group
        and     #$07                    ; 46A1  bits 0-2 = the class of the group's slowest member
        tax                             ; 46A3  X = that class
L_46A4:
        lda     speedClassRateTable,x   ; 46A4  $4066: 12/25/12/25/0 points per round for classes 0-4
        clc                             ; 46A7  clear carry for the addition
        adc     unitMovePointsTable,y   ; 46A8  add to the balance the unit has saved up
        bvc     L_46AF                  ; 46AB  no overflow past +127
        lda     #$7F                    ; 46AD  saturate: the balance is a signed byte
L_46AF:
        sta     unitMovePointsTable,y   ; 46AF  store the new balance
L_46B2:
        lda     #$00                    ; 46B2  mark 0 = this unit has not been placed in the round order yet
        sta     checksumXorConstant,y   ; 46B4  $0400+unit (the same page as the scan lists, which are finished with)  (also trainerPlaybook0200:play3Col12)
        dey                             ; 46B7  next unit down
        bpl     handOutMovePoints       ; 46B8  until unit 0

; ----------------------------------------------------------------------
; Build unitProcessOrder for the coming round out of the shuffled unitBaseOrder.  A unit whose next
; step would land on a unit that has not been ordered yet is pushed on a stack at $0464 and the unit
; in front of it is ordered first, so a queue of units all move in the same round instead of advancing
; one cell per round.  Marks in $0400+unit: 0 = not ordered, 1 = waiting on the stack, 5 = already
; ordered.  zp_1A = stack pointer, zp_1B = cursor into unitBaseOrder.
; ----------------------------------------------------------------------
        lda     #$00                    ; 46BA  start the new order at entry 0
        sta     unitOrderIndex          ; 46BC  $9162 is reused here as the write index into unitProcessOrder
        lda     #$FF                    ; 46BF  empty markers
        sta     scratch1A               ; 46C1  $1A = stack pointer of the waiting stack ($FF = empty)
        sta     scratch1B               ; 46C3  $1B = cursor into unitBaseOrder (the first INC gives entry 0)
buildRoundOrder:
        ldx     scratch1A               ; 46C5  anything waiting on the stack?
        bmi     L_46DA                  ; 46C7  no: take the next unit of the base order
        dec     scratch1A               ; 46C9  pop it
        ldy     play4Grp12,x            ; 46CB  $0464+sp = the unit that was blocked (same buffer as the scan direction list)
        lda     checksumXorConstant,y   ; 46CE  its mark  (also trainerPlaybook0200:play3Col12)
        sec                             ; 46D1  prepare the subtraction
        sbc     #$01                    ; 46D2  mark 1 (waiting) becomes 0 (unordered) so it can be placed now
        sta     checksumXorConstant,y   ; 46D4  store the mark  (also trainerPlaybook0200:play3Col12)
        jmp     L_46E6                  ; 46D7  and try to place it

L_46DA:
        inc     scratch1B               ; 46DA  next entry of the shuffled base order
        ldy     scratch1B               ; 46DC  read the cursor
        cpy     #$64                    ; 46DE  all 100 entries used?
        bcs     resetRoundCounters      ; 46E0  the order is complete
        lda     unitBaseOrder,y         ; 46E2  $9164+n = a unit index
        tay                             ; 46E5  Y = the unit to place
L_46E6:
        lda     checksumXorConstant,y   ; 46E6  its mark  (also trainerPlaybook0200:play3Col12)
        bne     buildRoundOrder         ; 46E9  already ordered or already waiting: take the next one
placeUnitInRoundOrder:
        sty     currentUnit             ; 46EB  the unit we are trying to place
        lda     unitTypeTable,y         ; 46EE  type byte
        bmi     appendToRoundOrder      ; 46F1  a dead unit blocks nothing: order it straight away
        lda     unitPathColTable,y      ; 46F3  path column
        and     #$3F                    ; 46F6  drop the cloak bits 6-7
        sta     lineCurCol              ; 46F8  line stepper start column
        lda     unitPathRowTable,y      ; 46FA  path row
        and     #$3F                    ; 46FD  drop the blitz bit
        sta     lineCurRow              ; 46FF  line stepper start row
        lda     unitDestColTable,y      ; 4701  destination column
        bmi     appendToRoundOrder      ; 4704  bit 7 = standing still: order it straight away
        sta     lineEndCol              ; 4706  line stepper target column
        lda     unitDestRowTable,y      ; 4708  destination row
        sta     lineEndRow              ; 470B  line stepper target row
        jsr     tryStepTowardTarget     ; 470D  look one cell ahead; nothing is moved, only tested
        bcc     appendToRoundOrder      ; 4710  blocked or too few movement points: order it now
        lda     scratch19               ; 4712  the cell it would step onto
        bpl     appendToRoundOrder      ; 4714  free terrain: nothing to wait for
        and     #$7F                    ; 4716  a unit stands there: its index
        sta     scratch19               ; 4718  keep it
        tax                             ; 471A  index the marks with it
        lda     checksumXorConstant,x   ; 471B  has the blocking unit been ordered or stacked already?  (also trainerPlaybook0200:play3Col12)
        bne     appendToRoundOrder      ; 471E  yes: then this unit can be ordered now
        lda     currentUnit             ; 4720  push this unit...
        inc     scratch1A               ; 4723  ...onto the waiting stack
        ldx     scratch1A               ; 4725  new stack pointer
        sta     play4Grp12,x            ; 4727  $0464+sp = the waiting unit
        tax                             ; 472A  index the marks with it
        inc     checksumXorConstant,x   ; 472B  mark 1 = waiting for the unit in front of it  (also trainerPlaybook0200:play3Col12)
        ldy     scratch19               ; 472E  now place the blocking unit first
        jmp     placeUnitInRoundOrder   ; 4730  and repeat the same test for it

appendToRoundOrder:
        ldy     unitOrderIndex          ; 4733  write position in the round order
        lda     currentUnit             ; 4736  the unit that has just been settled
        sta     unitProcessOrder,y      ; 4739  $90FD+n = the unit that will be stepped n-th next round
        inc     unitOrderIndex          ; 473C  one more unit ordered
        tay                             ; 473F  index the marks with the unit
        lda     #$05                    ; 4740  mark 5 = ordered
        sta     checksumXorConstant,y   ; 4742  store the mark  (also trainerPlaybook0200:play3Col12)
        jmp     buildRoundOrder         ; 4745  back to the stack / the base order

resetRoundCounters:
        lda     #$00                    ; 4748  restart the counters
        sta     stepDelayTimer          ; 474A  $90FC = 0: the very next frame starts the new round
        sta     unitOrderIndex          ; 474D  $9162 = 0: begin at the first unit of the new order
        rts                             ; 4750  the round is finished


; ----------------------------------------------------------------------
; moveUnitToCell - puts currentUnit into the cell ($93,$92) that tryStepTowardTarget just accepted:
; writes $80|unit into that cell (zp_48 still points at it), restores the terrain that was hidden
; under the unit into the cell it leaves, updates unitCol/unitRow and saves the terrain of the new
; cell.  It then scores territory for the step (see the block comment at $4781).
; In:  $90FA currentUnit, $93/$92 = new column/row, $19 = the byte that was in the new cell,
;      zp_48/$49 -> that cell, $9228 scanUnitType, $92AC terrainPointsBySide, patched branch at $4783
; Out: both map cells rewritten, $F640/$F6A4/$FAF0/$F76C of the unit updated, $92A2/$92A4 scores,
;      $9208 bumped so the status line redraws, $406E unitMovedFlag and $92C2 set; zp_48 low byte is
;      reused as the point accumulator and left clobbered
; Called from: processUnitStep $42F8 (catching up with the path position) and $4345 (the normal step)
; ----------------------------------------------------------------------
moveUnitToCell:
        inc     unitMovedFlag           ; 4751  $406E: tells processUnitStep to repaint the cell that was left
        lda     #$01                    ; 4754  something happened this round...
        sta     exchangeActivityFlag    ; 4756  ...so the setup-phase clock may tick ($92C2)
        ldy     #$00                    ; 4759  offset 0 for the (zp_48),Y stores
        ldx     currentUnit             ; 475B  the moving unit
        txa                             ; 475E  its index
        ora     #$80                    ; 475F  a map cell holding a unit is $80 | unit index
        sta     (mapCellPtr),y          ; 4761  zp_48 still points at the destination cell from readMapCell
        ldy     unitRowTable,x          ; 4763  row of the cell being left
        lda     unitColTable,x          ; 4766  column of the cell being left
        tax                             ; 4769  readMapCell wants the column in X
        jsr     readMapCell             ; 476A  point zp_48 at the old cell (A = its content, unused)
        ldy     #$00                    ; 476D  offset 0 again
        ldx     currentUnit             ; 476F  the moving unit
        lda     unitTerrainUnderTable,x ; 4772  the terrain that was hidden under it
        sta     (mapCellPtr),y          ; 4775  put that terrain back into the old cell
        lda     lineCurCol              ; 4777  column the line walker stepped onto
        sta     unitColTable,x          ; 4779  the unit now stands there
        lda     lineCurRow              ; 477C  row the line walker stepped onto
        sta     unitRowTable,x          ; 477E  store the new row

; ----------------------------------------------------------------------
; Territory scoring.  A holds the unit's new row.  For the opponent's units the row is mirrored
; (39-row) so that both sides are measured from their own baseline: the branch opcode at $4783 is
; patched by patchUnitSideBranches ($7BC6 in the map generator) with $90 = BCC when this machine plays
; side 0 and $B0 = BCS when it plays side 1, so 'own units' always skip the mirror.  Row < 20 means
; the unit has crossed into the enemy half (zone 1), row < 10 means it has reached the enemy's rear
; (zone 2).  Each zone pays a unit only once - the receipts are bits 3 and 4 of its type byte. The
; value is the owner's TERRAIN PTS setting, times 16 for a comcen and times 2 for the far zone.
; ----------------------------------------------------------------------
        cpx     #$32                    ; 4781  unit index < 50 = side 0, >= 50 = side 1
zoneRowSideBranch:
        bcc     L_4788                  ; 4783  opcode byte patched per side ($90 BCC for side 0, $B0 BCS for side 1): own units skip the mirror
        jsr     mirrorMapCoordinate     ; 4785  39 - row: measure the opponent from his own end of the field
L_4788:
        cmp     #$0A                    ; 4788  within 10 rows of the enemy baseline?
        bcs     L_4790                  ; 478A  no
        lda     #$02                    ; 478C  zone 2 = the enemy's rear
        bne     L_4796                  ; 478E  always taken
L_4790:
        cmp     #$14                    ; 4790  still in the enemy half (rows 10-19)?
        bcs     L_47E7                  ; 4792  no, the unit is in its own half: nothing to score
        lda     #$01                    ; 4794  zone 1 = the enemy half
L_4796:
        sta     L_47BF+1                ; 4796  keep the zone number in the LDA # operand at $47C0
        tay                             ; 4799  index the receipt table with it
        lda     zoneScoredBitTable,y    ; 479A  $406B: zone 1 -> bit 3 ($08), zone 2 -> bit 4 ($10)
        and     unitTypeTable,x         ; 479D  has this unit already been paid for this zone?
        bne     L_47E7                  ; 47A0  yes: a zone pays only once per unit
        ldy     #$00                    ; 47A2  index 0
        cpx     #$32                    ; 47A4  which side does the unit belong to?
        bcs     L_47A9                  ; 47A6  side 1 unit: index 0
        iny                             ; 47A8  side 0 unit: index 1 - both arrays are indexed by the opposing side
L_47A9:
        lda     terrainPointsBySide,y   ; 47A9  $92AC+i = that player's TERRAIN PTS setting (0 disables territory points)
        beq     L_47E7                  ; 47AC  territory scores nothing in this game
        sta     mapCellPtr              ; 47AE  zp_48 doubles as the point accumulator; the cell pointer has done its work
        lda     scanUnitType            ; 47B0  type of the unit that moved
        cmp     #$04                    ; 47B3  COMCEN?
        bne     L_47BF                  ; 47B5  ordinary unit
        asl     mapCellPtr              ; 47B7  a comcen in enemy territory is worth 16 times as much...
        asl     mapCellPtr              ; 47B9  ...x2...
        asl     mapCellPtr              ; 47BB  ...x4...
        asl     mapCellPtr              ; 47BD  ...x8, x16
L_47BF:
        lda     #$FF                    ; 47BF  operand patched at $4796 = the zone number 1 or 2
        pha                             ; 47C1  keep the zone for the receipt below
        cmp     #$02                    ; 47C2  the enemy's rear?
        bne     L_47C8                  ; 47C4  no
        asl     mapCellPtr              ; 47C6  the far zone is worth double
L_47C8:
        inc     lastShownClock          ; 47C8  $9208 <> gameClock forces the status line to redraw the score
        lda     mapCellPtr              ; 47CB  the points earned
        clc                             ; 47CD  clear carry for the 16-bit add
        adc     sideScoreLo,y           ; 47CE  $92A2+i: the score array is indexed by the losing side, so this is the mover's own score
        sta     sideScoreLo,y           ; 47D1  store the low byte
        lda     sideScoreHi,y           ; 47D4  $92A4+i = high byte
        adc     #$00                    ; 47D7  add the carry
        sta     sideScoreHi,y           ; 47D9  store it
        pla                             ; 47DC  the zone number again
        tay                             ; 47DD  index the receipt table
        lda     zoneScoredBitTable,y    ; 47DE  bit 3 or bit 4
        ora     unitTypeTable,x         ; 47E1  mark the unit as paid for this zone
        sta     unitTypeTable,x         ; 47E4  store the type byte
L_47E7:
        lda     scratch19               ; 47E7  the byte that was in the new cell (its terrain)
        sta     unitTerrainUnderTable,x ; 47E9  hide it under the unit so it can be restored when the unit leaves
        rts                             ; 47EC  the move is complete


; ----------------------------------------------------------------------
; shuffleUnitOrder - exchanges two randomly chosen entries of unitBaseOrder ($9164, the 100 unit
; indices the round order is built from).  The indices come from randomUnitIndex, which draws on the
; shared scenario RNG, so both machines shuffle in exactly the same way.
; In:  $9164 unitBaseOrder, scenario RNG state $5A-$5C
; Out: two entries of unitBaseOrder exchanged, RNG advanced twice; A/X/Y clobbered
; Called from: processUnitStep $4229 (once per unit stepped during the battle) and the map
;      generator $7BBC (51 swaps at game start)
; ----------------------------------------------------------------------
shuffleUnitOrder:
        nop                             ; 47ED  leftover byte at the entry point - probably an instruction that was patched out
        jsr     randomUnitIndex         ; 47EE  first slot 0-99
        tax                             ; 47F1  keep it in X
        jsr     randomUnitIndex         ; 47F2  second slot 0-99
        tay                             ; 47F5  keep it in Y
        lda     unitBaseOrder,y         ; 47F6  entry Y...
        pha                             ; 47F9  ...onto the stack
        lda     unitBaseOrder,x         ; 47FA  entry X...
        sta     unitBaseOrder,y         ; 47FD  ...into slot Y
        pla                             ; 4800  the saved entry...
        sta     unitBaseOrder,x         ; 4801  ...into slot X
        rts                             ; 4804  the two entries have been exchanged


; ----------------------------------------------------------------------
; randomUnitIndex - returns a unit index 0-99 drawn uniformly from the scenario random generator: a
; 7-bit sample is rejected and redrawn while it is 100 or more, so the distribution stays flat.
; In:  scenario RNG state $5A-$5C
; Out: A = 0..99, C=0; RNG advanced (at least once)
; Called from: shuffleUnitOrder $47EE and $47F2
; ----------------------------------------------------------------------
randomUnitIndex:
        jsr     nextScenarioRandom      ; 4805  $FD4A: the 24-bit generator both machines keep in step
        and     #$7F                    ; 4808  0-127
        cmp     #$64                    ; 480A  100 units
        bcs     randomUnitIndex         ; 480C  reject 100-127 and draw again
        rts                             ; 480E  A = a unit index


; ----------------------------------------------------------------------
; followDirectedUnit - keeps the battlefield view locked on the unit the console is following.  When
; the battlefield screen is up and infoPanelUnit ($91D7) is the unit currently being stepped, it
; computes the cell under the view cursor (viewOriginCol+3, viewOriginRow+2) and, if the unit is not
; on it, moves sprite 0 (the tracking cursor) to the unit on the overview map, switches the sprite on
; and scrolls the 7x5 view onto the unit without animation.
; In:  $90FB currentScreen, $91D7 infoPanelUnit, $90FA currentUnit, zp_A5/zp_A6 view origin,
;      $F640/$F6A4 unit position
; Out: $91DA/$91DB = the cursor cell, $9010/$9018 sprite 0 position, $9022 sprite 0 enabled,
;      zp_92/zp_93 and zp_A5/zp_A6 moved by scrollViewToUnit
; Called from: processUnitStep $4380, after the unit has taken its step
; ----------------------------------------------------------------------
followDirectedUnit:
        lda     currentScreen           ; 480F  $90FB: 0 = the battlefield screen
        bne     L_485F                  ; 4812  the console screens do not follow units
        ldy     infoPanelUnit           ; 4814  $91D7 = the unit whose panel/menu is open ($FF = none)
        bmi     L_485F                  ; 4817  nothing is being followed
        cpy     currentUnit             ; 4819  is it the unit that is being stepped right now?
        bne     L_485F                  ; 481C  no: leave the view alone
        lda     #$03                    ; 481E  the view is 7 cells wide...
        clc                             ; 4820  clear carry for the add
        adc     viewOriginCol           ; 4821  ...so its centre column is the left edge + 3
        sta     currentUnitCol          ; 4823  $91DA = column of the cell under the cursor
        lda     #$02                    ; 4826  the view is 5 cells high...
        clc                             ; 4828  clear carry
        adc     viewOriginRow           ; 4829  ...so its centre row is the top edge + 2
        sta     currentUnitRow          ; 482B  $91DB = row of the cell under the cursor
        ldx     unitColTable,y          ; 482E  column of the followed unit
        lda     unitRowTable,y          ; 4831  row of the followed unit
        tay                             ; 4834  readable by the compare below
        cpx     currentUnitCol          ; 4835  is the unit already under the cursor?
        bne     L_483F                  ; 4838  no
        cpy     currentUnitRow          ; 483A  check the row too
        beq     L_485F                  ; 483D  the view is already centred on it: nothing to do
L_483F:
        jsr     mapCellToSpriteXY       ; 483F  X/Y = sprite coordinates of that cell on the overview map
        stx     spriteXShadow           ; 4842  $9010 = sprite 0 X (the tracking cursor)
        sty     spriteYShadow           ; 4845  $9018 = sprite 0 Y
        lda     #$01                    ; 4848  mask for sprite 0
        jsr     enableSprites           ; 484A  switch it on (unless sprites are suppressed)
        lda     currentUnitCol          ; 484D  the scroll starts at the current cursor cell
        sta     lineCurCol              ; 4850  line stepper start column
        lda     currentUnitRow          ; 4852  cursor row
        sta     lineCurRow              ; 4855  line stepper start row
        lda     #$00                    ; 4857  0 ticks per step = jump there without animating
        ldx     infoPanelUnit           ; 4859  the unit to centre on
        jsr     scrollViewToUnit        ; 485C  $1D4E: walk the view over to it
L_485F:
        rts                             ; 485F  done


; ----------------------------------------------------------------------
; updateHitFlashTimers - ages the hit markers of all 100 units.  unitHitFlashTable ($FA8C) is
; $80|damage while a unit is flashing (the map drawing code at $286F paints such a unit white); the
; low nibble counts engine steps down and the high nibble is left alone.  When the count expires the
; marker is cleared and the cell is repainted: a living unit is drawn again, and a destroyed one has
; the terrain that was under it written back into the map and drawn instead.
; In:  $FA8C unitHitFlashTable, $F76C unitTypeTable, $FAF0 unitTerrainUnderTable
; Out: $FA8C aged, map cells and screen updated for expired markers; zp_18 is the loop index
; Called from: updateGameFrame $41FB, once per engine step
; ----------------------------------------------------------------------
updateHitFlashTimers:
        lda     #$63                    ; 4860  walk the units from 99...
        sta     scratch18               ; 4862  ...down to 0 in zp_18
L_4864:
        ldx     scratch18               ; 4864  current unit index
        lda     unitHitFlashTable,x     ; 4866  $FA8C: 0 = no marker, else $80|damage with the countdown in bits 0-3
        beq     L_489D                  ; 4869  this unit is not flashing
        tay                             ; 486B  keep the whole byte
        and     #$F0                    ; 486C  high nibble = the marker bit plus the top bits of the damage
        sta     L_4879+1                ; 486E  patch it into the ORA operand at $487A
        tya                             ; 4871  the byte again
        and     #$0F                    ; 4872  the countdown in the low nibble
        beq     L_4881                  ; 4874  expired: take the marker down
        sec                             ; 4876  prepare the subtraction
        sbc     #$01                    ; 4877  one engine step less
L_4879:
        ora     #$FF                    ; 4879  operand patched at $486E: put the high nibble back
        sta     unitHitFlashTable,x     ; 487B  store the aged marker
        jmp     L_489D                  ; 487E  next unit

L_4881:
        lda     #$00                    ; 4881  clear the marker...
        sta     unitHitFlashTable,x     ; 4883  ...so the unit stops flashing white
        lda     unitTypeTable,x         ; 4886  type byte
        bmi     L_4890                  ; 4889  bit 7 = the unit was destroyed
        txa                             ; 488B  living unit: its index...
        ora     #$80                    ; 488C  ...as the map byte $80|unit
        bne     L_4898                  ; 488E  always taken (the value is >= $80)
L_4890:
        lda     unitTerrainUnderTable,x ; 4890  the terrain that was hidden under the wreck
        pha                             ; 4893  keep it for the redraw
        jsr     removeUnitLeavingCell   ; 4894  write that terrain into the map cell and free the unit's slot ($FAF0 = $FF)
        pla                             ; 4897  the terrain byte again
L_4898:
        ldy     scratch18               ; 4898  Y = the unit whose cell is redrawn
        jsr     drawValueAtUnit         ; 489A  repaint the cell in the view and on the overview map
L_489D:
        dec     scratch18               ; 489D  next unit down
        bpl     L_4864                  ; 489F  until unit 0
        rts                             ; 48A1  all markers aged


; ----------------------------------------------------------------------
; drawUnitAtPosition - redraws unit Y where it stands: builds the map byte $80|unit and falls into
; drawValueAtUnit.
; In:  Y = unit index
; Out: see drawValueAtUnit
; Called from: $3BDB (after damage), $4185/$41B1 (pending unit/group redraws), $43C7 (newly spotted
;      enemy) and $4534 (end of a unit step)
; ----------------------------------------------------------------------
drawUnitAtPosition:
        tya                             ; 48A2  the unit index
        ora     #$80                    ; 48A3  a map cell holding a unit reads $80 | index

; ----------------------------------------------------------------------
; drawValueAtUnit - draws cell value A at the position of unit Y.
; In:  A = the cell byte to draw, Y = unit index
; Out: the cell is repainted in the 7x5 view and on the overview map; A/X/Y clobbered
; Called from: updateHitFlashTimers $489A, and by falling through from drawUnitAtPosition
; ----------------------------------------------------------------------
drawValueAtUnit:
        pha                             ; 48A5  keep the cell value
        ldx     unitColTable,y          ; 48A6  X = the unit's map column
        lda     unitRowTable,y          ; 48A9  the unit's map row...
        tay                             ; 48AC  ...into Y as drawMapCell wants it
        pla                             ; 48AD  the cell value again
        jmp     drawMapCell             ; 48AE  $5C7C: repaint that cell everywhere it is visible


; ----------------------------------------------------------------------
; updateIdleFlag - recomputes bit 7 of unitDestCol ($F7D0) for currentUnit: it is set when the
; destination equals the unit's map position (nothing left to do) and cleared while the unit still has
; somewhere to go.  The other bits of the byte are kept.  The result is delivered in A so the caller
; can copy the same bit into the display flags.
; In:  $90FA currentUnit, $F640/$F6A4 position, $F7D0/$F834 destination
; Out: A = the new $F7D0 byte (bit 7 = at destination), Y = currentUnit, N reflects that bit
; Called from: processUnitStep $4278 (before the step) and $4383 (after it)
; ----------------------------------------------------------------------
updateIdleFlag:
        ldy     currentUnit             ; 48B1  the unit being stepped
        lda     #$00                    ; 48B4  assume 'still travelling'...
        sta     L_48D7+1                ; 48B6  ...by clearing the ORA operand at $48D8
        lda     unitDestRowTable,y      ; 48B9  destination row
        and     #$3F                    ; 48BC  bits 0-5 only: bit 7 is the repair flag
        cmp     unitRowTable,y          ; 48BE  is the unit already on that row?
        bne     L_48D2                  ; 48C1  no: it still has to move
        lda     unitDestColTable,y      ; 48C3  destination column
        and     #$3F                    ; 48C6  bits 0-5 only
        cmp     unitColTable,y          ; 48C8  is it on that column too?
        bne     L_48D2                  ; 48CB  no: it still has to move
        lda     #$80                    ; 48CD  bit 7 = at destination
        sta     L_48D7+1                ; 48CF  patch it into the ORA operand at $48D8
L_48D2:
        lda     unitDestColTable,y      ; 48D2  the destination column byte
        and     #$7F                    ; 48D5  drop the old flag, keep the coordinate
L_48D7:
        ora     #$FF                    ; 48D7  operand patched above: 0 or $80
        sta     unitDestColTable,y      ; 48D9  store the refreshed byte
        rts                             ; 48DC  A = that byte, Y = currentUnit


; ----------------------------------------------------------------------
; rebuildGroupTables - recomputes the 16 entries of groupStatusTable ($91F4), one per group key (7
; groups per side plus the side bit).  For every living member it works out the speed class (the unit
; type, or the per-player COMCEN SPEED setting for a comcen) and the terrain class of the ground it
; stands on (always 0 for a spy) and keeps the group's worst terrain class in bits 3-5 and the class
; of its slowest member in bits 0-2, so a group crosses country like its slowest member on the worst
; ground any member occupies.  Bits 6-7 (group blitzing / group cloaked) are carried over from the
; previous value for groups that still have members.  Scratch: $0400+g member count, $0410+g worst
; movement cost, $0420+g best movement rate, $0430+g the previous entry.
; In:  unit arrays $F708/$F76C/$FAF0, $92B4/$92B5 comcen speed classes, $9236 comcenUnit, tables
;      $405E terrainMoveCostTable, $4066 speedClassRateTable, $077E terrain classes
; Out: $91F4-$9203 rewritten, $0400-$043F used as scratch; zp_18/zp_19/zp_1A clobbered
; Called from: finishRound $465D, once per round
; ----------------------------------------------------------------------
rebuildGroupTables:
        ldy     #$0F                    ; 48DD  16 group slots: 7 groups per side, keyed by bits 2-5 of the unit flags
L_48DF:
        lda     groupStatusTable,y      ; 48DF  the entry as it was last round
        sta     play3Row10,y            ; 48E2  $0430+g: keep it for its blitz/cloak bits
        lda     #$00                    ; 48E5  start from scratch
        sta     groupStatusTable,y      ; 48E7  clear the entry
        sta     checksumXorConstant,y   ; 48EA  $0400+g = member count  (also trainerPlaybook0200:play3Col12)
        sta     play3Col28,y            ; 48ED  $0410+g = worst movement cost met so far
        lda     #$FF                    ; 48F0  worse than any real rate
        sta     play3Col44,y            ; 48F2  $0420+g = best (lowest) movement rate met so far
        dey                             ; 48F5  next slot
        bpl     L_48DF                  ; 48F6  until slot 0
        lda     #$63                    ; 48F8  scan the units from 99...
        sta     scratch1A               ; 48FA  ...down to 0 in zp_1A
L_48FC:
        ldy     scratch1A               ; 48FC  current unit
        lda     unitTypeTable,y         ; 48FE  type byte
        bmi     L_496D                  ; 4901  bit 7 = destroyed: a dead member does not slow the group down
        lda     unitFlagsTable,y        ; 4903  flag byte
        and     #$40                    ; 4906  bit 6 = the unit is in a group
        beq     L_496D                  ; 4908  not a member
        lda     unitFlagsTable,y        ; 490A  flag byte again
        and     #$3C                    ; 490D  group key in bits 2-5
        lsr     a                       ; 490F  shift it down...
        lsr     a                       ; 4910  ...to a group index 0-15
        tax                             ; 4911  index the group tables with it
        inc     checksumXorConstant,x   ; 4912  one more member in this group  (also trainerPlaybook0200:play3Col12)
        lda     unitTypeTable,y         ; 4915  type byte
        and     #$07                    ; 4918  unit type 0-4 = speed class
        cmp     #$04                    ; 491A  COMCEN?
        bne     L_4929                  ; 491C  ordinary unit: its type is its class
        lda     comcenSpeedClass        ; 491E  $92B4 = the local player's COMCEN SPEED option
        cpy     comcenUnit              ; 4921  $9236 = the local player's comcen
        beq     L_4929                  ; 4924  yes: keep the own setting
        lda     opponentComcenSpeedClass; 4926  $92B5 = the opponent's COMCEN SPEED option
L_4929:
        sta     scratch18               ; 4929  zp_18 = the member's speed class
        sec                             ; 492B  prepare the subtraction
        sbc     #$03                    ; 492C  class 3 = SPY
        beq     L_4937                  ; 492E  a spy ignores the terrain: class 0 (A is 0 here)
        lda     unitTerrainUnderTable,y ; 4930  terrain code hidden under the member
        tay                             ; 4933  index the class table with it
        lda     D_077E,y                ; 4934  $077E: terrain code -> movement class
L_4937:
        sta     scratch19               ; 4937  zp_19 = the member's terrain class
        tay                             ; 4939  index the cost table
        lda     terrainMoveCostTable,y  ; 493A  $405E: movement points that class costs per step
        cmp     play3Col28,x            ; 493D  worse ground than the group has met so far?
        bcc     L_4956                  ; 4940  no: leave the terrain class alone
        sta     play3Col28,x            ; 4942  remember the new worst cost
        lda     scratch19               ; 4945  the class that caused it
        asl     a                       ; 4947  shift it...
        asl     a                       ; 4948  ...into...
        asl     a                       ; 4949  ...bits 3-5
        sta     scratch19               ; 494A  keep it
        lda     groupStatusTable,x      ; 494C  the group entry
        and     #$07                    ; 494F  keep the speed class bits 0-2
        ora     scratch19               ; 4951  insert the new terrain class
        sta     groupStatusTable,x      ; 4953  store the entry
L_4956:
        ldy     scratch18               ; 4956  the member's speed class again
        lda     speedClassRateTable,y   ; 4958  $4066: movement points that class earns per round
        cmp     play3Col44,x            ; 495B  slower than the slowest member so far?
        bcs     L_496D                  ; 495E  no: leave the speed class alone
        sta     play3Col44,x            ; 4960  remember the new lowest rate
        lda     groupStatusTable,x      ; 4963  the group entry
        and     #$F8                    ; 4966  keep the terrain class bits 3-5
        ora     scratch18               ; 4968  insert the slower class into bits 0-2
        sta     groupStatusTable,x      ; 496A  store the entry
L_496D:
        dec     scratch1A               ; 496D  next unit down
        bpl     L_48FC                  ; 496F  until unit 0
        ldx     #$0F                    ; 4971  final pass over the 16 slots
L_4973:
        lda     checksumXorConstant,x   ; 4973  did the group have any living member?  (also trainerPlaybook0200:play3Col12)
        beq     L_4980                  ; 4976  no: the entry stays cleared
        lda     play3Row10,x            ; 4978  last round's entry
        and     #$C0                    ; 497B  bit 7 = group cloaked, bit 6 = group blitzing
        ora     groupStatusTable,x      ; 497D  add them to the freshly computed classes
L_4980:
        sta     groupStatusTable,x      ; 4980  store the entry
        dex                             ; 4983  next slot
        bpl     L_4973                  ; 4984  until slot 0
        rts                             ; 4986  the group table is up to date


; ----------------------------------------------------------------------
; averageGroupMoveVector - averages the outstanding movement of one group.  Over all 100 units it sums
; (unitDestCol & $7F) - unitCol and (unitDestRow & $7F) - unitRow as two sign-extended 16-bit totals,
; counting only living units whose authoritative flag byte has the member bit 6 set and whose group
; key (bits 2-5) equals cmdParam2, and divides both totals by the member count.  Its only caller is
; followGroupMovement, so it runs when a unit joins a group and has to inherit the heading the group
; is already moving on.
; In:  zp_75 cmdParam2 = the group key in bits 2-5 (group number plus side bit); unit arrays $F640
;      column, $F6A4 row, $F708 flags, $F76C type, $F7D0/$F834 destination
; Out: A = member count (Z=1 when the group is empty), X = average column delta, Y = average row
;      delta; zp_18-zp_1C destroyed; self-modifies the sign-extension ADC operands at $49C6 and $49E3
; Called from: followGroupMovement $4A04
; ----------------------------------------------------------------------
averageGroupMoveVector:
        lda     #$00                    ; 4987  clear the accumulators
        sta     scratch18               ; 4989  zp_18/zp_19 = 16-bit sum of the column deltas
        sta     scratch19               ; 498B  high byte of that sum
        sta     scratch1A               ; 498D  zp_1A/zp_1B = 16-bit sum of the row deltas
        sta     scratch1B               ; 498F  high byte of that sum
        sta     scratch1C               ; 4991  zp_1C = number of members found
        ldy     #$63                    ; 4993  walk the units from 99 down to 0
L_4995:
        lda     unitTypeTable,y         ; 4995  type byte
        bmi     L_49E6                  ; 4998  bit 7 = destroyed: not counted
        lda     unitFlagsTable,y        ; 499A  flag byte
        and     #$40                    ; 499D  bit 6 = the unit is in a group
        beq     L_49E6                  ; 499F  not a member
        lda     unitFlagsTable,y        ; 49A1  flag byte again
        and     #$3C                    ; 49A4  group key in bits 2-5
        cmp     cmdParam2               ; 49A6  zp_75 holds the wanted key in the same bit positions
        bne     L_49E6                  ; 49A8  a member of some other group
        inc     scratch1C               ; 49AA  count this member
        ldx     #$00                    ; 49AC  sign-extension byte for a positive delta
        lda     unitDestColTable,y      ; 49AE  destination column
        and     #$7F                    ; 49B1  drop bit 7 = at destination
        sec                             ; 49B3  prepare the subtraction
        sbc     unitColTable,y          ; 49B4  how far this member still has to travel sideways
        bpl     L_49BB                  ; 49B7  positive: extend with 0
        ldx     #$FF                    ; 49B9  negative: extend with $FF
L_49BB:
        stx     L_49C5+1                ; 49BB  patch the sign into the ADC operand at $49C6
        clc                             ; 49BE  clear carry for the 16-bit add
        adc     scratch18               ; 49BF  add the low byte to the running total
        sta     scratch18               ; 49C1  store it
        lda     scratch19               ; 49C3  high byte of the total
L_49C5:
        adc     #$FF                    ; 49C5  operand patched above: add the sign extension plus the carry
        sta     scratch19               ; 49C7  store it
        ldx     #$00                    ; 49C9  the same again for the row
        lda     unitDestRowTable,y      ; 49CB  destination row
        and     #$7F                    ; 49CE  drop bit 7 = repairing
        sec                             ; 49D0  prepare the subtraction
        sbc     unitRowTable,y          ; 49D1  how far this member still has to travel vertically
        bpl     L_49D8                  ; 49D4  positive: extend with 0
        ldx     #$FF                    ; 49D6  negative: extend with $FF
L_49D8:
        stx     L_49E2+1                ; 49D8  patch the sign into the ADC operand at $49E3
        clc                             ; 49DB  clear carry
        adc     scratch1A               ; 49DC  add the low byte
        sta     scratch1A               ; 49DE  store it
        lda     scratch1B               ; 49E0  high byte of the total
L_49E2:
        adc     #$FF                    ; 49E2  operand patched above
        sta     scratch1B               ; 49E4  store it
L_49E6:
        dey                             ; 49E6  next unit down
        bpl     L_4995                  ; 49E7  until unit 0
        lda     scratch18               ; 49E9  column total, low byte
        ldy     scratch19               ; 49EB  column total, high byte (carries the sign)
        ldx     scratch1C               ; 49ED  divide by the number of members
        jsr     divideSigned16by8       ; 49EF  $FCEC: signed 16 by 8 division
        pha                             ; 49F2  keep the average column delta
        lda     scratch1A               ; 49F3  row total, low byte
        ldy     scratch1B               ; 49F5  row total, high byte
        ldx     scratch1C               ; 49F7  the same divisor
        jsr     divideSigned16by8       ; 49F9  average the row delta too
        tay                             ; 49FC  Y = average row delta
        pla                             ; 49FD  the column result
        tax                             ; 49FE  X = average column delta
        lda     scratch1C               ; 49FF  A = member count, Z=1 when the group turned out to be empty
        rts                             ; 4A01  back to followGroupMovement


; ----------------------------------------------------------------------
; followGroupMovement - Gives the unit that is joining a group the movement the rest of the group
; already has: averageGroupMoveVector ($4987) returns the average (destination - position) vector of
; the members and the unit's new destination becomes its own cell plus that vector. The destination is
; applied through the shared destination setter sub_34D5, which clamps it to the map and maintains the
; at-destination bit. A group with no members leaves the unit untouched.
; In:  Y = index of the joining unit; cmdParam2 ($75) = group key (unitFlagsTable bits 2-5), read by
;     averageGroupMoveVector; unit position tables $F640/$F6A4
; Out: cmdParam1/cmdParam2 = the new destination cell, written into unitDestColTable/unitDestRowTable
;     by sub_34D5; scratch18/scratch19 = average column/row step, scratch1D = saved unit index
; Called from: joinUnitToGroup ($4ADE) only, i.e. the executor of command $88 JOIN GROUP
; ----------------------------------------------------------------------
followGroupMovement:
        sty     scratch1D               ; 4A02  remember the joining unit: averageGroupMoveVector needs Y for its own 100-unit scan
        jsr     averageGroupMoveVector  ; 4A04  average (destination - position) over the members of group cmdParam2: A = member count, X = average column step, Y = average row step
        beq     L_4A34                  ; 4A07  branch if the group has no members at all (count 0): leave this unit's destination alone
        stx     scratch18               ; 4A09  keep the group's average column step (signed, may be negative)
        sty     scratch19               ; 4A0B  keep the group's average row step
        ldy     scratch1D               ; 4A0D  point Y back at the joining unit

; ----------------------------------------------------------------------
; The next two instructions look like a bug: the test was meant to be 'is the average vector (dx,dy)
; zero?', but TYA here loads the unit index rather than the average row step that was just stored in
; scratch19.  The result is that the stand-still path below is only reachable for unit index 0, and
; then only when the average column step is zero as well.  It is harmless in the usual case (adding
; zero to the unit's own cell gives the same destination), except for unit 0 joining a group whose
; average dx is 0 and dy is not: that unit is told to stand still.
; ----------------------------------------------------------------------
        tya                             ; 4A0F  A = the unit index (this was probably meant to be LDA scratch19, the average row step)
        ora     scratch18               ; 4A10  OR in the average column step and test the pair for zero
        bne     L_4A21                  ; 4A12  branch unless both bytes are zero - true for every unit except index 0 with a zero average column step

; ----------------------------------------------------------------------
; Stand-still: the destination becomes the unit's own cell.
; ----------------------------------------------------------------------
        lda     unitColTable,y          ; 4A14  column of the cell the unit stands on
        sta     cmdParam1               ; 4A17  cmdParam1 = destination column for sub_34D5
        lda     unitRowTable,y          ; 4A19  row of the cell the unit stands on
        sta     cmdParam2               ; 4A1C  cmdParam2 = destination row
        jmp     L_4A31                  ; 4A1E  go and apply the destination


; ----------------------------------------------------------------------
; Follow the group: destination = own cell + the group's average movement vector.
; ----------------------------------------------------------------------
L_4A21:
        lda     scratch19               ; 4A21  average row step of the group
        clc                             ; 4A23  plain add, no carry in
        adc     unitRowTable,y          ; 4A24  new destination row = own row + average row step
        sta     cmdParam2               ; 4A27  cmdParam2 = destination row
        lda     scratch18               ; 4A29  average column step of the group
        clc                             ; 4A2B  plain add
        adc     unitColTable,y          ; 4A2C  new destination column = own column + average column step
        sta     cmdParam1               ; 4A2F  cmdParam1 = destination column
L_4A31:
        jsr     setUnitDestination      ; 4A31  shared destination setter: clamps to columns/rows 0-39, writes unitDestColTable/unitDestRowTable of unit Y, refreshes the at-destination bit 7 in unitDisplayFlagsTable and stands the unit up (clears the dug-in bit 6 of unitTypeTable)
L_4A34:
        rts                             ; 4A34  back to joinUnitToGroup


; ----------------------------------------------------------------------
; sendGroupMoveToCursor - Fire-button action of the battlefield target cursor while a whole group is
; selected (uiLevel $9209 >= 2). It queues command $81 with the selected unit as the group leader and
; the cell under the cursor (view origin + 3 columns, + 2 rows - the centre of the 7x5 tactical view)
; as the ordered position, then refreshes the leader's local at-destination flag. Unlike the
; single-unit order $80 it leaves the unit's member bit alone, so the leader stays in its group.
; In:  selectedUnit ($90F8) = the group leader, viewOriginCol/viewOriginRow (zp_A5/zp_A6) = top-left
;     cell of the tactical view
; Out: cmdParam0/1/2 = unit, column, row; 4 bytes ($81,unit,col,row) queued in the outgoing command
;     ring by queueCmd4 ($560A); unitDisplayFlags bit 7 of the leader refreshed; C=0 always (the
;     caller tests C)
; Called from: runTargetCursorLoop ($2218), the fire-click branch taken when uiLevel is 2 or more
; ----------------------------------------------------------------------
sendGroupMoveToCursor:
        lda     #$03                    ; 4A35  the cursor sits 3 cells right of the view origin (centre column of the 7-cell wide view)
        clc                             ; 4A37  plain add
        adc     viewOriginCol           ; 4A38  column of the cell under the cursor
        sta     cmdParam1               ; 4A3A  command argument 2 = ordered column
        lda     #$02                    ; 4A3C  the cursor sits 2 cells below the view origin (centre row of the 5-cell high view)
        clc                             ; 4A3E  plain add
        adc     viewOriginRow           ; 4A3F  row of the cell under the cursor
        sta     cmdParam2               ; 4A41  command argument 3 = ordered row
        lda     selectedUnit            ; 4A43  the unit the player selected acts as the group's leader
        sta     cmdParam0               ; 4A46  command argument 1 = leader unit index
        lda     #$81                    ; 4A48  command $81 = move group keeping formation (4 bytes: unit, column, row)
        jsr     queueCmd4               ; 4A4A  queue $81 with its three arguments; it runs on both machines when the exchange is executed
        jsr     updateUnitAtDestinationFlag; 4A4D  immediate local feedback: set bit 7 of unitDisplayFlags[leader] if the ordered cell is the one it already stands on, clear it otherwise
        clc                             ; 4A50  C=0 = the order was accepted
        rts                             ; 4A51  back to the target cursor loop


; ----------------------------------------------------------------------
; moveGroupKeepingFormation - Executor of command $81, run in lock step on both machines. It works out
; the offset between the leader's current destination and the newly ordered cell and subtracts that
; offset from the destination of every live member of the leader's group, so the leader ends up
; exactly on the ordered cell and the relative shape of the formation is preserved. Membership is
; taken from the authoritative unitFlagsTable, not from the UI copy. Members whose shifted destination
; falls off the map are clamped by sub_34D5, which does distort the formation at the map edges.
; In:  cmdParam0 ($73) = leader unit index, cmdParam1/cmdParam2 = ordered column/row (already mirrored
;     for a remote command by mirrorCmdCoordsIfRemote)
; Out: unitDestColTable/unitDestRowTable of every member rewritten through sub_34D5; cmdParam0 is
;     overwritten with the group key, fillEndPtr (zp_4A) is the 99..0 loop counter; self-modifies the
;     SBC operands at $4A8E (column offset) and $4A98 (row offset)
; Called from: cmdMoveGroupRelative ($5020), the handler of command $81
; ----------------------------------------------------------------------
moveGroupKeepingFormation:
        ldy     cmdParam0               ; 4A52  Y = the leader unit named by the command
        lda     unitDestColTable,y      ; 4A54  leader's current destination column
        and     #$7F                    ; 4A57  drop bit 7, the at-destination/idle marker kept in the same byte
        sec                             ; 4A59  borrow clear for a true subtract
        sbc     cmdParam1               ; 4A5A  column offset = leader destination column - ordered column
        sta     L_4A8D+1                ; 4A5C  patch the SBC operand at $4A8E: every member is shifted by this column offset
        lda     unitDestRowTable,y      ; 4A5F  leader's current destination row
        and     #$7F                    ; 4A62  drop the idle marker
        sec                             ; 4A64  borrow clear
        sbc     cmdParam2               ; 4A65  row offset = leader destination row - ordered row
        sta     L_4A97+1                ; 4A67  patch the SBC operand at $4A98 with the row offset
        lda     unitFlagsTable,y        ; 4A6A  authoritative flag byte of the leader
        and     #$3C                    ; 4A6D  keep group number (bits 2-4) and side (bit 5)
        ora     #$40                    ; 4A6F  add the in-a-group bit 6 so the value can be compared against flags & $7C
        sta     cmdParam0               ; 4A71  cmdParam0 now carries the group key instead of the leader index
        lda     #$63                    ; 4A73  start the scan at unit 99, the last slot
        sta     fillEndPtr              ; 4A75  zp_4A is the loop counter over the 100 unit slots

; ----------------------------------------------------------------------
; Walk all 100 unit slots from 99 down to 0 and shift every live member of the leader's group by the
; same offset.  zp_4A holds the index; Y is reloaded from it because sub_34D5 is called inside the
; loop.
; ----------------------------------------------------------------------
L_4A77:
        ldy     fillEndPtr              ; 4A77  index of the unit being considered
        lda     unitTypeTable,y         ; 4A79  its type byte; bit 7 marks a dead or empty slot
        bmi     L_4A9E                  ; 4A7C  skip dead units
        lda     unitFlagsTable,y        ; 4A7E  authoritative flags of this unit
        and     #$7C                    ; 4A81  group number, side and the in-a-group bit
        cmp     cmdParam0               ; 4A83  does it belong to the leader's group?
        bne     L_4A9E                  ; 4A85  no: try the next slot
        lda     unitDestColTable,y      ; 4A87  member's current destination column
        and     #$7F                    ; 4A8A  without the idle marker
        sec                             ; 4A8C  borrow clear
L_4A8D:
        sbc     #$FF                    ; 4A8D  operand patched at $4A5C: subtract the group's column offset
        sta     cmdParam1               ; 4A8F  cmdParam1 = the member's new destination column
        lda     unitDestRowTable,y      ; 4A91  member's current destination row
        and     #$7F                    ; 4A94  without the idle marker
        sec                             ; 4A96  borrow clear
L_4A97:
        sbc     #$FF                    ; 4A97  operand patched at $4A67: subtract the group's row offset
        sta     cmdParam2               ; 4A99  cmdParam2 = the member's new destination row
        jsr     setUnitDestination      ; 4A9B  apply it to unit Y (clamped to 0-39; the leader lands exactly on the ordered cell)
L_4A9E:
        dec     fillEndPtr              ; 4A9E  next lower unit index
        bpl     L_4A77                  ; 4AA0  loop until slot 0 has been handled
        rts                             ; 4AA2  back to the command dispatcher


; ----------------------------------------------------------------------
; addSelectedUnitToGroup - The 'ADDED' half of the group membership editor. isPointWithinGroupRange
; rejects a unit that is more than 8 cells from the group's current bounding box - then 'TOO FAR!' is
; shown and C=1 returned. Otherwise the local flag copy is updated at once so the viewport highlights
; the unit immediately, and command $88 is queued so that both machines perform the join in lock step.
; In:  selectedGroupKey ($9206) = group key (group number in bits 2-4, side in bit 5), selectedUnit
;     ($90F8)
; Out: C=0 accepted: unitDisplayFlags[unit] = (old & $83) | key | $40 and 3 bytes ($88, unit, key)
;     queued by queueCmd3 ($560F); C=1 rejected, with the TOO FAR! message and sound 3
; Called from: the JOIN GROUP menu ($61C6) and the group membership editor ($6483)
; ----------------------------------------------------------------------
addSelectedUnitToGroup:
        lda     selectedGroupKey        ; 4AA3  group being edited (bits 2-4 group number, bit 5 side)
        ldy     selectedUnit            ; 4AA6  Y below $80 means: test this unit's map cell against the members' map cells
        jsr     isPointWithinGroupRange ; 4AA9  refuse a unit further than 8 cells from the group's bounding box
        bcc     L_4AB4                  ; 4AAC  C=0: it is close enough, add it
        jsr     showTooFarMessage       ; 4AAE  C=1: beep and print 'TOO FAR!' in the info panel
        jmp     L_4AD2                  ; 4AB1  return with C=1, nothing changed

L_4AB4:
        ldx     selectedUnit            ; 4AB4  the unit to add
        lda     unitDisplayFlagsTable,x ; 4AB7  its local flag copy, the one the viewport and the editor read
        and     #$83                    ; 4ABA  keep the two facing bits and bit 7 (at destination); drop any old group bits
        ora     selectedGroupKey        ; 4ABC  insert the new group number and side
        ora     #$40                    ; 4ABF  and the in-a-group bit
        sta     unitDisplayFlagsTable,x ; 4AC1  instant local feedback; the authoritative $F708 only changes when command $88 executes
        stx     cmdParam1               ; 4AC4  command argument 1 = unit index
        lda     selectedGroupKey        ; 4AC6  the group it joins
        sta     cmdParam2               ; 4AC9  command argument 2 = group key
        lda     #$88                    ; 4ACB  command $88 = join group (3 bytes: unit, group key)
        jsr     queueCmd3               ; 4ACD  queue it for both machines
        clc                             ; 4AD0  C=0 = added
        rts                             ; 4AD1  back to the editor

L_4AD2:
        sec                             ; 4AD2  C=1 = rejected as too far away
        rts                             ; 4AD3  back to the editor


; ----------------------------------------------------------------------
; joinUnitToGroup - Executor of command $88. It first gives the unit the group's current movement
; through followGroupMovement, then writes the group key and the member bit into the authoritative
; unitFlagsTable and mirrors the new bits into the display copy with syncLocalGroupFlags. Only the two
; facing bits survive the mask, so joining a group also clears the unit's pending first-strike bit 7,
; and the side bit comes from the command argument rather than from the unit.
; In:  cmdParam1 ($74) = unit index, cmdParam2 ($75) = group key, both fetched by readCmdArgs2 in
;     cmdJoinGroup
; Out: unitFlagsTable[unit] = (old & $03) | key | $40 and unitDisplayFlags[unit] resynced; the unit's
;     destination is set to the group's average movement; self-modifies the LDX operand at $4AE2 and
;     the ORA operand at $4AE9
; Called from: cmdJoinGroup ($5198), the handler of command $88
; ----------------------------------------------------------------------
joinUnitToGroup:
        ldy     cmdParam1               ; 4AD4  unit index argument of command $88
        sty     L_4AE1+1                ; 4AD6  patch the LDX # operand at $4AE2 with it - followGroupMovement clobbers X and Y
        lda     cmdParam2               ; 4AD9  group key argument
        sta     L_4AE8+1                ; 4ADB  patch the ORA # operand at $4AE9 with the group key
        jsr     followGroupMovement     ; 4ADE  let the new member pick up the group's average movement (Y still holds the unit index)
L_4AE1:
        ldx     #$FF                    ; 4AE1  operand patched at $4AD6: the joining unit
        lda     unitFlagsTable,x        ; 4AE3  its authoritative flag byte
        and     #$03                    ; 4AE6  keep only the facing bits 0-1; the old group bits and the first-strike bit 7 are dropped
L_4AE8:
        ora     #$FF                    ; 4AE8  operand patched at $4ADB: group number and side from the command
        ora     #$40                    ; 4AEA  mark the unit as being in a group
        sta     unitFlagsTable,x        ; 4AEC  this is the copy both machines agree on
        jmp     syncLocalGroupFlags     ; 4AEF  mirror the new group bits into the display copy $FB54 and return from there


; ----------------------------------------------------------------------
; removeSelectedUnitFromGroup - The 'REMOVED' half of the membership editor. It clears the member bit
; 6 in the selected unit's local flag copy straight away so the viewport highlight updates on the next
; frame, and queues the 2-byte command $89, whose executor cmdLeaveGroup ($519B) clears the same bit
; in the authoritative unitFlagsTable on both machines and re-syncs the copy.
; In:  selectedUnit ($90F8)
; Out: unitDisplayFlags[unit] &= $BF, cmdParam0 = unit, 2 bytes ($89, unit) queued by queueCmd2
;     ($561F)
; Called from: the group membership editor ($64B1)
; ----------------------------------------------------------------------
removeSelectedUnitFromGroup:
        ldy     selectedUnit            ; 4AF2  the unit the cursor is on
        lda     unitDisplayFlagsTable,y ; 4AF5  its local flag copy
        and     #$BF                    ; 4AF8  clear bit 6 so it no longer counts as a group member for the display
        sta     unitDisplayFlagsTable,y ; 4AFA  store it back for immediate feedback
        lda     #$89                    ; 4AFD  command $89 = leave group (2 bytes: unit)
        sty     cmdParam0               ; 4AFF  command argument = unit index
        jmp     queueCmd2               ; 4B01  queue it and return from queueCmd2


; ----------------------------------------------------------------------
; isPointWithinGroupRange - Range test that stops a group from spreading over more than 8 cells. It
; builds the group's bounding box with computeGroupBounds and then demands that the tested point be
; less than 9 cells from each of the four edges of that box (which also bounds the box once the point
; has been added). Y below $80 tests unit Y's map position against the members' positions - used when
; a unit joins; Y = $80 tests the cell picked with the target cursor against the members' destinations
; - used when a member is re-positioned. An empty group always passes.
; In:  A = group key, Y = unit index or $80; pickedCellCol/pickedCellRow ($91D8/$91D9) when Y = $80
; Out: C=0 within range, C=1 too far; scratch18-scratch1D clobbered (bounding box and mode);
;     self-modifies the LDY operand at $4B0F
; Called from: addSelectedUnitToGroup ($4AA9) and orderGroupNewPosition ($4BEE)
; ----------------------------------------------------------------------
isPointWithinGroupRange:
        sty     L_4B0E+1                ; 4B04  patch the LDY # at $4B0F with the mode: a unit index, or $80 = use the picked cell
        jsr     computeGroupBounds      ; 4B07  A and Y are still the arguments: build the group's bounding box (positions or destinations)
        lda     scratch1A               ; 4B0A  number of members found
        beq     L_4B5A                  ; 4B0C  an empty group has no box - accept anything
L_4B0E:
        ldy     #$FF                    ; 4B0E  operand patched at $4B04
        bpl     L_4B1B                  ; 4B10  index below $80: measure the unit's own cell
        ldx     pickedCellCol           ; 4B12  $80: measure the cell picked with the target cursor
        ldy     pickedCellRow           ; 4B15  its row
        jmp     L_4B22                  ; 4B18  on to the four edge tests

L_4B1B:
        ldx     unitColTable,y          ; 4B1B  X = column of the unit being tested
        lda     unitRowTable,y          ; 4B1E  its row
        tay                             ; 4B21  Y = row of the unit being tested

; ----------------------------------------------------------------------
; Four edge tests, each |point - edge| < 9: right edge, left edge, bottom edge, top edge of the
; bounding box.  Any distance of 9 or more fails the whole test.
; ----------------------------------------------------------------------
L_4B22:
        txa                             ; 4B22  column of the tested point
        sec                             ; 4B23  borrow clear
        sbc     scratch1B               ; 4B24  distance to the rightmost member
        bcs     L_4B2C                  ; 4B26  already positive
        eor     #$FF                    ; 4B28  otherwise negate to get the absolute value
        adc     #$01                    ; 4B2A  (two's complement: EOR $FF then +1)
L_4B2C:
        cmp     #$09                    ; 4B2C  a group may never span more than 8 cells
        bcs     L_4B5C                  ; 4B2E  9 or more away from the right edge: too far
        txa                             ; 4B30  column of the tested point again
        sec                             ; 4B31  borrow clear
        sbc     scratch18               ; 4B32  distance to the leftmost member
        bcs     L_4B3A                  ; 4B34  already positive
        eor     #$FF                    ; 4B36  negate
        adc     #$01                    ; 4B38  +1 completes the two's complement
L_4B3A:
        cmp     #$09                    ; 4B3A  same 8-cell limit
        bcs     L_4B5C                  ; 4B3C  too far to the right of the left edge
        tya                             ; 4B3E  row of the tested point
        sec                             ; 4B3F  borrow clear
        sbc     scratch1C               ; 4B40  distance to the bottom-most member
        bcs     L_4B48                  ; 4B42  already positive
        eor     #$FF                    ; 4B44  negate
        adc     #$01                    ; 4B46  +1
L_4B48:
        cmp     #$09                    ; 4B48  8-cell limit
        bcs     L_4B5C                  ; 4B4A  too far below the bottom edge
        tya                             ; 4B4C  row of the tested point again
        sec                             ; 4B4D  borrow clear
        sbc     scratch19               ; 4B4E  distance to the top-most member
        bcs     L_4B56                  ; 4B50  already positive
        eor     #$FF                    ; 4B52  negate
        adc     #$01                    ; 4B54  +1
L_4B56:
        cmp     #$09                    ; 4B56  8-cell limit
        bcs     L_4B5C                  ; 4B58  too far above the top edge
L_4B5A:
        clc                             ; 4B5A  C=0: the point is within 8 cells of all four edges
        rts                             ; 4B5B  accepted

L_4B5C:
        sec                             ; 4B5C  C=1: too far from the group
        rts                             ; 4B5D  rejected


; ----------------------------------------------------------------------
; computeGroupBounds - Scans all 100 unit slots and accumulates the member count and the bounding box
; of one group. Membership is tested on the local flag copy unitDisplayFlagsTable ((flags & $7C) ==
; key | $40) and dead slots are skipped. Bit 7 of the mode byte (the Y register on entry) selects
; whether the box is built from the members' current map cells or from their ordered destinations. An
; empty group leaves min = $FF and max = 0.
; In:  A = group key (group number and side), Y = mode (bit 7 set = use destinations); unitTypeTable,
;     unitDisplayFlagsTable, unitColTable/unitRowTable or unitDestColTable/unitDestRowTable
; Out: A = scratch1A = member count, X = scratch18 = min column, Y = scratch19 = min row, scratch1B =
;     max column, scratch1C = max row, scratch1D = the mode byte; self-modifies the CMP operand at
;     $4B80
; Called from: isPointWithinGroupRange ($4B07) and buildGroupFormationSprite ($30C5), which draws the
;     group's formation ghost from the destination box
; ----------------------------------------------------------------------
computeGroupBounds:
        ora     #$40                    ; 4B5E  add the in-a-group bit to the group key
        sta     L_4B7F+1                ; 4B60  patch the CMP # at $4B80 with the key every member must match
        sty     scratch1D               ; 4B63  remember the mode: bit 7 set = measure destinations, clear = measure positions
        lda     #$00                    ; 4B65  zero
        sta     scratch1A               ; 4B67  member count = 0
        sta     scratch1B               ; 4B69  max column = 0 (only a member can raise it)
        sta     scratch1C               ; 4B6B  max row = 0
        lda     #$FF                    ; 4B6D  $FF
        sta     scratch18               ; 4B6F  min column = $FF so the first member always lowers it
        sta     scratch19               ; 4B71  min row = $FF
        ldx     #$63                    ; 4B73  scan unit slots 99 down to 0

; ----------------------------------------------------------------------
; One pass over the 100 unit slots: count the members of the group and grow the bounding box around
; either their positions or their destinations.
; ----------------------------------------------------------------------
L_4B75:
        lda     unitTypeTable,x         ; 4B75  type byte of this slot
        bmi     L_4BB5                  ; 4B78  bit 7 = dead or empty slot, skip it
        lda     unitDisplayFlagsTable,x ; 4B7A  the UI copy of the flags - this is what the membership editor manipulates
        and     #$7C                    ; 4B7D  group number, side and the in-a-group bit
L_4B7F:
        cmp     #$FF                    ; 4B7F  operand patched at $4B60: is this unit a member of the group?
        bne     L_4BB5                  ; 4B81  no: next slot
        inc     scratch1A               ; 4B83  count the member
        lda     unitColTable,x          ; 4B85  its map column
        bit     scratch1D               ; 4B88  test bit 7 of the mode byte
        bpl     L_4B91                  ; 4B8A  positions requested: keep the map column
        lda     unitDestColTable,x      ; 4B8C  destinations requested: use the ordered column instead
        and     #$7F                    ; 4B8F  without the at-destination marker in bit 7
L_4B91:
        cmp     scratch1B               ; 4B91  beyond the rightmost column so far?
        bcc     L_4B97                  ; 4B93  no
        sta     scratch1B               ; 4B95  grow the box to the right
L_4B97:
        cmp     scratch18               ; 4B97  left of the leftmost column so far?
        bcs     L_4B9D                  ; 4B99  no
        sta     scratch18               ; 4B9B  grow the box to the left
L_4B9D:
        lda     unitRowTable,x          ; 4B9D  its map row
        bit     scratch1D               ; 4BA0  test the mode byte again
        bpl     L_4BA9                  ; 4BA2  positions requested
        lda     unitDestRowTable,x      ; 4BA4  destinations requested: use the ordered row
        and     #$7F                    ; 4BA7  without the at-destination marker
L_4BA9:
        cmp     scratch1C               ; 4BA9  below the bottom-most row so far?
        bcc     L_4BAF                  ; 4BAB  no
        sta     scratch1C               ; 4BAD  grow the box downwards
L_4BAF:
        cmp     scratch19               ; 4BAF  above the top-most row so far?
        bcs     L_4BB5                  ; 4BB1  no
        sta     scratch19               ; 4BB3  grow the box upwards
L_4BB5:
        dex                             ; 4BB5  next lower slot
        bpl     L_4B75                  ; 4BB6  loop until slot 0 has been examined
        lda     scratch1A               ; 4BB8  return the member count in A (Z=1 for an empty group)
        ldx     scratch18               ; 4BBA  X = minimum column of the box
        ldy     scratch19               ; 4BBC  Y = minimum row of the box
        rts                             ; 4BBE  the maxima stay in scratch1B/scratch1C


; ----------------------------------------------------------------------
; showTooFarMessage - Error feedback shared by the two group range checks: selects and clears the
; 14-column info panel (screen columns 25-38, rows 12-21), plays sound 3 (the rejection beep) and
; prints 'TOO FAR!' from the $C000 text overlay on screen row 16, in whatever colour the caller left
; in textColour.
; In:  textColour (zp_3D) and textFillGlyph (zp_3E) as set by the caller
; Out: info panel window selected and blanked, sound 3 queued, the message printed (tail call into
;     printString)
; Called from: addSelectedUnitToGroup ($4AAE) and orderGroupNewPosition ($4BF3)
; ----------------------------------------------------------------------
showTooFarMessage:
        jsr     clearInfoPanel          ; 4BBF  select the info panel window (columns 25-38, rows 12-21) and blank it
        lda     #$03                    ; 4BC2  sound 3 = the short rejection beep
        jsr     playSound               ; 4BC4  queue it with the sound driver
        lda     #$10                    ; 4BC7  screen row 16, in the middle of the info panel
        jsr     setCursorRow            ; 4BC9  move the text cursor there
        lda     #$31                    ; 4BCC  low byte of the string address
        ldy     #$CB                    ; 4BCE  $CB31 = '  TOO FAR!' in the text overlay
        jmp     printString             ; 4BD0  print it and return from printString


; ----------------------------------------------------------------------
; orderGroupNewPosition - NEW POSITION action of the custom-formation editor. It highlights just the
; selected unit, redraws the viewport and runs the target cursor loop, which in this mode
; (cursorTargetMode negative) only reports the picked cell in pickedCellCol/pickedCellRow. If the pick
; was not aborted and lies within 8 cells of the group's destination box it queues command $98
; unit,column,row, refreshes the local at-destination flag, counts the order in the pending-order pair
; $929D/$929E and prints 'RE-POSITIONED'; otherwise it shows TOO FAR! and re-centres the viewport on
; the unit. It always ends by restoring the editor's cursor state, because the cursor loop zeroes
; cursorTargetMode when the pick is aborted.
; In:  selectedUnit ($90F8), selectedGroupKey ($9206), pickedCellCol/pickedCellRow ($91D8/$91D9)
;     filled by runTargetCursorLoop, checkUiContinue ($C451)
; Out: command $98 queued (queueCmd4) or a message shown; highlightedUnit/highlightedGroupKey,
;     lastOrderedGroupKey/pendingGroupOrderCount, cursorTargetMode = $80, textColour/textPadColour =
;     4; self-modifies the string pointer operands at $4C46/$4C48
; Called from: the group membership/formation editor ($64AB)
; ----------------------------------------------------------------------
orderGroupNewPosition:
        lda     #$FF                    ; 4BD3  $FF = no group highlighted ...
        sta     highlightedGroupKey     ; 4BD5  ... so only the single selected unit is drawn highlighted
        lda     selectedUnit            ; 4BD8  the unit being re-positioned
        sta     highlightedUnit         ; 4BDB  highlight it in the viewport
        jsr     drawViewport            ; 4BDE  redraw the 7x5 tactical view with the new highlight
        jsr     runTargetCursorLoop     ; 4BE1  run the target cursor; cursorTargetMode is negative here, so it only reports the picked cell in $91D8/$91D9 and returns
        jsr     checkUiContinue         ; 4BE4  may the modal UI continue? (0 = a screen change, SPACE or the end of the game aborted it)
        beq     L_4BF9                  ; 4BE7  aborted: skip the order and just restore the cursor
        ldy     #$80                    ; 4BE9  $80 = compare the picked cell with the members' destinations, not their positions
        lda     selectedGroupKey        ; 4BEB  the group being edited
        jsr     isPointWithinGroupRange ; 4BEE  is the picked cell within 8 cells of the group's destination box?
        bcc     L_4BFC                  ; 4BF1  C=0: accepted
        jsr     showTooFarMessage       ; 4BF3  C=1: beep and print 'TOO FAR!'
        jsr     recenterViewportOnUnit  ; 4BF6  scroll the viewport back so the unit is under the cursor again
L_4BF9:
        jmp     L_4C4F                  ; 4BF9  join the common tail


; ----------------------------------------------------------------------
; The pick was accepted: send command $98 (move keeping group membership) for this unit.
; ----------------------------------------------------------------------
L_4BFC:
        lda     pickedCellCol           ; 4BFC  column picked with the cursor
        sta     cmdParam1               ; 4BFF  command argument 2 = column
        lda     pickedCellRow           ; 4C01  row picked with the cursor
        sta     cmdParam2               ; 4C04  command argument 3 = row
        ldy     selectedUnit            ; 4C06  the unit being re-positioned
        sty     cmdParam0               ; 4C09  command argument 1 = unit index
        lda     #$98                    ; 4C0B  command $98 = set destination without leaving the group (4 bytes)
        jsr     queueCmd4               ; 4C0D  queue it for both machines
        jsr     updateUnitAtDestinationFlag; 4C10  local feedback: refresh bit 7 of unitDisplayFlags[unit] (at destination or not)

; ----------------------------------------------------------------------
; Count how many re-position orders are outstanding for this group.  The editor at $65E7 waits for
; pendingGroupOrderCount to come back to zero (each executed $98 decrements it in
; setGroupMemberDestination) before it lets the whole group be given a new goal.
; ----------------------------------------------------------------------
        ldy     cmdParam0               ; 4C13  the unit just ordered
        lda     unitDisplayFlagsTable,y ; 4C15  its local flag copy
        and     #$3C                    ; 4C18  group number and side, without the member bit
        cmp     lastOrderedGroupKey     ; 4C1A  same group as the previous NEW POSITION order?
        bne     L_4C25                  ; 4C1D  no: start a new count
        inc     pendingGroupOrderCount  ; 4C1F  yes: one more order outstanding for this group
        jmp     L_4C2D                  ; 4C22  on to the message

L_4C25:
        sta     lastOrderedGroupKey     ; 4C25  remember the group these orders belong to
        lda     #$01                    ; 4C28  this is the first outstanding order ...
        sta     pendingGroupOrderCount  ; 4C2A  ... for the new group

; ----------------------------------------------------------------------
; Confirm the order with a centred 'RE-POSITIONED' in the info panel.
; ----------------------------------------------------------------------
L_4C2D:
        lda     #$99                    ; 4C2D  low byte of $CB99 = 'RE-POSITIONED'
        ldy     #$CB                    ; 4C2F  high byte
        sta     L_4C45+1                ; 4C31  patch the LDA # operand at $4C46 with the string pointer low byte
        sty     L_4C47+1                ; 4C34  patch the LDY # operand at $4C48 with the high byte
        lda     #$04                    ; 4C37  colour 4 (purple) for the confirmation
        sta     textColour              ; 4C39  text colour
        sta     textPadColour           ; 4C3B  and the colour of the blanks that pad the centred line
        jsr     clearInfoPanel          ; 4C3D  blank the info panel
        lda     #$10                    ; 4C40  screen row 16
        jsr     setCursorRow            ; 4C42  move the cursor there
L_4C45:
        lda     #$FF                    ; 4C45  operand patched at $4C31
L_4C47:
        ldy     #$FF                    ; 4C47  operand patched at $4C34
        jsr     printStringCentred      ; 4C49  print the string centred in the 14-column panel
        jsr     recenterViewportOnUnit  ; 4C4C  scroll the viewport back onto the unit

; ----------------------------------------------------------------------
; Common tail: the target cursor loop zeroes cursorTargetMode when the pick was aborted, so the
; editor's 'free cell pick' mode and its two cursor sprites are set up again here.
; ----------------------------------------------------------------------
L_4C4F:
        lda     #$80                    ; 4C4F  $80 = free cell pick mode
        sta     cursorTargetMode        ; 4C51  restore it for the next round of the editor
        jsr     setCursorShapeSquare    ; 4C53  sprite 0 = the hollow square cell cursor
        jmp     showCursorBoxSprite     ; 4C56  sprite 2 = the centre box of the viewport, and return from there


; ----------------------------------------------------------------------
; setGroupMemberDestination - Executor of command $98. If the unit's group key matches the group
; remembered in lastOrderedGroupKey it decrements pendingGroupOrderCount - this is the acknowledgement
; the formation editor waits for - and then sets the unit's destination to the ordered cell through
; sub_34D5. Unlike command $80 it never touches the group bits, so the re-positioned unit stays a
; member of its group.
; In:  cmdParam0 ($73) = unit index, cmdParam1/cmdParam2 = column/row (already mirrored for a remote
;     command), lastOrderedGroupKey ($929D)
; Out: unitDestColTable/unitDestRowTable of the unit written via sub_34D5; pendingGroupOrderCount
;     ($929E) decremented when the group matches
; Called from: cmdMoveKeepGroup ($5476), the handler of command $98
; ----------------------------------------------------------------------
setGroupMemberDestination:
        ldy     cmdParam0               ; 4C59  the unit named by the command
        lda     unitDisplayFlagsTable,y ; 4C5B  its local flag copy
        and     #$3C                    ; 4C5E  group number and side
        cmp     lastOrderedGroupKey     ; 4C60  is it the group the local editor is waiting for?
        bne     L_4C68                  ; 4C63  no: this order was not counted
        dec     pendingGroupOrderCount  ; 4C65  one of the outstanding re-position orders has now been executed
L_4C68:
        jmp     setUnitDestination      ; 4C68  set the destination (clamped, at-destination bit maintained) and return from there; the group bits are left alone


; ----------------------------------------------------------------------
; syncLocalGroupFlags - Copies the authoritative group bits of unit X (unitFlagsTable & $7C: group
; number, side and member bit) into the local flag copy unitDisplayFlagsTable, keeping that byte's own
; bits 0, 1 and 7. Called after every command that changes group membership and when a unit is created
; or reset, so the UI copy can never drift away from the simulation.
; In:  X = unit index
; Out: unitDisplayFlags[X] = (old & $83) | (unitFlagsTable[X] & $7C); self-modifies the ORA operand at
;     $4C79
; Called from: the move executor ($34D0), unit creation ($3DFA), joinUnitToGroup ($4AEF) and
;     cmdLeaveGroup ($51A8)
; ----------------------------------------------------------------------
syncLocalGroupFlags:
        lda     unitFlagsTable,x        ; 4C6B  authoritative flag byte of the unit
        and     #$7C                    ; 4C6E  group number (bits 2-4), side (bit 5) and the in-a-group bit 6
        sta     L_4C78+1                ; 4C70  patch the ORA # operand at $4C79 with those bits
        lda     unitDisplayFlagsTable,x ; 4C73  the local copy the display reads
        and     #$83                    ; 4C76  keep its facing bits 0-1 and the at-destination bit 7
L_4C78:
        ora     #$FF                    ; 4C78  operand patched at $4C70: the group bits from the simulation
        sta     unitDisplayFlagsTable,x ; 4C7A  store the merged byte
        rts                             ; 4C7D  back to the caller


; formationRotateRules - two 3-byte rule sets that turn the FORMATION menu item into a coordinate
; transform for rotateGroupFormation.  Index = the rotation mode carried by command $A3 (0 SWEEP LEFT,
; 1 SWEEP RIGHT, 2 REVERSE).  $4C7E[mode] describes what happens to the member's column offset dx,
; $4C81[mode] what happens to its row offset dy: bit 7 = negate the component, bit 0 = store it as the
; new row offset ($75) instead of the new column offset ($74).  The three modes are therefore (dy,-dx)
; = 90 degrees anticlockwise, (-dy,dx) = 90 degrees clockwise and (-dx,-dy) = 180 degrees.
formationRotateRules:
        .byte   $81,$01,$80             ; 4C7E  ...  rule for the column offset: mode 0 negate+store as row, mode 1 store as row, mode 2 negate+store as column
formationRotateRulesDy:
        .byte   $00,$80,$81             ; 4C81  ...  rule for the row offset: mode 0 store as column, mode 1 negate+store as column, mode 2 negate+store as row

; ----------------------------------------------------------------------
; rotateGroupFormation - Executor of command $A3 - the SWEEP LEFT / SWEEP RIGHT / REVERSE items of the
; FORMATION menu. It rotates the whole formation about the leader's destination cell: for every live
; member of the leader's group the offset (dx,dy) from that cell is transformed with the two rule
; bytes of formationRotateRules and the new destination leader+offset is applied with sub_34D5. During
; the setup phase the resulting row is clamped into the issuing player's own half of the map, so a
; rotation can never push units across the middle line.
; In:  cmdParam1 ($74) = leader unit index, cmdParam2 ($75) = rotation mode 0-2 (both from
;     readCmdArgs2), cmdFromOpponent ($76) bit 7, gamePhase (zp_B1)
; Out: unitDestColTable/unitDestRowTable of every member rewritten through sub_34D5;
;     cmdParam1/cmdParam2 are reused as the working destination; ten self-modified immediates: $4CC4
;     loop index, $4CD0 group key, $4CDA/$4CFE leader column, $4CDC/$4CE5 column rule, $4CEF/$4D05
;     leader row, $4CF1/$4CFA row rule
; Called from: cmdSetFormation ($5599), the handler of command $A3
; ----------------------------------------------------------------------
rotateGroupFormation:
        ldy     cmdParam1               ; 4C84  the leader unit named by the command
        lda     unitDestColTable,y      ; 4C86  leader's destination column - the formation turns about this cell
        and     #$7F                    ; 4C89  drop the at-destination marker in bit 7
        sta     L_4CD9+1                ; 4C8B  patch the SBC at $4CDA: leader column, used to get each member's dx
        sta     L_4CFD+1                ; 4C8E  patch the LDA # at $4CFE: same value, used to rebuild the absolute column
        lda     unitDestRowTable,y      ; 4C91  leader's destination row
        and     #$7F                    ; 4C94  drop the at-destination marker
        sta     L_4CEE+1                ; 4C96  patch the SBC at $4CEF: leader row, used to get each member's dy
        sta     L_4D04+1                ; 4C99  patch the LDA # at $4D05: same value for the absolute row
        lda     unitFlagsTable,y        ; 4C9C  authoritative flags of the leader
        and     #$3C                    ; 4C9F  group number (bits 2-4) and side (bit 5)
        ora     #$40                    ; 4CA1  add the in-a-group bit so it can be compared with flags & $7C
        sta     L_4CCF+1                ; 4CA3  patch the CMP # at $4CD0 with the group key
        ldy     cmdParam2               ; 4CA6  rotation mode 0-2 (0 SWEEP LEFT, 1 SWEEP RIGHT, 2 REVERSE)
        lda     formationRotateRules,y  ; 4CA8  rule byte for the column offset
        sta     L_4CDB+1                ; 4CAB  patch the LDX # at $4CDC: bit 7 of the rule decides whether dx is negated
        and     #$01                    ; 4CAE  bit 0 of the rule = which parameter byte the transformed value goes to
        sta     L_4CE4+1                ; 4CB0  patch the LDX # at $4CE5: 0 = store as column, 1 = store as row
        lda     formationRotateRulesDy,y; 4CB3  rule byte for the row offset
        sta     L_4CF0+1                ; 4CB6  patch the LDX # at $4CF1: bit 7 = negate dy
        and     #$01                    ; 4CB9  bit 0 of that rule
        sta     L_4CF9+1                ; 4CBB  patch the LDX # at $4CFA: destination parameter for the transformed dy
        ldy     #$63                    ; 4CBE  start at unit slot 99
        sty     L_4CC3+1                ; 4CC0  the loop index lives in the LDY # operand at $4CC4 (Y is needed by sub_34D5)

; ----------------------------------------------------------------------
; Walk all 100 unit slots.  For every live member of the leader's group: take its offset from the
; leader's destination, run the offset through the rotation rules, add it back to the leader's
; destination and order the member to the resulting cell.
; ----------------------------------------------------------------------
L_4CC3:
        ldy     #$FF                    ; 4CC3  operand patched at $4CC0 and decremented at $4D24: the unit being considered
        lda     unitTypeTable,y         ; 4CC5  its type byte
        bmi     L_4D24                  ; 4CC8  bit 7 = dead or empty slot, skip it
        lda     unitFlagsTable,y        ; 4CCA  authoritative flags
        and     #$7C                    ; 4CCD  group number, side and member bit
L_4CCF:
        cmp     #$FF                    ; 4CCF  operand patched at $4CA3: a member of the leader's group?
        bne     L_4D24                  ; 4CD1  no: next slot
        lda     unitDestColTable,y      ; 4CD3  member's destination column
        and     #$7F                    ; 4CD6  without the at-destination marker
        sec                             ; 4CD8  borrow clear
L_4CD9:
        sbc     #$FF                    ; 4CD9  operand patched at $4C8B: dx = member column - leader column
L_4CDB:
        ldx     #$FF                    ; 4CDB  operand patched at $4CAB: only the N flag of the rule byte matters here
        bpl     L_4CE4                  ; 4CDD  rule bit 7 clear: keep dx as it is
        eor     #$FF                    ; 4CDF  rule bit 7 set: negate dx ...
        clc                             ; 4CE1  ... carry clear ...
        adc     #$01                    ; 4CE2  ... +1 completes the two's complement
L_4CE4:
        ldx     #$FF                    ; 4CE4  operand patched at $4CB0: 0 = the transformed dx becomes the new column offset, 1 = the new row offset
        sta     cmdParam1,x             ; 4CE6  store it into cmdParam1 ($74) or cmdParam2 ($75)
        lda     unitDestRowTable,y      ; 4CE8  member's destination row
        and     #$7F                    ; 4CEB  without the at-destination marker
        sec                             ; 4CED  borrow clear
L_4CEE:
        sbc     #$FF                    ; 4CEE  operand patched at $4C96: dy = member row - leader row
L_4CF0:
        ldx     #$FF                    ; 4CF0  operand patched at $4CB6: N flag of the row rule
        bpl     L_4CF9                  ; 4CF2  rule bit 7 clear: keep dy
        eor     #$FF                    ; 4CF4  negate dy ...
        clc                             ; 4CF6  ... carry clear ...
        adc     #$01                    ; 4CF7  ... +1
L_4CF9:
        ldx     #$FF                    ; 4CF9  operand patched at $4CBB: which parameter byte receives the transformed dy
        sta     cmdParam1,x             ; 4CFB  store it into cmdParam1 or cmdParam2
L_4CFD:
        lda     #$FF                    ; 4CFD  operand patched at $4C8E: leader's destination column
        clc                             ; 4CFF  plain add
        adc     cmdParam1               ; 4D00  absolute column = leader column + transformed column offset
        sta     cmdParam1               ; 4D02  cmdParam1 = the member's new destination column
L_4D04:
        lda     #$FF                    ; 4D04  operand patched at $4C99: leader's destination row
        clc                             ; 4D06  plain add
        adc     cmdParam2               ; 4D07  absolute row = leader row + transformed row offset

; ----------------------------------------------------------------------
; During the setup phase each side may only place units in its own half of the map, so a rotation that
; would carry a unit across the middle line (row 20) is clamped.  The local player always owns rows
; 20-39 of his own view, the opponent rows 0-19.
; ----------------------------------------------------------------------
        ldx     gamePhase               ; 4D09  0 = the battle is running, non-zero = setup/menu phase
        beq     L_4D1F                  ; 4D0B  in battle a rotation may use the whole map
        ldx     cmdFromOpponent         ; 4D0D  did this command come from the opponent?
        bpl     L_4D19                  ; 4D0F  no: it is our own order
        cmp     #$14                    ; 4D11  remote order: rows must stay in the top half (0-19)
        bcc     L_4D1F                  ; 4D13  already there
        lda     #$13                    ; 4D15  clamp to row 19, the last row of the opponent's half
        bne     L_4D1F                  ; 4D17  always taken
L_4D19:
        cmp     #$14                    ; 4D19  own order: rows must stay in the bottom half (20-39)
        bcs     L_4D1F                  ; 4D1B  already there
        lda     #$14                    ; 4D1D  clamp to row 20, the first row of our own half
L_4D1F:
        sta     cmdParam2               ; 4D1F  cmdParam2 = the member's new destination row
        jsr     setUnitDestination      ; 4D21  order unit Y to that cell (clamped to the map, at-destination bit maintained)
L_4D24:
        dec     L_4CC3+1                ; 4D24  next lower unit slot, straight in the LDY operand
        bpl     L_4CC3                  ; 4D27  loop until slot 0 has been handled
        rts                             ; 4D29  back to the command dispatcher


; commandHandlerTable - 39 little-endian handler addresses, one per command byte $80-$A6.  The
; dispatcher in executeTurnCommands indexes it with (command & $3F) * 2 and copies the entry into the
; operand of the JSR at $4EEE.  The $8D entry is $FFFF (rendered as irqVectorHi) because that command
; is consumed by the packet mergers and never reaches the dispatcher.  Handlers marked 'immediate'
; below are likewise executed while the packets are merged.
commandHandlerTable:
D_4D2B                   = commandHandlerTable+1
        .addr   cmdMoveUnit             ; 4D2A  $80 MOVE unit (4 bytes: unit, column, row)
        .byte   $1A                     ; 4D2C  $81 move group keeping formation (4 bytes)
        .byte   $50                     ; 4D2D  P
        .byte   $23                     ; 4D2E  $82 cloak on, single unit (2 bytes)
        .byte   $50                     ; 4D2F  P
        .byte   $98                     ; 4D30  $83 cloak on, whole group (2 bytes)
        .byte   $50                     ; 4D31  P
        .byte   $4B                     ; 4D32  $84 cloak off, single unit (2 bytes)
        .byte   $50                     ; 4D33  P
        .byte   $D2                     ; 4D34  $85 cloak off, whole group (2 bytes)
        .byte   $50                     ; 4D35  P
        .byte   $5D                     ; 4D36  $86 clear a boomer's target (2 bytes)
        .byte   $51                     ; 4D37  Q
        .byte   $6B                     ; 4D38  $87 set a boomer's target (3 bytes)
        .byte   $51                     ; 4D39  Q
        .byte   $85                     ; 4D3A  $88 join group (3 bytes: unit, group key)
        .byte   $51                     ; 4D3B  Q
        .byte   $9B                     ; 4D3C  $89 leave group (2 bytes: unit)
        .byte   $51                     ; 4D3D  Q
        .byte   $AB                     ; 4D3E  $8A place unit at a cell (4 bytes) - the setup phase
        .byte   $51                     ; 4D3F  Q
        .byte   $EE                     ; 4D40  $8B set the map seed (4 bytes: 3 seed bytes)
        .byte   $51                     ; 4D41  Q
        .byte   $3E                     ; 4D42  $8C session control: abort, pause, resume, disconnect (2 bytes: reason)
        .byte   $52                     ; 4D43  R
        .byte   $FF                     ; 4D44  $8D drone position report - never dispatched: the mergers run it at once, so the slot holds $FFFF
        .byte   $FF                     ; 4D45  .
        .byte   $36                     ; 4D46  $8E detonate the drone (3 bytes: column, row)
        .byte   $53                     ; 4D47  S
        .byte   $7B                     ; 4D48  $8F release drone control (1 byte)
        .byte   $53                     ; 4D49  S
        .byte   $C0                     ; 4D4A  $90 comcen missile strike (3 bytes: column, row)
        .byte   $53                     ; 4D4B  S
        .byte   $2E                     ; 4D4C  $91 self destruct, unit or its whole group (2 bytes)
        .byte   $54                     ; 4D4D  T
        .byte   $8B                     ; 4D4E  $92 the drone reached its target (1 byte)
        .byte   $53                     ; 4D4F  S
        .byte   $70                     ; 4D50  $93 blitz on, single unit (2 bytes)
        .byte   $50                     ; 4D51  P
        .byte   $AA                     ; 4D52  $94 blitz on, whole group (2 bytes)
        .byte   $50                     ; 4D53  P
        .byte   $62                     ; 4D54  $95 blitz off, single unit (2 bytes)
        .byte   $50                     ; 4D55  P
        .byte   $BE                     ; 4D56  $96 blitz off, whole group (2 bytes)
        .byte   $50                     ; 4D57  P
        .byte   $A3                     ; 4D58  $97 drone launched (1 byte)
        .byte   $53                     ; 4D59  S
        .byte   $70                     ; 4D5A  $98 move but keep group membership (4 bytes)
        .byte   $54                     ; 4D5B  T
        .byte   $B1                     ; 4D5C  $99 menu-level link poll (1 byte) - the handler is the link keep-alive itself
        .byte   $56                     ; 4D5D  V
        .byte   $79                     ; 4D5E  $9A add energy to a unit (3 bytes: unit, amount)
        .byte   $54                     ; 4D5F  T
        .byte   $9E                     ; 4D60  $9B halt every member of a group (2 bytes)
        .byte   $54                     ; 4D61  T
        .byte   $E1                     ; 4D62  $9C set the recycler mode and cell (4 bytes)
        .byte   $54                     ; 4D63  T
        .byte   $11                     ; 4D64  $9D choose a side (2 bytes)
        .byte   $55                     ; 4D65  U
        .byte   $0C                     ; 4D66  $9E comcen repaired / setup finished (1 byte)
        .byte   $52                     ; 4D67  R
        .byte   $48                     ; 4D68  $9F dig in, single unit (2 bytes)
        .byte   $55                     ; 4D69  U
        .byte   $4C                     ; 4D6A  $A0 dig out, single unit (2 bytes)
        .byte   $55                     ; 4D6B  U
        .byte   $6D                     ; 4D6C  $A1 dig in, whole group (2 bytes)
        .byte   $55                     ; 4D6D  U
        .byte   $71                     ; 4D6E  $A2 dig out, whole group (2 bytes)
        .byte   $55                     ; 4D6F  U
        .byte   $96                     ; 4D70  $A3 rotate the formation (3 bytes: leader, mode 0-2)
        .byte   $55                     ; 4D71  U
        .byte   $9C                     ; 4D72  $A4 set the game type / option byte (2 bytes)
        .byte   $55                     ; 4D73  U
        .byte   $A5                     ; 4D74  $A5 set one setup option (3 bytes: kind, value)
        .byte   $55                     ; 4D75  U
        .byte   $EA                     ; 4D76  $A6 set a custom map seed (4 bytes) - $51EA, rendered as raw bytes because the table was typed one entry short
        .byte   $51                     ; 4D77  Q

; commandLengthTable - total length in bytes (the command byte included) of each command
; $80-$A6, indexed by command & $3F.  getCommandLength is the only reader; the packet mergers,
; the dispatcher, the outgoing packet builder and the comm module all use it to step over a command
; they do not want to look inside.
commandLengthTable:
        .byte   $04,$04,$02,$02,$02,$02,$02,$03; 4D78  ........  $80-$87: move 4, group move 4, cloak on/off and group cloak on/off 2 each, clear target 2, set target 3
        .byte   $03                     ; 4D80  $88-$8F: join group 3, leave group 2, place unit 4, map seed 4, session control 2, drone report 4, detonate 3, release drone 1
        .byte   $02,$04,$04,$02,$04,$03,$01; 4D81  .......
        .byte   $03                     ; 4D88  $90-$97: missile 3, self destruct 2, drone arrived 1, blitz on 2, group blitz on 2, blitz off 2, group blitz off 2, drone launched 1
        .byte   $02,$01,$02,$02,$02,$02,$01; 4D89  .......
        .byte   $04                     ; 4D90  $98-$9F: move keeping group 4, menu poll 1, add energy 3, halt group 2, recycler 4, choose side 2, setup ready 1, dig in 2
        .byte   $01,$03,$02,$04,$02,$01,$02; 4D91  .......
        .byte   $02                     ; 4D98  $A0-$A6: dig out 2, group dig in 2, group dig out 2, formation 3, game type 2, setup option 3, custom map seed 4
        .byte   $02,$02,$03,$02,$03,$04 ; 4D99  ......

; ----------------------------------------------------------------------
; getCommandLength - Looks the total length (command byte plus its parameters) of command A up in
; commandLengthTable and leaves it in currentCommandLength as well as in A.
; In:  A = command byte $80-$A6 (bit 7 is ignored)
; Out: A = currentCommandLength ($92EE) = 1..4, X = the table index (command & $3F)
; Called from: the dispatcher ($4ECF), advancePacketCursor ($4F5F), the outgoing packet builder
;     ($565C) and the solo trainer ($EA4C, $EA92)
; ----------------------------------------------------------------------
getCommandLength:
        and     #$3F                    ; 4D9F  strip bit 7: commands $80-$A6 become indices $00-$26
        tax                             ; 4DA1  index the length table with it
        lda     commandLengthTable,x    ; 4DA2  1, 2, 3 or 4 bytes
        sta     currentCommandLength    ; 4DA5  the mergers and the dispatcher use this to skip to the next command
        rts                             ; 4DA8  length also returned in A


; ----------------------------------------------------------------------
; serviceCommandExchange - Per-frame entry point of the lock-step command exchange, called from the
; main loop of every screen and from the overlays. It services the status-message system and then,
; unless the 10-frame throttle timer linkPollDelay is still running, falls into
; processCommandExchange. During film playback the throttle is ignored so records are consumed as fast
; as their time stamps allow.
; In:  filmPlaybackMode ($0B9B) bit 7, linkPollDelay ($9163) counted down by the raster IRQ
; Out: as processCommandExchange, plus the status line refreshed
; Called from: the map/battlefield/console loops ($1AB0, $21B2, $600D), waitForExchangeIdle ($5915),
;     the map generator overlay and the text engine ($C76E)
; ----------------------------------------------------------------------
serviceCommandExchange:
        jsr     updateStatusMessage     ; 4DA9  let the status line show/expire the current message
        bit     filmPlaybackMode        ; 4DAC  is a game film being played back?
        bmi     processCommandExchange  ; 4DAF  yes: never throttle, the film's own time stamps do the pacing
        ldy     linkPollDelay           ; 4DB1  frames still to wait before the next packet exchange
        beq     processCommandExchange  ; 4DB4  timer expired: try to exchange
        rts                             ; 4DB6  still throttled, come back next frame


; ----------------------------------------------------------------------
; processCommandExchange - The heart of the lock-step protocol. During film playback it reads records
; from the film buffer whose time stamp (exchange slot commandSliceIndex plus the game clock) has
; come, copies their command bytes into the merged command buffer and executes them; a record header
; of $FE stops the film where it is and $FF runs the end-of-film handler. In live play it waits for
; the comm module to report a completed exchange ($E01D bit 6), then merges the packet just sent and
; the packet just received into one buffer - always with player 0's commands first, so both machines
; execute the same sequence - and falls into executeTurnCommands.
; In:  filmPlaybackMode ($0B9B), linkSessionState ($0B9C), gameEndReason ($0BA8), playerSide ($0B9F),
;     the comm module's $E01D/$E03B, film pointer zp_BC/zp_BD, commandSliceIndex ($923C), gameClock
;     ($91CB)
; Out: mergedCmdBuffer filled and executed;
;     mergedCmdLength/mergedCmdSplitIndex/linkPollDelay/linkTimeoutCounter/mergeFillIndex set;
;     self-modifies $4DE9 (a copy of the time-stamp byte), $4DF4 (the previous record's clock) and
;     $4E20 (offset of the first command byte in the record)
; Called from: serviceCommandExchange (fall-through and branches) and updateGameFrame ($41EE), which
;     calls it once every ten units of the round
; ----------------------------------------------------------------------
processCommandExchange:
        lda     filmPlaybackMode        ; 4DB7  bit 7 = a game film is being replayed
        bpl     L_4E39                  ; 4DBA  clear: this is a live game, use the link

; ----------------------------------------------------------------------
; Film playback.  Records are consumed from the film buffer until one is found whose time stamp is
; still in the future; several records may share one time stamp, so this is a loop.
; ----------------------------------------------------------------------
L_4DBC:
        lda     gameEndReason           ; 4DBC  0 while the game runs
        bne     L_4DC9                  ; 4DBF  the game has ended: stop feeding records, only keep the link alive
        jsr     compareFilmPtrToEnd     ; 4DC1  has the film pointer reached the recorded end?
        bcc     L_4DD2                  ; 4DC4  no: there is another record to look at
L_4DC6:
        jsr     finishFilmPlayback      ; 4DC6  end of the buffer: restore the final scores and clock and start the end sequence

; ----------------------------------------------------------------------
; Nothing to execute this frame.  If a link session exists (a film can be watched while the modem link
; is still up) give the menu-level packet handler a turn.
; ----------------------------------------------------------------------
L_4DC9:
        lda     linkSessionState        ; 4DC9  0 = no link, $80 = link up at menu level, 2 = game session
        beq     L_4DD1                  ; 4DCC  no link at all: just return
        jmp     pollMenuSession         ; 4DCE  service the menu-level link (carrier check plus keep-alive) and return from there

L_4DD1:
        rts                             ; 4DD1  nothing to do this frame


; ----------------------------------------------------------------------
; Read the next film record.  Layout: [ splitIndex<<4 | length ] [ 1 or 2 time-stamp bytes ] [ length
; command bytes ].  The time-stamp byte holds the exchange slot within the round in its low nibble and
; the negated clock delta in its high nibble; a zero high nibble means the next byte is the absolute
; game clock.  Header $FE = the film ends here, $FF = the recorded game was aborted (a real header can
; never reach $EF because a merged buffer holds at most 14 bytes).
; ----------------------------------------------------------------------
L_4DD2:
        ldy     #$00                    ; 4DD2  offset 0 of the record
        jsr     readFilmByte            ; 4DD4  read it from the film buffer in the RAM hidden under the I/O area
        sta     mergedCmdLength         ; 4DD7  park the header byte in mergedCmdLength for now
        iny                             ; 4DDA  offset 1
        jsr     readFilmByte            ; 4DDB  the time-stamp byte
        sta     L_4DE8+1                ; 4DDE  patch the LDA # at $4DE9 so the byte can be re-read without touching the film again
        and     #$0F                    ; 4DE1  low nibble = the exchange slot within the round (commandSliceIndex, one exchange per ten units processed)
        cmp     commandSliceIndex       ; 4DE3  only replay this record when the round has reached the same slot it was recorded in
        bne     L_4DC9                  ; 4DE6  this record belongs to another slot: not yet
L_4DE8:
        lda     #$FF                    ; 4DE8  operand patched at $4DDE: the time-stamp byte again
        lsr     a                       ; 4DEA  shift the high nibble down ...
        lsr     a                       ; 4DEB  ...
        lsr     a                       ; 4DEC  ...
        lsr     a                       ; 4DED  ... A = the clock delta code
        beq     L_4DF8                  ; 4DEE  0 = the long form: an absolute clock byte follows
        ora     #$F0                    ; 4DF0  sign-extend the 4-bit delta to a negative byte
        sec                             ; 4DF2  the writer subtracted one extra, add it back
L_4DF3:
        adc     #$00                    ; 4DF3  operand patched at $4E01: the clock of the previous record, giving this record's clock
        jmp     L_4DFC                  ; 4DF5  join the clock comparison

L_4DF8:
        iny                             ; 4DF8  long form: offset 2
        jsr     readFilmByte            ; 4DF9  read the absolute game clock of the record
L_4DFC:
        cmp     gameClock               ; 4DFC  is this record due now?
        bne     L_4DC9                  ; 4DFF  no, it is in the future: come back next frame
        sta     L_4DF3+1                ; 4E01  remember this clock as the base for the next record's delta
        iny                             ; 4E04  step past the time stamp
        sty     L_4E1F+1                ; 4E05  patch the LDY # at $4E20: offset of the first command byte in this record
        lda     mergedCmdLength         ; 4E08  the header byte again
        cmp     #$FE                    ; 4E0B  $FE = the recorded game reached its own end
        beq     L_4DD1                  ; 4E0D  freeze here: return every frame so the last picture stays up
        bcs     L_4DC6                  ; 4E0F  $FF = the recorded game was aborted: run the end-of-film handler
        pha                             ; 4E11  keep the header byte
        and     #$0F                    ; 4E12  low nibble = number of command bytes in the record
        sta     mergedCmdLength         ; 4E14  that is the length of the merged buffer for this exchange
        pla                             ; 4E17  header byte back
        lsr     a                       ; 4E18  shift the high nibble down ...
        lsr     a                       ; 4E19  ...
        lsr     a                       ; 4E1A  ...
        lsr     a                       ; 4E1B  ... A = where player 1's commands start
        sta     mergedCmdSplitIndex     ; 4E1C  the dispatcher flips the owner flag at this index
L_4E1F:
        ldy     #$FF                    ; 4E1F  operand patched at $4E05: first command byte of the record
        ldx     #$00                    ; 4E21  write into the merged buffer from index 0

; ----------------------------------------------------------------------
; Copy the record's command bytes out of the film buffer into the merged command buffer.
; ----------------------------------------------------------------------
L_4E23:
        jsr     readFilmByte            ; 4E23  next byte of the record
        sta     mergedCmdBuffer,x       ; 4E26  into the merged command buffer
        iny                             ; 4E29  next film offset
        inx                             ; 4E2A  next buffer index
        cpx     mergedCmdLength         ; 4E2B  all command bytes copied?
        bcc     L_4E23                  ; 4E2E  no: keep copying
        jsr     advanceFilmPtr          ; 4E30  advance the film pointer by the whole record (Y = its length)
        jsr     executeTurnCommands     ; 4E33  execute the commands exactly as a live exchange would
        jmp     L_4DBC                  ; 4E36  look for another record with the same time stamp


; ----------------------------------------------------------------------
; Live play.  Everything below runs once per completed packet exchange with the opponent (or with the
; solo trainer, which fakes the same exchange).
; ----------------------------------------------------------------------
L_4E39:
        lda     linkSessionState        ; 4E39  link state: 0 none, 2 game session agreed, $80 link up at menu level, $FF lost
        bpl     L_4E41                  ; 4E3C  bit 7 clear: a game session is running, do the full exchange
        jmp     pollMenuSession         ; 4E3E  menu level: only the carrier check and the menu packet handler, then return

L_4E41:
        jsr     exitIfGameEnded         ; 4E41  returns without coming back here if the game is already over
        bne     L_4E55                  ; 4E44  the end-of-game countdown is running: no more exchanges
        lda     isLinkActive            ; 4E46  comm module flag: is the link (UART or trainer) running at all?  (also trainerAiE000:isLinkActive)
        beq     L_4E55                  ; 4E49  no link: nothing to exchange

; ----------------------------------------------------------------------
; Wait for the comm module to report 'this frame's packets have been swapped' (bit 6 of the exchange
; flag byte $E01D).  Normally the routine simply returns and is called again next frame; once the
; watchdog has been waiting for about half a minute checkLinkTimeout returns C=1 and the game
; busy-waits here with PHONE TROUBLE on the status line until the packet arrives or the watchdog gives
; up and returns to the main menu.
; ----------------------------------------------------------------------
L_4E4B:
        bit     exchangeFlags           ; 4E4B  $E01D bit 6 = the exchange is complete  (also trainerAiE000:commInterfaceBlock)
        bvs     L_4E56                  ; 4E4E  both packets are in: go and merge them
        jsr     checkLinkTimeout        ; 4E50  arm/advance the link watchdog (it counts in the raster IRQ)
        bcs     L_4E4B                  ; 4E53  C=1 = already overdue: spin here instead of giving the frame back
L_4E55:
        rts                             ; 4E55  not ready yet, try again next frame


; ----------------------------------------------------------------------
; Both packets are available: reset the timers and merge the two command streams.
; ----------------------------------------------------------------------
L_4E56:
        lda     sndVoiceSoundId         ; 4E56  sound currently playing on the voice
        cmp     #$15                    ; 4E59  $15 = the 'waiting for the opponent' sound
        bne     L_4E62                  ; 4E5B  some other sound: leave it alone
        lda     #$00                    ; 4E5D  sound 0 = silence
        jsr     playSound               ; 4E5F  stop the waiting sound now that the opponent answered
L_4E62:
        ldy     #$0A                    ; 4E62  10 frames ...
        sty     linkPollDelay           ; 4E64  ... before the next exchange is attempted
        ldx     #$00                    ; 4E67  0 also clears bit 7 ...
        stx     linkTimeoutCounter      ; 4E69  ... which stops the IRQ advancing the link watchdog
        stx     mergeFillIndex          ; 4E6C  the merged command buffer starts out empty
        lda     playerSide              ; 4E6E  which side is this machine playing?
        bne     L_4E81                  ; 4E71  player 1: the opponent's commands go first
        jsr     mergeOutgoingPacket     ; 4E73  player 0: our own commands go into the buffer first
        ldx     mergeFillIndex          ; 4E76  bytes written so far
        stx     mergedCmdSplitIndex     ; 4E78  = the index at which player 1's commands start
        jsr     mergeIncomingPacket     ; 4E7B  then append the packet received from the opponent
        jmp     L_4E8C                  ; 4E7E  on to the dispatcher

L_4E81:
        jsr     mergeIncomingPacket     ; 4E81  player 1: the opponent is player 0, so his commands go first
        ldx     mergeFillIndex          ; 4E84  bytes written so far
        stx     mergedCmdSplitIndex     ; 4E86  = the index at which player 1's (our own) commands start
        jsr     mergeOutgoingPacket     ; 4E89  then append the copy of what we just sent
L_4E8C:
        ldx     mergeFillIndex          ; 4E8C  total number of bytes in the merged buffer
        stx     mergedCmdLength         ; 4E8E  and fall straight into executeTurnCommands

; ----------------------------------------------------------------------
; executeTurnCommands - Runs every command of one exchange. It walks the merged buffer from index 0 to
; mergedCmdLength, keeping cmdFromOpponent up to date: the owner flag starts at 'remote' or 'own'
; depending on which side this machine plays and is toggled when the read index reaches
; mergedCmdSplitIndex, the boundary between the two players' blocks. Own commands decrement the
; outstanding-order count. A byte with bit 7 set is dispatched through commandHandlerTable with the
; self-modified JSR at $4EEE; a byte with bit 7 clear is a cancelled command (a handler may overwrite
; its own command byte, e.g. $8E becomes $0E at $534F) and is skipped by its table length. In live
; play the whole exchange is then appended to the game film, the packet is acknowledged and the next
; outgoing packet is built and sent.
; In:  mergedCmdBuffer ($908F), mergedCmdLength ($92EB), mergedCmdSplitIndex ($92EC), playerSide
;     ($0B9F), filmPlaybackMode ($0B9B)
; Out: the game state changed by the handlers; mergedCmdReadIndex, mergedCmdOwnerFlag,
;     cmdFromOpponent, exchangeActivityFlag bit 0 and pendingOwnCmdCount updated; in live play the
;     film grows and the next packet is transmitted
; Called from: processCommandExchange - by fall-through for a live exchange and by JSR at $4E33 for a
;     film record
; ----------------------------------------------------------------------
executeTurnCommands:
        lda     #$00                    ; 4E91  start at the first byte ...
        sta     mergedCmdReadIndex      ; 4E93  ... of the merged command buffer
        ldy     playerSide              ; 4E96  which side is this machine?
        beq     L_4E9D                  ; 4E99  player 0: the first block of commands is our own (owner flag 0)
        lda     #$80                    ; 4E9B  player 1: the first block belongs to player 0, i.e. the opponent
L_4E9D:
        sta     mergedCmdOwnerFlag      ; 4E9D  owner of the block currently being executed

; ----------------------------------------------------------------------
; Command loop.  Every command in the buffer is executed on both machines in exactly this order, which
; is what keeps the two simulations identical.
; ----------------------------------------------------------------------
L_4EA0:
        ldy     mergedCmdReadIndex      ; 4EA0  index of the next command byte
        cpy     mergedCmdLength         ; 4EA3  past the end of the merged buffer?
        bcs     L_4EF4                  ; 4EA6  yes: the exchange has been executed
        lda     exchangeActivityFlag    ; 4EA8  activity flag read by the round timer
        ora     #$01                    ; 4EAB  bit 0 = at least one command was executed this round
        sta     exchangeActivityFlag    ; 4EAD  in the setup phase the clock only ticks while something happens
        cpy     mergedCmdSplitIndex     ; 4EB0  have we reached the boundary between the two players' commands?
        bne     L_4EBD                  ; 4EB3  no: same owner as the previous command
        lda     mergedCmdOwnerFlag      ; 4EB5  current owner
        eor     #$80                    ; 4EB8  flip between own ($00) and opponent ($80)
        sta     mergedCmdOwnerFlag      ; 4EBA  everything from here belongs to the other player
L_4EBD:
        lda     mergedCmdOwnerFlag      ; 4EBD  owner of this command
        sta     cmdFromOpponent         ; 4EC0  the handlers test bit 7 of this to mirror coordinates and to suppress local-only side effects
        bmi     L_4EC7                  ; 4EC2  a command from the opponent: nothing of ours to account for
        dec     pendingOwnCmdCount      ; 4EC4  one of our own queued orders has now been executed
L_4EC7:
        ldy     mergedCmdReadIndex      ; 4EC7  index of the command byte
        lda     mergedCmdBuffer,y       ; 4ECA  the command byte itself
        bmi     L_4EDB                  ; 4ECD  bit 7 set = a live command, dispatch it

; ----------------------------------------------------------------------
; A byte with bit 7 clear is a command that was cancelled after it was queued (a handler can clear bit
; 7 of its own byte in the buffer, e.g. cmdDroneDetonate writes $0E over its $8E). Its length is still
; looked up so the reader can step over the whole command.
; ----------------------------------------------------------------------
        jsr     getCommandLength        ; 4ECF  look the length up anyway - the low 6 bits still identify the command
        clc                             ; 4ED2  plain add
        adc     mergedCmdReadIndex      ; 4ED3  skip the whole command ...
        sta     mergedCmdReadIndex      ; 4ED6  ... without executing it
        bne     L_4EA0                  ; 4ED9  always taken (the index can never wrap in a 14-byte buffer)

; ----------------------------------------------------------------------
; Dispatch: copy the handler address out of commandHandlerTable into the operand of the JSR below and
; call it.  The handler itself reads its parameters with readCmdArg1/2/3, which also advance
; mergedCmdReadIndex to the next command.
; ----------------------------------------------------------------------
L_4EDB:
        lda     mergedCmdBuffer,y       ; 4EDB  the command byte
        and     #$3F                    ; 4EDE  $80-$A6 becomes index $00-$26
        asl     a                       ; 4EE0  two bytes per table entry
        tax                             ; 4EE1  X = byte offset into commandHandlerTable
        lda     commandHandlerTable,x   ; 4EE2  low byte of the handler address
        sta     L_4EEE+1                ; 4EE5  patch the JSR operand at $4EEF
        lda     D_4D2B,x                ; 4EE8  high byte of the handler address
        sta     L_4EEE+2                ; 4EEB  patch the JSR operand at $4EF0
L_4EEE:
        jsr     irqVectorHi             ; 4EEE  call the handler (the $FFFF shown here is only the unpatched placeholder from the $8D table slot)
        jmp     L_4EA0                  ; 4EF1  on to the next command


; ----------------------------------------------------------------------
; End of the exchange.  While a film is being replayed there is nothing to record and no packet to
; answer, so the routine simply returns.
; ----------------------------------------------------------------------
L_4EF4:
        lda     filmPlaybackMode        ; 4EF4  bit 7 = film playback
        bpl     L_4EFA                  ; 4EF7  live game: record and answer
        rts                             ; 4EF9  film playback: done

L_4EFA:
        jsr     recordExchangeToFilm    ; 4EFA  append this exchange (header, time stamp, command bytes) to the film in the RAM under I/O
        ldx     #$02                    ; 4EFD  comm module function 2 = acknowledge/consume the completed exchange
        jsr     commJumpTable           ; 4EFF  clears $E01D/$E01E/$E01F so the next exchange can start  (also trainerAiE000:commRequestEntry)
        jmp     sendOutgoingPacket      ; 4F02  build the next outgoing packet from the command ring and hand it to the comm module


; ----------------------------------------------------------------------
; mergeIncomingPacket - Appends the packet just received from the opponent ($E020, $E01E bytes) to the
; merged command buffer, marking the block as remote. Commands are copied whole, their length taken
; from commandLengthTable. Three command bytes are special: $97 (drone launched) also clears the drone
; flags, $92 (drone arrived) sets them, while $8D (the opponent's drone position report) and $8C
; (session control: abort, pause, resume) are executed here and now and never enter the buffer; the
; abort case of $8C even unwinds this loop by dropping two return addresses.
; In:  the comm module's receive buffer $E020-$E026 and its length $E01E, mergeFillIndex (zp_69) =
;     bytes already in the merged buffer
; Out: mergedCmdBuffer filled from mergeFillIndex on, mergeFillIndex advanced, packetReadIndex (zp_6A)
;     used as the cursor, cmdFromOpponent = $80, droneArrivalFlags possibly changed
; Called from: processCommandExchange ($4E7B for player 0, $4E81 for player 1)
; ----------------------------------------------------------------------
mergeIncomingPacket:
        lda     rxPacketLength          ; 4F05  number of bytes the opponent sent this exchange  (also trainerAiE000:rxPacketLength)
        beq     L_4F5D                  ; 4F08  empty packet: nothing to merge
        lda     #$80                    ; 4F0A  $80 = these commands come from the opponent ...
        sta     cmdFromOpponent         ; 4F0C  ... so cmdDroneMove and cmdSessionControl mirror their coordinates
        ldy     #$00                    ; 4F0E  start at the first byte of the packet
        sty     packetReadIndex         ; 4F10  zp_6A is the read cursor inside the packet

; ----------------------------------------------------------------------
; Walk the received packet command by command.
; ----------------------------------------------------------------------
L_4F12:
        ldy     packetReadIndex         ; 4F12  offset of the next command in the packet
        lda     rxPacketBuffer,y        ; 4F14  its command byte  (also trainerAiE000:rxPacketBuffer)
        jsr     advancePacketCursor     ; 4F17  look up its length and point zp_6A at the following command
        cmp     #$97                    ; 4F1A  $97 = the opponent launched his drone
        bne     L_4F23                  ; 4F1C  not $97
        ldx     #$00                    ; 4F1E  clear all drone state ...
        stx     droneArrivalFlags       ; 4F20  ... a new drone flight starts; the $97 command itself is copied into the buffer as well
L_4F23:
        cmp     #$8D                    ; 4F23  $8D = the opponent's drone position report
        bne     L_4F2E                  ; 4F25  not $8D
        iny                             ; 4F27  step to its first parameter byte
        jsr     cmdDroneMove            ; 4F28  run it immediately (it only moves the drone sprite and the view) and do not copy it
        jmp     L_4F56                  ; 4F2B  on to the next command

L_4F2E:
        cmp     #$8C                    ; 4F2E  $8C = session control (abort, pause, resume, disconnect)
        bne     L_4F3C                  ; 4F30  not $8C
        iny                             ; 4F32  step to the reason byte
        lda     rxPacketBuffer,y        ; 4F33  read it  (also trainerAiE000:rxPacketBuffer)
        jsr     cmdSessionControl       ; 4F36  act on it at once - an abort must work even mid-buffer - and do not copy it
        jmp     L_4F56                  ; 4F39  on to the next command

L_4F3C:
        cmp     #$92                    ; 4F3C  $92 = the opponent's drone reached its target
        bne     L_4F45                  ; 4F3E  not $92
        lda     #$80                    ; 4F40  remember it in the drone flags ...
        sta     droneArrivalFlags       ; 4F42  ... the drone screen watches this byte
L_4F45:
        ldx     mergeFillIndex          ; 4F45  X = where in the merged buffer this command goes

; ----------------------------------------------------------------------
; Copy the whole command (byte plus parameters) into the merged buffer.
; ----------------------------------------------------------------------
L_4F47:
        lda     rxPacketBuffer,y        ; 4F47  next byte of the command  (also trainerAiE000:rxPacketBuffer)
        sta     mergedCmdBuffer,x       ; 4F4A  into the merged command buffer
        inc     mergeFillIndex          ; 4F4D  the buffer grew by one byte
        inx                             ; 4F4F  next buffer position
        iny                             ; 4F50  next packet position
        dec     currentCommandLength    ; 4F51  one byte of this command done
        bne     L_4F47                  ; 4F54  keep going until the whole command has been copied
L_4F56:
        ldy     packetReadIndex         ; 4F56  the cursor advancePacketCursor left
        cpy     rxPacketLength          ; 4F58  reached the end of the packet?  (also trainerAiE000:rxPacketLength)
        bcc     L_4F12                  ; 4F5B  no: next command
L_4F5D:
        rts                             ; 4F5D  the received packet has been merged


; ----------------------------------------------------------------------
; advancePacketCursor - Helper of both packet mergers: keeps the command byte in A, looks its length
; up and leaves packetReadIndex pointing at the next command in the packet.
; In:  A = command byte, Y = its offset inside the packet
; Out: A unchanged, packetReadIndex (zp_6A) = Y + length, currentCommandLength = the length
; Called from: mergeIncomingPacket ($4F17) and mergeOutgoingPacket ($4F7D)
; ----------------------------------------------------------------------
advancePacketCursor:
        pha                             ; 4F5E  save the command byte - getCommandLength destroys A
        jsr     getCommandLength        ; 4F5F  A = its total length, also stored in currentCommandLength
        tya                             ; 4F62  offset of this command
        clc                             ; 4F63  plain add
        adc     currentCommandLength    ; 4F64  plus its length
        sta     packetReadIndex         ; 4F67  = offset of the next command in the packet
        pla                             ; 4F69  hand the command byte back
        rts                             ; 4F6A  the caller compares it against the special command bytes


; ----------------------------------------------------------------------
; mergeOutgoingPacket - Appends the copy of the packet this machine just sent ($E027, $E01F bytes) to
; the merged buffer, marking the block as own, so the local commands are executed at exactly the same
; point in the sequence as on the opponent's machine. $8D is not copied - it only re-centres the
; viewport on the drone and closes one outstanding order - and $8C is executed at once, like in the
; incoming merger.
; In:  the comm module's transmit copy $E027-$E02D and its length $E01F, mergeFillIndex (zp_69)
; Out: mergedCmdBuffer filled, mergeFillIndex advanced, packetReadIndex used as the cursor,
;     cmdFromOpponent = 0, viewOriginCol/viewOriginRow or pendingOwnCmdCount possibly changed
; Called from: processCommandExchange ($4E73 for player 0, $4E89 for player 1)
; ----------------------------------------------------------------------
mergeOutgoingPacket:
        lda     txPacketLength          ; 4F6B  number of bytes in the packet we just sent  (also trainerAiE000:txPacketLength)
        beq     L_4FC3                  ; 4F6E  we sent nothing: nothing to merge
        lda     #$00                    ; 4F70  0 = these are our own commands ...
        sta     cmdFromOpponent         ; 4F72  ... so their coordinates are already in our own frame of reference
        ldy     #$00                    ; 4F74  start at the first byte
        sty     packetReadIndex         ; 4F76  zp_6A is the read cursor inside the packet

; ----------------------------------------------------------------------
; Walk our own packet command by command.
; ----------------------------------------------------------------------
L_4F78:
        ldy     packetReadIndex         ; 4F78  offset of the next command
        lda     txPacketBuffer,y        ; 4F7A  its command byte  (also trainerAiE000:txPacketBuffer)
        jsr     advancePacketCursor     ; 4F7D  look up its length and point zp_6A at the next command
        cmp     #$8D                    ; 4F80  $8D = our own drone position report
        bne     L_4F94                  ; 4F82  not $8D
        lda     droneViewOriginCol      ; 4F84  column the drone screen chose ...
        sta     viewOriginCol           ; 4F87  ... becomes the tactical view origin ...
        lda     droneViewOriginRow      ; 4F89  ... together with the row ...
        sta     viewOriginRow           ; 4F8C  ... so the view follows the drone
L_4F8E:
        dec     pendingOwnCmdCount      ; 4F8E  the report counted as an outstanding order when it was queued
        jmp     L_4FBC                  ; 4F91  and it is not copied into the merged buffer

L_4F94:
        cmp     #$8C                    ; 4F94  $8C = session control
        bne     L_4FA2                  ; 4F96  not $8C
        iny                             ; 4F98  step to the reason byte
        lda     txPacketBuffer,y        ; 4F99  read it  (also trainerAiE000:txPacketBuffer)
        jsr     cmdSessionControl       ; 4F9C  act on our own pause/abort at once, then also count it off
        jmp     L_4F8E                  ; 4F9F  and skip the copy

L_4FA2:
        cmp     #$92                    ; 4FA2  $92 = our drone reached its target
        bne     L_4FAB                  ; 4FA4  not $92
        lda     #$80                    ; 4FA6  set the drone flag ...
        sta     droneArrivalFlags       ; 4FA8  ... the drone screen watches this byte; the command is copied as well
L_4FAB:
        ldx     mergeFillIndex          ; 4FAB  X = where in the merged buffer this command goes

; ----------------------------------------------------------------------
; Copy the whole command into the merged buffer.
; ----------------------------------------------------------------------
L_4FAD:
        lda     txPacketBuffer,y        ; 4FAD  next byte of the command  (also trainerAiE000:txPacketBuffer)
        sta     mergedCmdBuffer,x       ; 4FB0  into the merged buffer
        iny                             ; 4FB3  next packet position
        inx                             ; 4FB4  next buffer position
        inc     mergeFillIndex          ; 4FB5  the buffer grew by one byte
        dec     currentCommandLength    ; 4FB7  one byte of this command done
        bne     L_4FAD                  ; 4FBA  until the whole command has been copied
L_4FBC:
        ldy     packetReadIndex         ; 4FBC  the cursor advancePacketCursor left
        cpy     txPacketLength          ; 4FBE  end of our packet?  (also trainerAiE000:txPacketLength)
        bcc     L_4F78                  ; 4FC1  no: next command
L_4FC3:
        rts                             ; 4FC3  our own packet has been merged


; ----------------------------------------------------------------------
; readCmdArgs3 - Argument reader for the 4-byte commands $80, $81, $8A, $8B, $98, $9C and $A6: takes
; the first parameter into cmdParam0 and falls through into readCmdArgs2, which picks up the other
; two. The read index ends up on the next command, matching commandLengthTable.
; In:  mergedCmdReadIndex ($90B7) = offset of the command byte in mergedCmdBuffer
; Out: cmdParam0/cmdParam1/cmdParam2 ($73/$74/$75) = the three parameters, mergedCmdReadIndex advanced
;     by 4, A/Y clobbered
; Called from: cmdMoveUnit ($5011), cmdMoveGroupRelative ($501A), cmdPlaceUnit ($51AB), cmdSetMapSeed
;     ($51F3), cmdMoveKeepGroup ($5470) and cmdSetRecycler ($54E1)
; ----------------------------------------------------------------------
readCmdArgs3:
        ldy     mergedCmdReadIndex      ; 4FC4  offset of the command byte
        iny                             ; 4FC7  first parameter follows it
        lda     mergedCmdBuffer,y       ; 4FC8  read it
        sta     cmdParam0               ; 4FCB  cmdParam0, usually the unit or group
        inc     mergedCmdReadIndex      ; 4FCD  count that byte, then fall into readCmdArgs2 for the remaining two

; ----------------------------------------------------------------------
; readCmdArgs2 - Argument reader for the 3-byte commands $87, $88, $8E, $90, $9A, $A3 and $A5, and the
; tail of readCmdArgs3. It reads the two bytes that follow the command byte into cmdParam1/cmdParam2
; and leaves the read index on the next command (advance 3, or 4 when entered from readCmdArgs3).
; In:  mergedCmdReadIndex ($90B7), mergedCmdBuffer ($908F)
; Out: cmdParam1/cmdParam2 ($74/$75), mergedCmdReadIndex advanced, A/Y clobbered
; Called from: cmdSetTarget ($516B), cmdJoinGroup ($5185), cmdDroneDetonate ($5336), cmdMissileStrike
;     ($53C0), cmdAddEnergy ($5479), cmdSetFormation ($5596), cmdSetupOption ($55A5) and readCmdArgs3
; ----------------------------------------------------------------------
readCmdArgs2:
        ldy     mergedCmdReadIndex      ; 4FD0  current read offset (already past parameter 1 when entered from $4FC4)
        iny                             ; 4FD3  next byte
        lda     mergedCmdBuffer,y       ; 4FD4  read it
        sta     cmdParam1               ; 4FD7  cmdParam1, usually the map column
        iny                             ; 4FD9  next byte
        lda     mergedCmdBuffer,y       ; 4FDA  read it
        sta     cmdParam2               ; 4FDD  cmdParam2, usually the map row
        iny                             ; 4FDF  step past it
        sty     mergedCmdReadIndex      ; 4FE0  the read index now points at the next command byte
        rts                             ; 4FE3  back to the handler


; ----------------------------------------------------------------------
; mirrorCmdCoordsIfRemote - Point mirror for the map coordinates carried by a command. Both machines
; run one shared simulation while each player sees his own side at the bottom of the map, so every
; coordinate that arrives over the link has to be rotated 180 degrees (39-x, 39-y) before it is used.
; Own commands are left alone.
; In:  cmdFromOpponent (zp_76) bit 7, cmdParam1 = column, cmdParam2 = row
; Out: cmdParam1/cmdParam2 replaced by 39-column/39-row for a remote command, untouched for an own
;     one; A clobbered
; Called from: cmdMoveUnit, cmdMoveGroupRelative, cmdPlaceUnit, cmdMissileStrike, cmdMoveKeepGroup,
;     cmdSetRecycler and mirrorCmdCoordsIfRemoteDec
; ----------------------------------------------------------------------
mirrorCmdCoordsIfRemote:
        bit     cmdFromOpponent         ; 4FE4  did this command come from the opponent?
        bpl     L_4FF6                  ; 4FE6  no: our own coordinates are already in the local frame
        lda     cmdParam1               ; 4FE8  the column
        jsr     mirrorMapCoordinate     ; 4FEA  39 - column
        sta     cmdParam1               ; 4FED  back into cmdParam1
        lda     cmdParam2               ; 4FEF  the row
        jsr     mirrorMapCoordinate     ; 4FF1  39 - row
        sta     cmdParam2               ; 4FF4  back into cmdParam2
L_4FF6:
        rts                             ; 4FF6  coordinates are now in this machine's frame of reference


; ----------------------------------------------------------------------
; readCmdArg1 - Argument reader for all the 2-byte commands - the per-unit and per-group orders
; $82-$86, $89, $8C, $91, $93-$96, $9B, $9D, $9F-$A2 and $A4. It reads the single byte after the
; command byte into cmdParam0 and steps the read index to the next command.
; In:  mergedCmdReadIndex ($90B7), mergedCmdBuffer ($908F)
; Out: cmdParam0 ($73) = the unit index or group key, mergedCmdReadIndex advanced by 2, A/Y clobbered
; Called from: the cloak/blitz/dig/group handlers ($5031, $504B, $5062, $507E, $50FA, $515D, $519B,
;     $542E, $549E, $5511, $555D, $5576, $559C)
; ----------------------------------------------------------------------
readCmdArg1:
        ldy     mergedCmdReadIndex      ; 4FF7  offset of the command byte
        iny                             ; 4FFA  its single parameter follows
        lda     mergedCmdBuffer,y       ; 4FFB  read it
        sta     cmdParam0               ; 4FFE  cmdParam0 = unit index or group key
        iny                             ; 5000  step past it
        sty     mergedCmdReadIndex      ; 5001  the read index now points at the next command
        rts                             ; 5004  back to the handler


; ----------------------------------------------------------------------
; mirrorCmdCoordsIfRemoteDec - Coordinate mirror for the drone, which covers a 2x2 block of cells and
; is addressed by its top-left corner. For a remote command it mirrors the corner and then decrements
; both coordinates, giving 38-x / 38-y, which is the top-left corner of the mirrored block. No-op for
; an own command.
; In:  cmdFromOpponent (zp_76), cmdParam1/cmdParam2 = the drone's top-left cell
; Out: cmdParam1/cmdParam2 = 38-column / 38-row for a remote command; A clobbered
; Called from: cmdDroneMove ($52DC) and cmdDroneDetonate ($5359)
; ----------------------------------------------------------------------
mirrorCmdCoordsIfRemoteDec:
        lda     cmdFromOpponent         ; 5005  did this command come from the opponent?
        bpl     L_5010                  ; 5007  no: leave the coordinates alone
        jsr     mirrorCmdCoordsIfRemote ; 5009  mirror the corner cell (39-x, 39-y)
        dec     cmdParam1               ; 500C  the mirrored 2x2 block starts one column earlier ...
        dec     cmdParam2               ; 500E  ... and one row earlier
L_5010:
        rts                             ; 5010  back to the drone handler


; ----------------------------------------------------------------------
; cmdMoveUnit - Handler of command $80 MOVE (4 bytes: unit, column, row), the ordinary move order
; given by clicking a cell with a single unit selected. It reads and mirrors the arguments and
; tail-jumps into applyMoveOrderOld ($34C6), which drops the unit out of any group, re-syncs its
; display flags and sets the destination.
; In:  the merged command buffer and its read index; cmdFromOpponent for the mirroring
; Out: unit destination/waypoint tables updated via $34C6
; Called from: reached only through commandHandlerTable, entry $80
; ----------------------------------------------------------------------
cmdMoveUnit:
        jsr     readCmdArgs3            ; 5011  unit, column and row into cmdParam0/1/2
        jsr     mirrorCmdCoordsIfRemote ; 5014  a move ordered by the opponent is expressed in his own view of the map
        jmp     applyMoveOrder          ; 5017  leave the group, refresh the flags and set the destination


; ----------------------------------------------------------------------
; cmdMoveGroupRelative - Handler of command $81 (4 bytes: unit, column, row) - the move order given
; while a whole group is selected. It reads and mirrors the arguments and tail-jumps into
; moveGroupKeepingFormation, which shifts every member of the named unit's group by the same offset so
; the formation is kept.
; In:  the merged command buffer and its read index; cmdFromOpponent
; Out: destinations of all members of the leader's group
; Called from: reached only through commandHandlerTable, entry $81
; ----------------------------------------------------------------------
cmdMoveGroupRelative:
        jsr     readCmdArgs3            ; 501A  leader unit, ordered column and row
        jsr     mirrorCmdCoordsIfRemote ; 501D  mirror the ordered cell for a command from the opponent
        jmp     moveGroupKeepingFormation; 5020  shift the whole group by leader-destination minus ordered cell


; ----------------------------------------------------------------------
; cmdCloakOn - Handler of command $82 CLOAK ON (2 bytes: unit). Cloak and blitz are mutually
; exclusive, so the unit's blitz bits are cleared first by calling cmdBlitzOff, which consumes the
; same argument; the read index is therefore saved in a self-modified operand and restored before the
; argument is read again. SPY units (type 3) are permanently cloaked and are rejected, everybody else
; gets bits 6-7 of unitPathColTable set (cloak ordered plus cloak active) and falls into the shared
; tail at $5058.
; In:  the merged command buffer; unitTypeTable for the SPY test
; Out: unitPathColTable[unit] |= $C0 and unitPathRowTable[unit] &= $3F; pendingRedrawUnit and
;     comcenCloakedFlag through the tail; self-modifies the LDA operand at $502D
; Called from: reached only through commandHandlerTable, entry $82
; ----------------------------------------------------------------------
cmdCloakOn:
        ldy     mergedCmdReadIndex      ; 5023  current read index in the merged buffer
        sty     L_502C+1                ; 5026  patch the LDA # at $502D with it - cmdBlitzOff will consume the argument too
        jsr     cmdBlitzOff             ; 5029  clear the unit's blitz bits first: a unit cannot be cloaked and blitzing at once
L_502C:
        lda     #$FF                    ; 502C  operand patched at $5026: the saved read index
        sta     mergedCmdReadIndex      ; 502E  rewind so the same argument can be read again
        jsr     readCmdArg1             ; 5031  cmdParam0 = the unit
        ldy     cmdParam0               ; 5034  Y = unit index
        lda     unitTypeTable,y         ; 5036  its type byte
        and     #$07                    ; 5039  keep only the type, dropping the dead and dug-in bits
        cmp     #$03                    ; 503B  type 3 = SPY
        beq     L_504A                  ; 503D  spies are always cloaked: nothing to do
        lda     unitPathColTable,y      ; 503F  waypoint column byte, whose top two bits hold the cloak state
        ora     #$C0                    ; 5042  bit 7 = cloak active, bit 6 = cloak ordered
        sta     unitPathColTable,y      ; 5044  the unit is now cloaked
        jmp     L_5058                  ; 5047  join the shared tail

L_504A:
        rts                             ; 504A  a SPY needs no cloak order


; ----------------------------------------------------------------------
; cmdCloakOff - Handler of command $84 CLOAK OFF (2 bytes: unit); also used as a subroutine by
; cmdBlitzOn. It clears bits 6-7 of unitPathColTable. The shared tail at $5058 notes the unit in
; pendingRedrawUnit for an own command only - the opponent's cloaking must not trigger a redraw here -
; and then falls into updateComcenCloakLock. There is no SPY exemption on this side, unlike in the
; group handler.
; In:  cmdParam0 after readCmdArg1, cmdFromOpponent
; Out: unitPathColTable[unit] &= $3F; pendingRedrawUnit ($923B) = unit for an own command;
;     comcenCloakedFlag refreshed
; Called from: commandHandlerTable entry $84, and cmdBlitzOn ($5076)
; ----------------------------------------------------------------------
cmdCloakOff:
        jsr     readCmdArg1             ; 504B  cmdParam0 = the unit
        ldy     cmdParam0               ; 504E  Y = unit index
        lda     unitPathColTable,y      ; 5050  waypoint column byte
        and     #$3F                    ; 5053  clear bits 6-7: cloak off
        sta     unitPathColTable,y      ; 5055  store it back

; ----------------------------------------------------------------------
; Shared tail of the single-unit cloak handlers: only our own cloak changes are worth a local redraw,
; and the console's inspect lock has to follow the state of our own comcen.
; ----------------------------------------------------------------------
L_5058:
        lda     cmdFromOpponent         ; 5058  did this command come from the opponent?
        bmi     L_505F                  ; 505A  yes: no local redraw
        sty     pendingRedrawUnit       ; 505C  ask the per-frame tick loop to redraw this unit
L_505F:
        jmp     updateComcenCloakLock   ; 505F  refresh 'my comcen is cloaked' and return from there


; ----------------------------------------------------------------------
; cmdBlitzOff - Handler of command $95 BLITZ OFF (2 bytes: unit); also used as a subroutine by
; cmdCloakOn. It clears bits 6-7 of unitPathRowTable, the blitz ordered/active pair.
; In:  the merged command buffer and its read index
; Out: unitPathRowTable[unit] &= $3F; cmdParam0 = unit, Y = unit
; Called from: commandHandlerTable entry $95, and cmdCloakOn ($5029)
; ----------------------------------------------------------------------
cmdBlitzOff:
        jsr     readCmdArg1             ; 5062  cmdParam0 = the unit
        ldy     cmdParam0               ; 5065  Y = unit index
        lda     unitPathRowTable,y      ; 5067  waypoint row byte, whose top two bits hold the blitz state
        and     #$3F                    ; 506A  clear bits 6-7: no longer blitzing
        sta     unitPathRowTable,y      ; 506C  store it back
        rts                             ; 506F  back to the dispatcher (or to cmdCloakOn)


; ----------------------------------------------------------------------
; cmdBlitzOn - Handler of command $93 BLITZ ON (2 bytes: unit), the mirror image of cmdCloakOn. It
; saves and restores the read index around a call to cmdCloakOff (blitz cancels cloak), rejects SPY
; units, sets bits 6-7 of unitPathRowTable and tail-jumps into applyBlitzStepCost, which pays one
; energy point for the extra 25 movement points.
; In:  the merged command buffer; unitTypeTable for the SPY test
; Out: unitPathRowTable[unit] |= $C0, unitPathColTable[unit] &= $3F, energy and movement points
;     through $3EE2; self-modifies the LDA operand at $507A
; Called from: reached only through commandHandlerTable, entry $93
; ----------------------------------------------------------------------
cmdBlitzOn:
        ldy     mergedCmdReadIndex      ; 5070  current read index
        sty     L_5079+1                ; 5073  patch the LDA # at $507A - cmdCloakOff consumes the argument as well
        jsr     cmdCloakOff             ; 5076  clear the cloak bits first: a unit cannot be cloaked and blitzing at once
L_5079:
        lda     #$FF                    ; 5079  operand patched at $5073: the saved read index
        sta     mergedCmdReadIndex      ; 507B  rewind so the argument can be read again
        jsr     readCmdArg1             ; 507E  cmdParam0 = the unit
        ldy     cmdParam0               ; 5081  Y = unit index
        lda     unitTypeTable,y         ; 5083  its type byte
        and     #$07                    ; 5086  keep only the type
        cmp     #$03                    ; 5088  type 3 = SPY
        beq     L_5097                  ; 508A  spies cannot blitz
        lda     unitPathRowTable,y      ; 508C  waypoint row byte
        ora     #$C0                    ; 508F  bit 7 = blitz active, bit 6 = blitz ordered
        sta     unitPathRowTable,y      ; 5091  the unit is now blitzing
        jmp     applyBlitzStepCost      ; 5094  charge the energy and grant the extra movement points, then return from there

L_5097:
        rts                             ; 5097  a SPY cannot blitz: nothing to do


; ----------------------------------------------------------------------
; cmdGroupCloakOn - Handler of command $83 (2 bytes: group key). It runs cmdGroupBlitzOff for the same
; group first (the two states are exclusive), saving and restoring the read index around it, then
; enters the patch block of the shared group routine at $50D4 with X = $80 so the body works on
; unitPathColTable with OR $C0 and sets bit 7 of the group's status entry.
; In:  the merged command buffer; the group key argument carries the group number in bits 2-4 and the
;     side in bit 5
; Out: unitPathColTable of every living non-SPY member |= $C0, unitPathRowTable &= $3F,
;     groupStatusTable[group] |= $80, pendingRedrawGroup and comcenCloakedFlag; self-modifies the LDA
;     operand at $50A2
; Called from: reached only through commandHandlerTable, entry $83
; ----------------------------------------------------------------------
cmdGroupCloakOn:
        ldy     mergedCmdReadIndex      ; 5098  current read index
        sty     L_50A1+1                ; 509B  patch the LDA # at $50A2 - the blitz handler consumes the argument as well
        jsr     cmdGroupBlitzOff        ; 509E  clear the group's blitz bits first
L_50A1:
        lda     #$FF                    ; 50A1  operand patched at $509B: the saved read index
        sta     mergedCmdReadIndex      ; 50A3  rewind so the group key can be read again
        ldx     #$80                    ; 50A6  X = $80: cloak variant, group status bit 7
        bne     L_50D4                  ; 50A8  always taken: enter the cloak patch block

; ----------------------------------------------------------------------
; cmdGroupBlitzOn - Handler of command $94 (2 bytes: group key). It runs cmdGroupCloakOff for the
; group first, then enters the shared patch block at $50C1 with X = $C0 and A = $40, so the body works
; on unitPathRowTable with OR $C0, calls applyBlitzStepCost for every member and sets
; groupStatusTable[group] = (old & $BF) | $40.
; In:  the merged command buffer
; Out: unitPathRowTable of every living non-SPY member |= $C0, one energy point and 25 movement points
;     per member, groupStatusTable[group] bit 6 set; self-modifies the LDA operand at $50B4
; Called from: reached only through commandHandlerTable, entry $94
; ----------------------------------------------------------------------
cmdGroupBlitzOn:
        ldy     mergedCmdReadIndex      ; 50AA  current read index
        sty     L_50B3+1                ; 50AD  patch the LDA # at $50B4 - the cloak handler consumes the argument as well
        jsr     cmdGroupCloakOff        ; 50B0  clear the group's cloak bits first
L_50B3:
        lda     #$FF                    ; 50B3  operand patched at $50AD: the saved read index
        sta     mergedCmdReadIndex      ; 50B5  rewind so the group key can be read again
        ldx     #$C0                    ; 50B8  per-unit bits to OR in: blitz ordered + active
        lda     #$40                    ; 50BA  bit 6 of the group's status entry = the group is blitzing
        bne     L_50C1                  ; 50BC  always taken

; ----------------------------------------------------------------------
; cmdGroupBlitzOff - Handler of command $96 (2 bytes: group key) and, at $50C1, the blitz half of the
; patch block that configures the shared group routine. With X = A = 0 it selects: no blitz cost,
; group status OR $00, group status AND $BF and table unitPathRowTable, so every member's blitz bits
; are cleared.
; In:  the merged command buffer
; Out: unitPathRowTable of every member &= $3F, groupStatusTable[group] &= $BF; patches the operands
;     at $512D, $514B and $514D and the table operands via applyGroupCloakOrBlitz
; Called from: commandHandlerTable entry $96, and cmdGroupCloakOn ($509E)
; ----------------------------------------------------------------------
cmdGroupBlitzOff:
        ldx     #$00                    ; 50BE  X = 0: no bits to set in the members' waypoint byte
        txa                             ; 50C0  A = 0 as well
L_50C1:
        sta     L_514C+1                ; 50C1  patch the ORA # at $514D: bits to set in the group status entry
        sta     L_512C+1                ; 50C4  patch the LDA # at $512D: non-zero here means 'charge the blitz cost per member'
        lda     #$BF                    ; 50C7  keep everything but bit 6 ...
        sta     L_514A+1                ; 50C9  ... patch the AND # at $514B (bit 6 = the group is blitzing)
        lda     #$FC                    ; 50CC  low byte of unitPathRowTable ($F8FC), the blitz table
        ldy     #$F8                    ; 50CE  its high byte
        bne     applyGroupCloakOrBlitz  ; 50D0  always taken: run the shared body

; ----------------------------------------------------------------------
; cmdGroupCloakOff - Handler of command $85 (2 bytes: group key) and, at $50D4, the cloak half of the
; patch block. With X = 0 it selects: no blitz cost, group status AND $7F, group status OR $00 and
; table unitPathColTable, so the cloak bits of every member are cleared. Entered with X = $80 by
; cmdGroupCloakOn, which turns the same block into the cloak-on variant.
; In:  the merged command buffer; X = 0 (cloak off) or $80 (cloak on)
; Out: unitPathColTable of every member &= $3F (or |= $C0), groupStatusTable[group] bit 7 cleared or
;     set, pendingRedrawGroup for an own command, comcenCloakedFlag refreshed
; Called from: commandHandlerTable entry $85, cmdGroupBlitzOn ($50B0) and cmdGroupCloakOn ($50A8)
; ----------------------------------------------------------------------
cmdGroupCloakOff:
        ldx     #$00                    ; 50D2  X = 0: clear the cloak bits
L_50D4:
        lda     #$00                    ; 50D4  0 = no blitz cost in this variant ...
        sta     L_512C+1                ; 50D6  ... patch the LDA # at $512D
        lda     #$7F                    ; 50D9  keep everything but bit 7 ...
        sta     L_514A+1                ; 50DB  ... patch the AND # at $514B (bit 7 = the group is cloaked)
        stx     L_514C+1                ; 50DE  patch the ORA # at $514D: $00 for cloak off, $80 for cloak on
        lda     #$98                    ; 50E1  low byte of unitPathColTable ($F898), the cloak table
        ldy     #$F8                    ; 50E3  its high byte
        cpx     #$00                    ; 50E5  which variant was requested?
        beq     applyGroupCloakOrBlitz  ; 50E7  cloak off: the members' bits are cleared (OR $00)
        ldx     #$C0                    ; 50E9  cloak on: OR $C0 into every member's waypoint byte

; ----------------------------------------------------------------------
; applyGroupCloakOrBlitz - The shared, heavily self-modified body of the four group cloak/blitz
; handlers; it is only ever reached by branching, never by JSR. It stores the chosen table address
; into the LDA/STA operands of the unit loop, reads the group key argument, notes the group in
; pendingRedrawGroup for an own cloak command, and walks all 100 unit slots skipping dead units,
; non-members and SPYs. For every member it optionally charges the blitz cost and rewrites table[unit]
; = (old & $3F) | patchedBits. Finally it updates the group's entry in groupStatusTable and falls into
; updateComcenCloakLock.
; In:  the patched operands at $512D (blitz cost gate), $5134/$513B (table address), $5139 (per-unit
;     bits), $514B/$514D (group status mask and bits); cmdParam0 = group key after readCmdArg1;
;     unitFlagsTable and unitTypeTable
; Out: cloak or blitz bits of every matching unit, groupStatusTable[group], pendingRedrawGroup
;     ($923A), comcenCloakedFlag ($9245)
; Called from: the four group handlers $83/$85/$94/$96 by branch ($50D0 and $50E7)
; ----------------------------------------------------------------------
applyGroupCloakOrBlitz:
        stx     L_5138+1                ; 50EB  patch the ORA # at $5139: the bits every member gets ($00 or $C0)
        sta     L_5133+1                ; 50EE  patch the low byte of the LDA abs,y at $5134 ...
        sta     L_513A+1                ; 50F1  ... and of the STA abs,y at $513B
        sty     L_5133+2                ; 50F4  patch the high byte of the LDA at $5135 ...
        sty     L_513A+2                ; 50F7  ... and of the STA at $513C ($F898 cloak or $F8FC blitz)
        jsr     readCmdArg1             ; 50FA  cmdParam0 = the group key argument
        lda     L_514A+1                ; 50FD  the group status AND mask that was patched in
        cmp     #$7F                    ; 5100  $7F means the cloak variant is running
        bne     L_510D                  ; 5102  blitz: no redraw needed, cloaking is what changes the picture
        lda     cmdFromOpponent         ; 5104  did this command come from the opponent?
        bmi     L_510D                  ; 5106  yes: the opponent's cloaking must not redraw our screen
        lda     cmdParam0               ; 5108  our own group ...
        sta     pendingRedrawGroup      ; 510A  ... is handed to the tick loop to be redrawn
L_510D:
        lda     cmdParam0               ; 510D  the group key
        ora     #$40                    ; 510F  add the in-a-group bit so it matches unitFlagsTable & $7C
        sta     cmdParam0               ; 5111  and keep it there for the loop
        ldy     #$63                    ; 5113  scan unit slots 99 down to 0

; ----------------------------------------------------------------------
; One pass over all 100 unit slots.  Dead slots, units of other groups and SPYs are skipped; SPYs are
; permanently cloaked and can never blitz, so they are exempt from both orders.
; ----------------------------------------------------------------------
L_5115:
        lda     unitTypeTable,y         ; 5115  type byte of this slot
        bmi     L_513D                  ; 5118  bit 7 = dead or empty, skip it
        lda     unitFlagsTable,y        ; 511A  authoritative flags
        and     #$7C                    ; 511D  group number, side and member bit
        cmp     cmdParam0               ; 511F  a member of the group named by the command?
        bne     L_513D                  ; 5121  no: next slot
        lda     unitTypeTable,y         ; 5123  type byte again
        and     #$07                    ; 5126  keep only the type
        cmp     #$03                    ; 5128  type 3 = SPY
        beq     L_513D                  ; 512A  spies are exempt from group cloak and blitz orders
L_512C:
        lda     #$FF                    ; 512C  operand patched at $50C4/$50D6: non-zero only for the blitz-on variant
        beq     L_5133                  ; 512E  zero: no cost to charge
        jsr     applyBlitzStepCost      ; 5130  blitz on: pay one energy point for 25 extra movement points
L_5133:
        lda     irqVectorHi,y           ; 5133  operand patched at $50EE/$50F4: the member's cloak ($F898) or blitz ($F8FC) byte
        and     #$3F                    ; 5136  keep the waypoint coordinate in bits 0-5
L_5138:
        ora     #$FF                    ; 5138  operand patched at $50EB: set both state bits, or none
L_513A:
        sta     irqVectorHi,y           ; 513A  operand patched at $50F1/$50F7: store the byte back
L_513D:
        dey                             ; 513D  next lower slot
        bpl     L_5115                  ; 513E  loop until slot 0 has been examined

; ----------------------------------------------------------------------
; Finally record the new state in the group's own entry so that the console and the group menu can
; show CLOAKED / BLITZ without scanning the units again.
; ----------------------------------------------------------------------
        lda     cmdParam0               ; 5140  the group key with its member bit
        and     #$3C                    ; 5142  group number and side ...
        lsr     a                       ; 5144  ... shifted down ...
        lsr     a                       ; 5145  ... to give the 0-15 index (group 0-7 of side 0, 8-15 of side 1)
        tay                             ; 5146  Y = index into groupStatusTable
        lda     groupStatusTable,y      ; 5147  current status: speed class in bits 0-2, terrain class in bits 3-5, blitz bit 6, cloak bit 7
L_514A:
        and     #$FF                    ; 514A  operand patched at $50C9/$50DB: clear the bit this command owns
L_514C:
        ora     #$FF                    ; 514C  operand patched at $50C1/$50DE: and set it if the order was 'on'
        sta     groupStatusTable,y      ; 514E  store the new group status

; ----------------------------------------------------------------------
; updateComcenCloakLock - The common tail of every cloak and blitz handler and the only writer of
; comcenCloakedFlag: it copies bit 7 of the local player's own comcen entry in unitPathColTable, so
; $9245 bit 7 is set exactly while that comcen is cloaked. The console reads it to lock the inspection
; panels ($2064, $2096) and both $6F00 overlays read it as well.
; In:  comcenUnit ($9236) = 49 or 99, unitPathColTable; no arguments
; Out: comcenCloakedFlag ($9245) = unitPathColTable[own comcen] & $80; A and Y clobbered
; Called from: the fall-through from applyGroupCloakOrBlitz, the single-unit cloak tail ($505F) and
;     both $6F00 overlays ($72E7)
; ----------------------------------------------------------------------
updateComcenCloakLock:
        ldy     comcenUnit              ; 5151  index of this player's own comcen (49 for side 0, 99 for side 1)
        lda     unitPathColTable,y      ; 5154  its waypoint column byte
        and     #$80                    ; 5157  bit 7 = the comcen is cloaked right now
        sta     comcenCloakedFlag       ; 5159  the console and the overlays test this byte
        rts                             ; 515C  back to the dispatcher


; ----------------------------------------------------------------------
; cmdClearTarget - Handler of command $86 CLEAR TARGET (2 bytes: BOOMER unit). getBoomerTarget turns
; the unit index into that unit's ordinal among all BOOMERs and the matching entry of
; boomerTargetTable is zeroed, so the BOOMER stops holding fire for a particular enemy. The carry that
; getBoomerTarget returns is not tested, so a non-BOOMER argument would clear a slot chosen by a
; leftover Y; only the unit menu of a BOOMER offers the item, so that never happens in practice.
; In:  mergedCmdBuffer $908F / mergedCmdReadIndex $90B7 (the queued command), cmdParam0 after
;     readCmdArg1
; Out: boomerTargetTable $91DC[ordinal] = 0; mergedCmdReadIndex += 2; X = unit, Y = ordinal
; Called from: commandHandlerTable slot for $86 ($4D36), dispatched by executeTurnCommands at $4EEE
; ----------------------------------------------------------------------
cmdClearTarget:
        jsr     readCmdArg1             ; 515D  read the single argument byte into cmdParam0 and step mergedCmdReadIndex past this 2-byte command
        ldx     cmdParam0               ; 5160  X = the BOOMER's unit index
        jsr     getBoomerTarget         ; 5162  $3C74: for a BOOMER (unitTypeTable & 7 == 2) returns Y = its ordinal among all BOOMERs
        lda     #$00                    ; 5165  0 = no target and no lock
        sta     boomerTargetTable,y     ; 5167  clear this BOOMER's slot of the 20-entry boomerTargetTable at $91DC
        rts                             ; 516A  done - CLEAR TARGET touches nothing but the target table


; ----------------------------------------------------------------------
; cmdSetTarget - Handler of command $87 SET TARGET (3 bytes: BOOMER unit, target unit). It only
; rearranges the parameter bytes so that setBoomerTargetCmd ($3C50) finds the BOOMER in cmdParam0 and
; the target in cmdParam2, then jumps there; the result is boomerTargetTable[BOOMER ordinal] = target
; | $80. The command is sent by sub_3C20 when the player puts the cursor on an enemy unit with a
; BOOMER selected. The EOR #$80 on cmdFromOpponent at $5171 is undone again at $517C without anything
; reading it in between, and the store at
; $5177 is immediately overwritten at $5180 - both are leftovers from an earlier version.
; In:  the queued command in mergedCmdBuffer; cmdParam1 = BOOMER, cmdParam2 = target after
;     readCmdArgs2
; Out: boomerTargetTable $91DC[ordinal] = target | $80 via $3C50; cmdParam0 = BOOMER; read index += 3
; Called from: commandHandlerTable slot for $87 ($4D38)
; ----------------------------------------------------------------------
cmdSetTarget:
        jsr     readCmdArgs2            ; 516B  read the two arguments: cmdParam1 = the BOOMER, cmdParam2 = the target unit; read index += 3
        lda     cmdFromOpponent         ; 516E  take a copy of the own/remote flag of the command being executed
        pha                             ; 5170  push it
        eor     #$80                    ; 5171  flip bit 7 = 'this command came from the opponent' ...
        sta     cmdFromOpponent         ; 5173  ... and store the flipped value back; nothing between here and $517C looks at it, so the flip does nothing (leftover)
        lda     cmdParam2               ; 5175  A = the target unit index
        sta     cmdParam0               ; 5177  cmdParam0 = target - overwritten again at $5180, so this store is dead
        sta     cmdParam2               ; 5179  write cmdParam2 back over itself - a no-op
        pla                             ; 517B  pull the untouched own/remote flag
        sta     cmdFromOpponent         ; 517C  undo the EOR of $5171
        lda     cmdParam1               ; 517E  A = the BOOMER's unit index
        sta     cmdParam0               ; 5180  setBoomerTargetCmd wants the BOOMER in cmdParam0 and the target in cmdParam2
        jmp     setBoomerTargetCmd      ; 5182  $3C50: boomerTargetTable[BOOMER ordinal] = cmdParam2 | $80 (target unit plus the 'locked' bit)


; ----------------------------------------------------------------------
; cmdJoinGroup - Handler of command $88 JOIN GROUP (3 bytes: unit, group byte). It jumps to
; joinUnitToGroup ($4AD4), which makes the unit follow the group's movement and sets unitFlagsTable =
; (old & 3) | group | $40. The eight instructions at $5188-$5197 are a shuffle whose only lasting
; effect is cmdParam0 = group byte, and nothing downstream reads cmdParam0 - the whole block is dead
; code.
; In:  the queued command; cmdParam1 = unit, cmdParam2 = group byte (id bits 2-4, side bit 5)
; Out: unitFlagsTable $F708[unit] and unitDisplayFlagsTable $FB54[unit] via $4AD4; read index += 3
; Called from: commandHandlerTable slot for $88 ($4D3A)
; ----------------------------------------------------------------------
cmdJoinGroup:
        jsr     readCmdArgs2            ; 5185  read the two arguments: cmdParam1 = unit index, cmdParam2 = group byte (id in bits 2-4, side in bit 5)

; ----------------------------------------------------------------------
; Eight instructions of register shuffling whose only lasting effect is cmdParam0 = the group byte,
; and nothing reads cmdParam0 afterwards. Dead code left over from an earlier calling convention.
; ----------------------------------------------------------------------
        lda     cmdParam1               ; 5188  A = the unit index
        sta     cmdParam0               ; 518A  cmdParam0 = unit - overwritten again at $5192
        lda     cmdParam0               ; 518C  read the same byte straight back
        sta     cmdParam1               ; 518E  store the unit index back into cmdParam1 - a no-op
        lda     cmdParam2               ; 5190  A = the group byte
        sta     cmdParam0               ; 5192  cmdParam0 = group byte; nothing downstream ever reads cmdParam0, so even this is pointless
        lda     cmdParam0               ; 5194  read it back again
        sta     cmdParam2               ; 5196  store the group byte back into cmdParam2 - another no-op
        jmp     joinUnitToGroup         ; 5198  $4AD4: make unit cmdParam1 follow group cmdParam2 and set unitFlagsTable = (old & 3) | group | $40


; ----------------------------------------------------------------------
; cmdLeaveGroup - Handler of command $89 (2 bytes: unit): clears bit 6 ('in a group') of the unit's
; unitFlagsTable entry and tail-jumps to syncLocalGroupFlags ($4C6B), which mirrors bits 2-6 into
; unitDisplayFlagsTable so the overview map stops drawing the unit's group marker. Sent by
; removeSelectedUnitFromGroup ($4AF2), the GROUP menu item REMOVED.
; In:  the queued command; cmdParam0 = unit index
; Out: unitFlagsTable $F708[unit] &= $BF, unitDisplayFlagsTable $FB54[unit] refreshed; read index += 2
; Called from: commandHandlerTable slot for $89 ($4D3C)
; ----------------------------------------------------------------------
cmdLeaveGroup:
        jsr     readCmdArg1             ; 519B  cmdParam0 = the unit index; read index += 2
        ldx     cmdParam0               ; 519E  X = the unit
        lda     unitFlagsTable,x        ; 51A0  $F708 flags: facing bits 0-1, group id bits 2-4, side bit 5, 'in a group' bit 6
        and     #$BF                    ; 51A3  clear bit 6 - the unit no longer belongs to a group
        sta     unitFlagsTable,x        ; 51A5  write the flags back
        jmp     syncLocalGroupFlags     ; 51A8  $4C6B: copy bits 2-6 into unitDisplayFlagsTable so the map stops drawing the group marker


; ----------------------------------------------------------------------
; cmdPlaceUnit - Handler of command $8A (4 bytes: unit, column, row): teleports a unit during the
; deployment phase. The unit's cloak bits (unitPathColTable & $C0) and blitz bits (unitPathRowTable &
; $C0) are first saved into the ORA operands at $51C8 and $51E4, then the new column is written to
; unitColTable, to unitPathColTable (keeping the cloak state) and to unitDestColTable with the
; 'arrived' bit 7, bit 7 of unitDisplayFlagsTable is set, and the new row goes to unitRowTable,
; unitDestRowTable and unitPathRowTable (keeping the blitz state). The $F000 map itself is not touched
; here.
; In:  the queued command; cmdParam0 = unit, cmdParam1/cmdParam2 = column/row (mirrored when remote)
; Out: $F640, $F6A4, $F7D0, $F834, $F898, $F8FC and $FB54 of the unit; self-modifies $51C9 and $51E5
; Called from: commandHandlerTable slot for $8A ($4D3E)
; ----------------------------------------------------------------------
cmdPlaceUnit:
        jsr     readCmdArgs3            ; 51AB  read three arguments: cmdParam0 = unit, cmdParam1 = column, cmdParam2 = row; read index += 4
        jsr     mirrorCmdCoordsIfRemote ; 51AE  a remote player sends his own coordinates: convert them with 39-v so both machines agree
        ldy     cmdParam0               ; 51B1  Y = the unit index into every one of the $64-spaced unit arrays

; ----------------------------------------------------------------------
; Save the two flag pairs that live in the top bits of the waypoint bytes, so the rewrite below can
; put them back: cloak in unitPathColTable bits 6-7, blitz in unitPathRowTable bits 6-7.
; ----------------------------------------------------------------------
        lda     unitPathColTable,y      ; 51B3  $F898 waypoint column: bits 0-5 column, bits 6-7 = cloak ordered / cloak active
        and     #$C0                    ; 51B6  keep only the two cloak bits
        sta     L_51C8+1                ; 51B8  patch them into the ORA immediate at $51C8 so the rewrite below preserves them
        lda     unitPathRowTable,y      ; 51BB  $F8FC waypoint row: bits 0-5 row, bits 6-7 = blitz ordered / blitz active
        and     #$C0                    ; 51BE  keep only the two blitz bits
        sta     L_51E4+1                ; 51C0  patch them into the ORA immediate at $51E4

; ----------------------------------------------------------------------
; Column: current position, waypoint (cloak preserved) and destination (marked as already reached).
; ----------------------------------------------------------------------
        lda     cmdParam1               ; 51C3  A = the new map column
        sta     unitColTable,y          ; 51C5  $F640 = the unit's current column
L_51C8:
        ora     #$FF                    ; 51C8  immediate operand patched at $51B8 with the saved cloak bits
        sta     unitPathColTable,y      ; 51CA  waypoint column = the new column with the cloak state untouched
        and     #$3F                    ; 51CD  strip the cloak bits off again
        ora     #$80                    ; 51CF  bit 7 of unitDestColTable means 'already standing on its destination'
        sta     unitDestColTable,y      ; 51D1  $F7D0 destination column = the new column, marked as reached
        lda     unitDisplayFlagsTable,y ; 51D4  $FB54 draw flags of the unit
        ora     #$80                    ; 51D7  bit 7 = idle / at destination, the copy the drawing code looks at
        sta     unitDisplayFlagsTable,y ; 51D9  write the draw flags back

; ----------------------------------------------------------------------
; Row: current position, destination and waypoint (blitz preserved).
; ----------------------------------------------------------------------
        lda     cmdParam2               ; 51DC  A = the new map row
        sta     unitRowTable,y          ; 51DE  $F6A4 = the unit's current row
        sta     unitDestRowTable,y      ; 51E1  $F834 destination row = the same row (rows carry no flag bit)
L_51E4:
        ora     #$FF                    ; 51E4  immediate operand patched at $51C0 with the saved blitz bits
        sta     unitPathRowTable,y      ; 51E6  waypoint row = the new row with the blitz state untouched
        rts                             ; 51E9  note that the $F000 map cell is never updated here - the setup screen redraws the board itself


; ----------------------------------------------------------------------
; cmdSetCustomMapSeed - Handler of command $A6 (4 bytes: three seed bytes): sets mapFlipFlag ($0B9D) =
; 1 and falls into cmdSetMapSeed. Overlay A sends $A6 instead of $8B at $79BD when the player picked
; the flipped (180-degree rotated) view of the map, so both machines build the battlefield the same
; way round.
; In:  the queued command
; Out: mapFlipFlag = 1, then everything cmdSetMapSeed does
; Called from: the last entry of commandHandlerTable ($4D76, command $A6)
; ----------------------------------------------------------------------
cmdSetCustomMapSeed:
        lda     #$01                    ; 51EA  command $A6: the sender chose the flipped map variant
        bne     L_51F0                  ; 51EC  always taken - join the shared body

; ----------------------------------------------------------------------
; cmdSetMapSeed - Handler of command $8B (4 bytes: three seed bytes): mapFlipFlag = 0, the three
; arguments are copied into the 24-bit map seed $0B95-$0B97, and bit 7 of gamePhase is cleared. The
; player who owns the map sends this at the start of a session; the other machine's
; waitForMapSeedCommand ($7C1C) spins on gamePhase bit 7 until this handler runs, after which both
; generate the identical 40x40 battlefield.
; In:  the queued command; cmdParam0/1/2 = the seed bytes after readCmdArgs3
; Out: mapFlipFlag $0B9D, mapSeed $0B95-$0B97, gamePhase zp_B1 &= $7F; read index += 4
; Called from: commandHandlerTable slot for $8B ($4D40), and by fall-through from cmdSetCustomMapSeed
; ----------------------------------------------------------------------
cmdSetMapSeed:
        lda     #$00                    ; 51EE  command $8B: the map in its generated orientation
L_51F0:
        sta     mapFlipFlag             ; 51F0  $0B9D: 0 = map as generated, 1 = rotated 180 degrees (the F1 TO FLIP option on the map screen)
        jsr     readCmdArgs3            ; 51F3  read the three seed bytes into cmdParam0/1/2; read index += 4
        lda     cmdParam0               ; 51F6  seed byte 0
        sta     mapSeed                 ; 51F8  $0B95: byte 0 of the 24-bit battlefield seed, which is the 5-letter map name packed 5 bits per letter
        lda     cmdParam1               ; 51FB  seed byte 1
        sta     mapSeed1                ; 51FD  $0B96
        lda     cmdParam2               ; 5200  seed byte 2
        sta     mapSeed2                ; 5202  $0B97
        lda     gamePhase               ; 5205  zp_B1 game phase
        and     #$7F                    ; 5207  clear bit 7 = 'still waiting for the map seed'
        sta     gamePhase               ; 5209  this releases waitForMapSeedCommand ($7C1C) on the receiving machine
        rts                             ; 520B  both machines now hold the same seed and will generate the identical battlefield


; ----------------------------------------------------------------------
; cmdComcenRepairedOrSetupReady - Handler of the 1-byte command $9E, which means two different things.
; While the battle runs (gamePhase = 0) it is the result of a successful COMCEN repair from the REPAIR
; console tab ($1812): the stun counter in the low nibble of the sending side's COMCEN unitStunTable
; entry is cleared. In every other phase it is the READY / phase-sync acknowledgement of
; sendPhaseSyncAndWait ($7BF9): the sender's bit of gamePhase is cleared with getPauseClearMask, input
; is locked out for 32 frames, a fake SPACE key event is injected so any 'press space' loop falls
; through, and the chat state is reset.
; In:  gamePhase zp_B1, cmdFromOpponent zp_76, playerSide $0B9F, perSideValueTable $0C8A
; Out: battle: unitStunTable $F960[comcen] &= $F0. Setup: zp_B1 masked, $0B7D = $20, $90EA = $20,
;     $929C = 0.
;      mergedCmdReadIndex += 1 in both cases
; Called from: commandHandlerTable slot for $9E ($4D66)
; ----------------------------------------------------------------------
cmdComcenRepairedOrSetupReady:
        inc     mergedCmdReadIndex      ; 520C  a 1-byte command: just step the read index over it
        ldx     gamePhase               ; 520F  zp_B1 is zero only while the battle itself is running
        beq     L_5228                  ; 5211  battle: this is a successful COMCEN repair
        jsr     getPauseClearMask       ; 5213  setup: A = the AND mask that clears the sending side's 'not ready' bit
        and     gamePhase               ; 5216  apply it to the phase byte
        sta     gamePhase               ; 5218  once both bits are gone sendPhaseSyncAndWait ($7BF9) stops waiting
        lda     #$20                    ; 521A  $20 = 32
        sta     inputLockoutTimer       ; 521C  $0B7D: ignore keyboard and joystick for the next 32 frames
        sta     newKeyEvent             ; 521F  inject key code $20 (SPACE) so any 'press space' wait loop falls through at once
        lda     #$00                    ; 5222  0
        sta     chatState               ; 5224  $929C: abandon any chat line that was being typed
        rts                             ; 5227  phase sync done


; ----------------------------------------------------------------------
; Battle phase: command $9E means the sending side's COMCEN has finished repairing itself, so clear
; its stun counter.
; ----------------------------------------------------------------------
L_5228:
        lda     playerSide              ; 5228  $0B9F = the side this machine plays, 0 or 1
        ldy     cmdFromOpponent         ; 522B  who sent the repair?
        bpl     L_5231                  ; 522D  our own command: our own COMCEN was repaired
        eor     #$01                    ; 522F  remote command: the other side's COMCEN
L_5231:
        tax                             ; 5231  X = the sender's absolute side
        ldy     comcenUnitIndexBySide,x ; 5232  $0C8A = {$31,$63}: unit 49 is side 0's COMCEN, unit 99 is side 1's
        lda     unitStunTable,y         ; 5235  $F960: low nibble = stun/knock-out counter, bit 4 spare, bits 5-7 = dig state
        and     #$F0                    ; 5238  clear the stun counter - the COMCEN is back on line
        sta     unitStunTable,y         ; 523A  write it back, leaving the dig bits alone
        rts                             ; 523D  done


; ----------------------------------------------------------------------
; cmdSessionControl - Handler of command $8C (2 bytes: reason). It is never dispatched out of the
; merged buffer: both packet mergers call it directly ($4F36 and $4F9C) with A = the reason byte while
; the packets are being merged, so the effect is immediate and the bytes are not copied into the film.
; Reason 0 = the game is being abandoned: gameOverFlags = $C0, the message queue is cleared, 'GAME
; ABORTED.' is shown for a remote abort, the end-of-game countdown is armed, and the dispatcher is
; abandoned by pulling two return addresses off the stack. Reasons 1 and 2 return at once ($8C,$02 is
; the menu READY packet handled elsewhere). Reason 3 = link timeout, reason 5 = deliberate hang-up,
; anything else (4) = resume. Everything except reason 0 is skipped during film playback.
; In:  A = reason byte, X/Y = the merge loop's registers, cmdFromOpponent zp_76, $0B9B, $0BA8
; Out: pauseState zp_BE, $92A1, $0B7D, $929C, $92C9, $923F, zp_72, the message queue; returns through
;     $532A
;      with X/Y restored, or never returns at all for reason 0
; Called from: mergeIncomingPacket $4F36 and mergeOutgoingPacket $4F9C
; ----------------------------------------------------------------------
cmdSessionControl:
        jsr     saveRegsForCmdReturn    ; 523E  this handler runs inside the packet merge loop, so stash its X and Y into the operands at $532B/$532D
        sta     cmdParam0               ; 5241  A holds the reason byte the merger fetched out of the packet
        cmp     #$03                    ; 5243  reasons 3 and up are the pause / hang-up / resume group
        bcs     L_5272                  ; 5245  branch to the pause group
        cmp     #$00                    ; 5247  reason 0 = the game is being abandoned
        beq     L_524C                  ; 5249  handle the abort
        rts                             ; 524B  reason 1 is unused; reason 2 is the menu-level READY packet handled by readyForNewGameHandshake


; ----------------------------------------------------------------------
; Reason 0 - the game is being abandoned, by us or by the opponent.
; ----------------------------------------------------------------------
L_524C:
        lda     #$C0                    ; 524C  $C0 = game finished, no result computed
        sta     gameOverFlags           ; 524E  $923F game-over flags
        lda     #$62                    ; 5251  98 ticks of end-of-game countdown when no battle was running
        ldy     gameEndReason           ; 5253  $0BA8 is non-zero whenever no battle is in progress
        bne     L_5266                  ; 5256  no battle: keep the $62 countdown and say nothing
        jsr     resetMessageQueue       ; 5258  a battle was running: throw away everything queued for the status line
        lda     cmdFromOpponent         ; 525B  who aborted?
        bpl     L_5269                  ; 525D  our own abort - the local menu code has already set the countdown
        lda     #$03                    ; 525F  message $03
        jsr     queueMessage            ; 5261  'GAME ABORTED.' - the opponent quit on us
        lda     #$71                    ; 5264  113 ticks so the message can be read before the menu returns
L_5266:
        sta     gameEndCountdown        ; 5266  $92C9 counts down to the return to the main menu

; ----------------------------------------------------------------------
; Abandon the whole dispatcher. This handler was entered as JSR cmdSessionControl from inside
; mergeIncomingPacket/mergeOutgoingPacket, which themselves were entered as JSR from
; serviceCommandExchange, so four PLAs drop both return addresses and the JMP below returns straight
; to serviceCommandExchange's own caller.
; ----------------------------------------------------------------------
L_5269:
        pla                             ; 5269  discard the return address of this handler ...
        pla                             ; 526A  (second byte)
        pla                             ; 526B  ... and that of mergeIncomingPacket / mergeOutgoingPacket ...
        pla                             ; 526C  ... so the RTS below lands in the caller of serviceCommandExchange instead
        ldx     #$02                    ; 526D  comm function 2 = acknowledge and consume the finished exchange
        jmp     commJumpTable           ; 526F  $E000; its RTS unwinds two levels because of the four PLAs above  (also trainerAiE000:commRequestEntry)


; ----------------------------------------------------------------------
; Reasons 3 and up - the pause, hang-up and resume group. All of them are ignored during film
; playback.
; ----------------------------------------------------------------------
L_5272:
        bit     filmPlaybackMode        ; 5272  $0B9B bit 7 = a game film is being replayed
        bmi     L_52AB                  ; 5275  during playback the pause / resume traffic is ignored
        ldy     #$00                    ; 5277  0
        sty     chatState               ; 5279  cancel any chat line being typed
        ldy     #$3C                    ; 527C  $3C = 60 frames, about one second
        sty     inputLockoutTimer       ; 527E  hold the player's input off while the pause state changes
        cmp     #$03                    ; 5281  reason 3 = link timeout
        bne     L_528E                  ; 5283  not the timeout reason
        sta     pauseState              ; 5285  zp_BE = 3: both sides counted as paused
        lda     #$19                    ; 5287  message $19
        sta     persistentMessageId     ; 5289  'TIMEOUT. RUN/STOP TO RESUME.' stays on the line until someone resumes
        bne     L_52A8                  ; 528C  always taken - go and queue the message
L_528E:
        cmp     #$05                    ; 528E  reason 5 = the link was hung up on purpose
        bne     L_5296                  ; 5290  not the hang-up reason
        lda     #$80                    ; 5292  bit 7 of pauseState = link dropped (DISCONNECT / SET VOICE)
        bne     L_529D                  ; 5294  always taken
L_5296:
        ldx     pauseState              ; 5296  any other reason (4 = RESUME) clears only the sender's own bit
        jsr     getPauseClearMask       ; 5298  A = $FD for our resume, $FE for his, $FC in a solo game
        and     pauseState              ; 529B  clear that bit of the pause state
L_529D:
        sta     pauseState              ; 529D  store the new pause state
        bne     L_52AB                  ; 529F  someone is still paused - leave the message up
        sta     persistentMessageId     ; 52A1  A = 0: no persistent message any more
        sta     messageTimer            ; 52A4  zp_72 = 0 so the next message appears immediately
        lda     #$1B                    ; 52A6  message $1B 'RESUMED'
L_52A8:
        jsr     queueMessage            ; 52A8  put it on the status line
L_52AB:
        jmp     L_532A                  ; 52AB  leave through the shared tail that restores the merge loop's X and Y


; ----------------------------------------------------------------------
; getPauseClearMask - Returns the AND mask that clears the resuming side's bit of a two-bit
; ready/pause byte. cmdSessionControl applies it to pauseState (zp_BE) and
; cmdComcenRepairedOrSetupReady applies it to gamePhase (zp_B1). In a solo-trainer game there is no
; independent opponent, so both bits are cleared at once ($FC); otherwise an own command clears bit 0
; ($FD) and a remote command clears bit 1 ($FE), and in the remote case the message 'OPPONENT READY.'
; is queued if his bit was still set.
; In:  X = the pause/phase byte before this command, isSoloTrainer $0BA5, cmdFromOpponent zp_76
; Out: A = $FC, $FD or $FE; may queue message $04; Y clobbered
; Called from: cmdSessionControl $5298 and cmdComcenRepairedOrSetupReady $5213
; ----------------------------------------------------------------------
getPauseClearMask:
        lda     #$FC                    ; 52AE  mask that clears both pause bits at once
        ldy     isSoloTrainer           ; 52B0  $0BA5 bit 7 = PRACTICE WITH SOLO TRAINER
        bmi     L_52C7                  ; 52B3  solo: there is no independent opponent, so clear both bits together
        lda     #$FD                    ; 52B5  mask that clears bit 0 = this machine's bit
        ldy     cmdFromOpponent         ; 52B7  who sent the command?
        bpl     L_52C7                  ; 52B9  our own command: bit 0 is the one to clear
        txa                             ; 52BB  A = the pause/phase byte as it was before this command (passed in X)
        and     #$02                    ; 52BC  bit 1 = the opponent was still flagged as not ready
        beq     L_52C5                  ; 52BE  he was already ready - nothing to announce
        lda     #$04                    ; 52C0  message $04
        jsr     queueMessage            ; 52C2  'OPPONENT READY.'
L_52C5:
        lda     #$FE                    ; 52C5  mask that clears bit 1 = the opponent's bit
L_52C7:
        rts                             ; 52C7  A = the AND mask


; ----------------------------------------------------------------------
; cmdDroneMove - Handler of command $8D (4 bytes: heading, column, row), the opponent's live drone
; telemetry. Only mergeIncomingPacket calls it, with Y pointing at the heading byte inside the
; received packet $E020; mergeOutgoingPacket deals with our own $8D by re-centring the tactical view
; instead, which is why the commandHandlerTable slot for $8D is a dummy - the byte never reaches the
; merged buffer. The handler mirrors the 2x2 drone origin into our coordinate frame and, while the
; COMCEN radar screen is showing and the drone has not already finished, parks sprite 0 on the drone's
; cell with the shape for the reversed heading.
; In:  Y = index of the heading byte in $E020, cmdFromOpponent zp_76, droneArrivalFlags $92F9, $90FB
; Out: cmdParam0/1/2, droneControlFlags $920D bit 0, $9022, $92F6/$92F7, $9010/$9018, $91CC, $9008 and
;      sprite 0's shape; X/Y restored from the operands patched by saveRegsForCmdReturn
; Called from: mergeIncomingPacket $4F28
; ----------------------------------------------------------------------
cmdDroneMove:
        lda     rxPacketBuffer,y        ; 52C8  read straight out of the received packet at $E020; Y points at the heading byte  (also trainerAiE000:rxPacketBuffer)
        sta     cmdParam0               ; 52CB  heading 0-7 of the opponent's drone
        iny                             ; 52CD  next packet byte
        lda     rxPacketBuffer,y        ; 52CE  fetch it  (also trainerAiE000:rxPacketBuffer)
        sta     cmdParam1               ; 52D1  drone map column in the sender's coordinate frame
        iny                             ; 52D3  next packet byte
        lda     rxPacketBuffer,y        ; 52D4  fetch it  (also trainerAiE000:rxPacketBuffer)
        sta     cmdParam2               ; 52D7  drone map row in the sender's coordinate frame
        jsr     saveRegsForCmdReturn    ; 52D9  remember the merge loop's X and Y for the common exit at $532A
        jsr     mirrorCmdCoordsIfRemoteDec; 52DC  mirror into our frame and step back one cell, because the drone occupies a 2x2 block
        lda     droneArrivalFlags       ; 52DF  $92F9 tracks the life of the drone
        and     #$30                    ; 52E2  bit 4 = it reached its target, bit 5 = its kill burst has already been played
        bne     L_532A                  ; 52E4  the drone is finished - ignore stale telemetry
        lda     droneControlFlags       ; 52E6  $920D drone state flags
        ora     #$01                    ; 52E9  bit 0 = an enemy drone is currently being tracked on our display
        sta     droneControlFlags       ; 52EB  store the flags back
        ldy     currentScreen           ; 52EE  $90FB: 0 = battlefield, 1 = COMCEN radar screen, 2 = drone screen, 3 = menus
        cpy     #$01                    ; 52F1  is the radar screen showing?
        bne     L_532A                  ; 52F3  the enemy drone blip only exists on the radar screen

; ----------------------------------------------------------------------
; Move sprite 0, the enemy drone blip, to the drone's cell on the 21x17 radar grid. The sprite is
; switched off first so that a drone which has flown off the grid simply disappears.
; ----------------------------------------------------------------------
        lda     spriteEnableShadow      ; 52F5  $9022 shadow of VIC_SPR_ENA
        and     #$FE                    ; 52F8  clear bit 0 - hide sprite 0 until we know the drone is inside the grid
        sta     spriteEnableShadow      ; 52FA  store the sprite enable shadow back
        ldx     cmdParam1               ; 52FD  X = drone column
        stx     droneViewOriginCol      ; 52FF  $92F6 = the tracked drone's map column
        ldy     cmdParam2               ; 5302  Y = drone row
        sty     droneViewOriginRow      ; 5304  $92F7 = its map row
        jsr     getUnitSpriteCentreOnGrid; 5307  $340B: converts map cell X,Y into the sprite X/Y of that cell's centre on the 21x17 radar grid; C=1 when off-grid
        bcs     L_532A                  ; 530A  off the visible grid - leave sprite 0 switched off
        sty     spriteYShadow           ; 530C  $9018 = sprite 0 Y position shadow
        stx     spriteXShadow           ; 530F  $9010 = sprite 0 X position shadow
        lda     #$00                    ; 5312  0
        sta     activeSpriteIndex       ; 5314  $91CC = 0 so loadDirectionShapeSprite fills sprite 0's shape at $8A00
        lda     cmdParam0               ; 5317  the heading as the sender sees it
        clc                             ; 5319  clear carry for the add
        adc     #$04                    ; 531A  +4 of the 8 compass points = the opposite direction ...
        cmp     #$08                    ; 531C  past the eighth point?
        bcc     L_5322                  ; 531E  no wrap needed
        sbc     #$08                    ; 5320  ... modulo 8; reversing the heading is exactly what the 180-degree map mirror demands
L_5322:
        jsr     loadDirectionShapeSprite; 5322  $3340: copy shape A out of the decompressed set at $0200 into sprite 0 and enable it
        lda     #$01                    ; 5325  VIC colour 1 = white
        sta     spriteColourShadow      ; 5327  $9008 = sprite 0 colour shadow
L_532A:
        ldx     #$FF                    ; 532A  immediate operand patched by saveRegsForCmdReturn: restore the merge loop's X
L_532C:
        ldy     #$FF                    ; 532C  immediate operand patched by saveRegsForCmdReturn: restore the merge loop's Y
        rts                             ; 532E  back into the middle of the packet merge loop


; ----------------------------------------------------------------------
; saveRegsForCmdReturn - Two-instruction helper for the handlers that are called from the middle of
; the packet merge loop instead of from the table dispatcher: it copies X and Y into the LDX/LDY
; immediate operands at
; $532B and $532D, so the shared exit at $532A hands the merger back exactly the loop registers it
; had.
; In:  X, Y
; Out: self-modifies $532B and $532D
; Called from: cmdSessionControl $523E and cmdDroneMove $52D9
; ----------------------------------------------------------------------
saveRegsForCmdReturn:
        stx     L_532A+1                ; 532F  write X into the LDX # operand of the shared exit at $532A
        sty     L_532C+1                ; 5332  write Y into the LDY # operand at $532C
        rts                             ; 5335  done - the exit will now hand the caller back its own registers


; ----------------------------------------------------------------------
; cmdDroneDetonate - Handler of command $8E (3 bytes: column, row). For our own command it clears the
; 'strike pending' bit 5 of droneControlFlags. If droneArrivalFlags bit 7 is already set the drone
; died or arrived before the order was exchanged, so the detonation is cancelled by overwriting the
; $8E byte in the merged buffer with $0E in place - bit 7 clear makes executeTurnCommands skip it
; while $0E & $3F still gives length 3, so the film keeps a correctly sized no-op. Otherwise the drone
; is marked detonated, the coordinates are mirrored, sound $11 plays, applyDroneBlast damages the 2x2
; blast area and the blast-ring sprite is drawn; a remote detonation finally clears our copy of his
; drone state through the tail at $537E.
; In:  the queued command, cmdFromOpponent zp_76, droneArrivalFlags $92F9, filmPlaybackMode $0B9B
; Out: $92F9, $920D, the command byte in mergedCmdBuffer, sound, blast damage and the marker sprite
; Called from: commandHandlerTable slot for $8E ($4D46)
; ----------------------------------------------------------------------
cmdDroneDetonate:
        jsr     readCmdArgs2            ; 5336  cmdParam1 = target column, cmdParam2 = target row; read index += 3
        lda     cmdFromOpponent         ; 5339  whose drone is detonating?
        bmi     L_5345                  ; 533B  the opponent's - nothing local to clean up
        lda     droneControlFlags       ; 533D  $920D drone state flags
        and     #$DF                    ; 5340  clear bit 5 = 'a strike of ours is pending' (strikeWithDrone set it at $8187)
        sta     droneControlFlags       ; 5342  store the flags back
L_5345:
        lda     droneArrivalFlags       ; 5345  $92F9 bit 7 = the drone has already finished its flight
        bpl     L_5354                  ; 5348  still airborne, so the strike counts

; ----------------------------------------------------------------------
; The drone had already finished when the detonate order came round, so cancel the command in place
; instead of executing it.
; ----------------------------------------------------------------------
        ldy     mergedCmdReadIndex      ; 534A  readCmdArgs2 left the read index 3 past the command byte ...
        lda     #$0E                    ; 534D  $0E has bit 7 clear so executeTurnCommands skips it, and $0E & $3F still yields length 3
        sta     outgoingCmdRingEnd,y    ; 534F  $908C+readIndex is mergedCmdBuffer[command byte]: neuter the $8E in place, so the film records a correctly sized no-op
        bne     L_537E                  ; 5352  always taken (A = $0E)

; ----------------------------------------------------------------------
; A real detonation: mark the drone as burst, damage the 2x2 blast area and draw the ring.
; ----------------------------------------------------------------------
L_5354:
        lda     #$40                    ; 5354  $40 = the drone has detonated
        sta     droneArrivalFlags       ; 5356  $92F9 drone life flags
        jsr     mirrorCmdCoordsIfRemoteDec; 5359  mirror the 2x2 blast origin into our frame for a remote strike
        lda     filmPlaybackMode        ; 535C  $0B9B bit 7 = film playback
        bmi     L_5365                  ; 535F  during playback everything is audible
        lda     cmdFromOpponent         ; 5361  whose strike is it?
        bpl     L_536A                  ; 5363  our own strike is silent here - the drone screen already made its noise
L_5365:
        lda     #$11                    ; 5365  sound $11 = the drone explosion
        jsr     playSound               ; 5367  hand it to the sound driver
L_536A:
        lda     #$19                    ; 536A  $19 = 25 points of blast damage
        jsr     applyDroneBlast         ; 536C  overlay $72A1: damage whatever stands in the four cells of the 2x2 blast  (also mapGenerator6F00:blobWriteTile)
        ldx     cmdParam1               ; 536F  blast column
        ldy     cmdParam2               ; 5371  blast row
        lda     #$01                    ; 5373  marker style 1 = the drone burst ring (offsets 8,9 and colour 4 from the table at $33C2)
        jsr     showExplosionMarker     ; 5375  $33C8: put the blast-ring sprite on that cell and start its 90-frame timer
        jmp     L_537E                  ; 5378  join the shared tail


; ----------------------------------------------------------------------
; cmdDroneRelease - Handler of the 1-byte command $8F, queued by releaseDroneControl ($8230) when the
; player lets go of the drone. It only steps the read index and falls into the tail shared with
; cmdDroneDetonate, which for a remote command clears bits 0-5 of droneControlFlags - our copy of the
; opponent's drone state - while bits 6-7, which describe our own drone flight, are preserved.
; In:  cmdFromOpponent zp_76, droneControlFlags $920D
; Out: mergedCmdReadIndex += 1; $920D &= $C0 for a remote command
; Called from: commandHandlerTable slot for $8F ($4D48); the tail at $537E also from cmdDroneDetonate
; ----------------------------------------------------------------------
cmdDroneRelease:
        inc     mergedCmdReadIndex      ; 537B  1-byte command $8F: step the read index over it
L_537E:
        lda     cmdFromOpponent         ; 537E  shared tail of $8E and $8F: whose drone was it?
        bpl     L_538A                  ; 5380  our own drone state belongs to the drone screen, not here
        lda     droneControlFlags       ; 5382  $920D drone state flags
        and     #$C0                    ; 5385  keep bits 6-7 (our own flight) and clear bits 0-5, our copy of his drone state
        sta     droneControlFlags       ; 5387  store the flags back
L_538A:
        rts                             ; 538A  done


; ----------------------------------------------------------------------
; cmdDroneShotDown - Handler of the 1-byte command $92. The only place that ever queues it is
; checkStrikeHitsDrone ($79CC), which runs right after a COMCEN missile is fired and asks whether the
; aim point falls on one of the 2x2 cells the tracked drone occupies: $92 therefore means 'a missile
; has shot the drone down', not 'the drone arrived'. It clears bits 6-7 of droneControlFlags (no drone
; flight in progress any more) and, for our own command, sets bit 4 of droneArrivalFlags so
; showDroneKillBurst ($79DD) plays the kill effect on the next frame. Both packet mergers additionally
; set droneArrivalFlags = $80 the moment a $92 is seen in either packet, and sendOutgoingPacket drops
; a queued $92 when the drone detonated first.
; In:  cmdFromOpponent zp_76, droneControlFlags $920D, droneArrivalFlags $92F9
; Out: $920D &= $3F; $92F9 |= $10 for an own command; mergedCmdReadIndex += 1
; Called from: commandHandlerTable slot for $92 ($4D4E)
; (the survey called this cmdDroneArrived; renamed because $92 is the shot-down report, and the drone
; reaching its target is reported with $8E/$8F instead)
; ----------------------------------------------------------------------
cmdDroneShotDown:
        inc     mergedCmdReadIndex      ; 538B  1-byte command $92: step the read index over it
        lda     droneControlFlags       ; 538E  $920D drone state flags
        and     #$3F                    ; 5391  clear bits 6-7: no drone flight is in progress any more
        sta     droneControlFlags       ; 5393  store the flags back
        lda     cmdFromOpponent         ; 5396  who fired the missile that killed the drone?
        bmi     L_53A2                  ; 5398  the opponent scored the kill - nothing else to record here
        lda     droneArrivalFlags       ; 539A  $92F9 drone life flags
        ora     #$10                    ; 539D  bit 4 = we shot a drone down; showDroneKillBurst ($79DD) picks this up next frame and plays the effect
        sta     droneArrivalFlags       ; 539F  store the flags back
L_53A2:
        rts                             ; 53A2  done


; ----------------------------------------------------------------------
; cmdDroneLaunch - Handler of the 1-byte command $97: droneArrivalFlags = 0 for a fresh drone in the
; air. The opponent's launch also raises the 'DRONE ALERT!' message with sound through overlay B's
; showDroneAlert. Either way, if that side still has drones one is spent and markComcenRevealed
; exposes the launching COMCEN for six rounds - the price of flying a drone.
; In:  cmdFromOpponent zp_76, dronesLeft $92AE
; Out: $92F9 = 0, $92AE[side] decremented, COMCEN reveal timer and visibility bits via $403B
; Called from: commandHandlerTable slot for $97 ($4D58)
; ----------------------------------------------------------------------
cmdDroneLaunch:
        inc     mergedCmdReadIndex      ; 53A3  1-byte command $97: step the read index over it
        ldx     #$00                    ; 53A6  index 0 = this machine's allowances
        stx     droneArrivalFlags       ; 53A8  $92F9 = 0: a fresh drone is in the air
        lda     cmdFromOpponent         ; 53AB  who launched?
        bpl     L_53B4                  ; 53AD  our own launch - the drone screen has already told the player
        jsr     showDroneAlert          ; 53AF  overlay $820F: sound 7 plus message $20 'DRONE ALERT!'
        ldx     #$01                    ; 53B2  index 1 = the opponent's allowances
L_53B4:
        lda     dronesLeft,x            ; 53B4  $92AE[side] = drones that side still has
        beq     L_53BF                  ; 53B7  already out of drones - nothing to charge
        dec     dronesLeft,x            ; 53B9  spend one drone
        jsr     markComcenRevealed      ; 53BC  $403B: launching exposes that side's COMCEN for 6 rounds (X selects the side)
L_53BF:
        rts                             ; 53BF  done


; ----------------------------------------------------------------------
; cmdMissileStrike - Handler of command $90 (3 bytes), which carries two different payloads. During a
; setup phase (gamePhase non-zero) the two bytes are the sender's drone and missile allowances and are
; stored in dronesLeft/missilesLeft for that side - this is what overlay A's editDronesAndMissiles
; sends. During the battle they are the target cell of a COMCEN missile: if the firing side still has
; missiles and the game has not ended, one missile is spent, markComcenRevealed exposes the firing
; COMCEN, the coordinates are mirrored for a remote shot and the unit standing on the cell takes 14
; points of damage, halved to 7 for a COMCEN. The blast-ring sprite is drawn in either case and sound
; $0C plays unless it is our own live shot.
; In:  the queued command, gamePhase zp_B1, cmdFromOpponent zp_76, gameEndReason $0BA8, the $F000 map
; Out: $92AE/$92B0[side], unit energy and score via $3B22, the blast-ring sprite, sound
; Called from: commandHandlerTable slot for $90 ($4D4A)
; ----------------------------------------------------------------------
cmdMissileStrike:
        jsr     readCmdArgs2            ; 53C0  read the two payload bytes into cmdParam1/cmdParam2; read index += 3
        lda     gamePhase               ; 53C3  zp_B1 is zero only while the battle runs
        beq     L_53D9                  ; 53C5  battle: the payload is a target cell

; ----------------------------------------------------------------------
; Setup phase: the two bytes are the sender's drone and missile allowances, not a target cell.
; ----------------------------------------------------------------------
        ldx     #$00                    ; 53C7  setup phase: the payload is the sender's drone/missile allowance; index 0 = us
        lda     cmdFromOpponent         ; 53C9  who sent it?
        bpl     L_53CE                  ; 53CB  our own settings
        inx                             ; 53CD  index 1 = the opponent
L_53CE:
        lda     cmdParam1               ; 53CE  first byte = number of drones
        sta     dronesLeft,x            ; 53D0  $92AE[side]
        lda     cmdParam2               ; 53D3  second byte = number of missiles
        sta     missilesLeft,x          ; 53D5  $92B0[side]
        rts                             ; 53D8  allowances stored


; ----------------------------------------------------------------------
; Battle phase: the two bytes are the target cell of a COMCEN missile.
; ----------------------------------------------------------------------
L_53D9:
        ldx     #$00                    ; 53D9  battle: index 0 = our own missile stock
        lda     cmdFromOpponent         ; 53DB  who fired?
        bpl     L_53E4                  ; 53DD  our own shot needs no mirroring
        jsr     mirrorCmdCoordsIfRemote ; 53DF  turn his aim point into our coordinates (39-v)
        ldx     #$01                    ; 53E2  index 1 = the opponent's missile stock
L_53E4:
        lda     missilesLeft,x          ; 53E4  $92B0[side]
        beq     L_542D                  ; 53E7  the firing side has no missiles left - ignore the shot
        lda     gameEndReason           ; 53E9  $0BA8 is non-zero once the game has ended
        bne     L_542D                  ; 53EC  no shots after the final whistle
        dec     missilesLeft,x          ; 53EE  spend one missile
        jsr     markComcenRevealed      ; 53F1  firing gives the firing COMCEN away for 6 rounds
        ldx     cmdParam1               ; 53F4  target column
        cpx     #$28                    ; 53F6  $28 = 40, the width of the battlefield
        bcs     L_5416                  ; 53F8  off the map: draw the ring but hit nothing
        ldy     cmdParam2               ; 53FA  target row
        cpy     #$28                    ; 53FC  $28 = 40, the height of the battlefield
        bcs     L_5416                  ; 53FE  off the map
        jsr     readMapCell             ; 5400  $25FA: A = the $F000 map byte of column X, row Y
        bpl     L_5416                  ; 5403  bit 7 clear = plain terrain, nobody is standing there
        and     #$7F                    ; 5405  bits 0-6 = the index of the unit occupying the cell
        tay                             ; 5407  Y = the victim
        lda     #$0E                    ; 5408  $0E = 14 points of missile damage
        cpy     #$31                    ; 540A  unit 49 = side 0's COMCEN
        beq     L_5412                  ; 540C  halve it
        cpy     #$63                    ; 540E  unit 99 = side 1's COMCEN
        bne     L_5413                  ; 5410  an ordinary unit takes the full 14
L_5412:
        lsr     a                       ; 5412  a COMCEN only takes half, 7 points
L_5413:
        jsr     applyHitToUnit          ; 5413  $3B22: subtract the energy, score it, explode the unit or knock the COMCEN out
L_5416:
        ldx     cmdParam1               ; 5416  blast column
        ldy     cmdParam2               ; 5418  blast row
        lda     #$00                    ; 541A  marker style 0 = the missile blast ring (offsets 10,8 and colour 2 from the table at $33C2)
        jsr     showExplosionMarker     ; 541C  $33C8: draw the ring sprite on the target cell
        lda     cmdFromOpponent         ; 541F  whose shot?
        bmi     L_5428                  ; 5421  the opponent's shot is always audible
        lda     filmPlaybackMode        ; 5423  $0B9B bit 7 = film playback
        bpl     L_542D                  ; 5426  our own shot is silent in live play - the radar screen made its own noise
L_5428:
        lda     #$0C                    ; 5428  sound $0C = missile impact
        jsr     playSound               ; 542A  hand it to the sound driver
L_542D:
        rts                             ; 542D  done


; ----------------------------------------------------------------------
; cmdSelfDestruct - Handler of command $91 (2 bytes: unit; bit 7 of the argument means 'the whole
; group this unit belongs to'). A plain unit index falls straight into destroyUnit. For a group the
; match value (unitFlagsTable & $3C) | $40 is patched into the CMP operand at $544D and units 99..0
; are scanned, calling destroyUnit on every member; because the $3C mask keeps the side bit 5, a group
; can never span both armies.
; In:  the queued command; cmdParam0 = unit index, bit 7 = group mode; unitFlagsTable $F708
; Out: unitTypeTable $F76C entries set to $FF; self-modifies $544E; cmdParam0 reused as the loop
;     counter
; Called from: commandHandlerTable slot for $91 ($4D4C)
; ----------------------------------------------------------------------
cmdSelfDestruct:
        jsr     readCmdArg1             ; 542E  cmdParam0 = the unit index; bit 7 set means 'the whole group this unit belongs to'
        lda     cmdParam0               ; 5431  look at the argument
        bpl     destroyUnit             ; 5433  a plain unit index: destroy just that one and return through destroyUnit
        and     #$7F                    ; 5435  strip the 'whole group' flag to get the unit index
        tay                             ; 5437  Y = the named unit
        lda     unitFlagsTable,y        ; 5438  its $F708 flags
        and     #$3C                    ; 543B  group id in bits 2-4 plus the side bit 5, so a group can never span both armies
        ora     #$40                    ; 543D  bit 6 = 'in a group', which every member carries
        sta     L_544D+1                ; 543F  patch the CMP immediate of the scan loop with the group signature to look for

; ----------------------------------------------------------------------
; Scan units 99..0 and destroy every living member whose group signature matches the one patched into
; the CMP at $544D. cmdParam0 doubles as the loop counter because destroyUnit reads its victim from
; there.
; ----------------------------------------------------------------------
        lda     #$63                    ; 5442  $63 = 99, the highest unit index
        sta     cmdParam0               ; 5444  reuse cmdParam0 as the loop counter, because destroyUnit reads its victim from there
L_5446:
        ldy     cmdParam0               ; 5446  Y = the unit currently being tested
        lda     unitFlagsTable,y        ; 5448  its $F708 flags
        and     #$7C                    ; 544B  group id, side and in-group bit
L_544D:
        cmp     #$FF                    ; 544D  immediate operand patched at $543F with the group signature
        bne     L_5454                  ; 544F  not a member of the group
        jsr     destroyUnit             ; 5451  blow this member up
L_5454:
        dec     cmdParam0               ; 5454  step down one unit
        bpl     L_5446                  ; 5456  keep going until unit 0 has been tested
        rts                             ; 5458  the whole group is gone


; ----------------------------------------------------------------------
; destroyUnit - Removes unit cmdParam0 from play. A COMCEN (unit 49 or 99) is never allowed to
; self-destruct and returns unchanged. Everything else takes 52 points of damage through sub_3B52 -
; more than the 50-point maximum energy, so the kill is certain and runs through the normal explosion,
; sound, score and redraw path - and then has its slot freed outright by writing $FF into
; unitTypeTable.
; In:  cmdParam0 zp_73 = unit index
; Out: unitTypeTable $F76C[unit] = $FF plus all the side effects of sub_3B52; Y = unit index
; Called from: cmdSelfDestruct (branch at $5433 and JSR at $5451)
; ----------------------------------------------------------------------
destroyUnit:
        ldy     cmdParam0               ; 5459  Y = the unit to destroy (also the loop counter of cmdSelfDestruct)
        cpy     #$31                    ; 545B  unit 49 = side 0's COMCEN
        beq     L_546F                  ; 545D  a COMCEN may never self-destruct
        cpy     #$63                    ; 545F  unit 99 = side 1's COMCEN
        beq     L_546F                  ; 5461  likewise protected
        lda     #$34                    ; 5463  $34 = 52 points, more than the 50-point maximum energy, so the kill is certain
        jsr     applyHitToUnitNoAlert   ; 5465  the damage path of applyHitToUnit without its attacker/SPY preamble: explosion, sound, score, redraw
        ldy     cmdParam0               ; 5468  reload the unit index (sub_3B52 clobbers Y)
        lda     #$FF                    ; 546A  $FF = free slot, bit 7 set = dead
        sta     unitTypeTable,y         ; 546C  $F76C: release the record so nothing draws or scans it again
L_546F:
        rts                             ; 546F  done


; ----------------------------------------------------------------------
; cmdMoveKeepGroup - Handler of command $98 MOVE TO GOAL (4 bytes: unit, column, row), the order
; issued to each member of a group in turn by $4C0B. It reads and mirrors the arguments and jumps to
; setGroupMemberDestination ($4C59), which counts the order off pendingGroupOrderCount when the unit
; belongs to the group being ordered and then sets the destination through $34D5 without disturbing
; the group bits - unlike command $80, which drops the unit out of its group.
; In:  the queued command; cmdParam0 = unit, cmdParam1/cmdParam2 = goal column/row
; Out: destination tables of the unit, pendingGroupOrderCount $929E; read index += 4
; Called from: commandHandlerTable slot for $98 ($4D5A)
; ----------------------------------------------------------------------
cmdMoveKeepGroup:
        jsr     readCmdArgs3            ; 5470  cmdParam0 = unit, cmdParam1/cmdParam2 = goal column/row; read index += 4
        jsr     mirrorCmdCoordsIfRemote ; 5473  mirror a remote player's goal into our coordinates
        jmp     setGroupMemberDestination; 5476  $4C59: count the order off pendingGroupOrderCount and set the destination without touching the group bits


; ----------------------------------------------------------------------
; cmdAddEnergy - Handler of command $9A (3 bytes: unit, amount), sent by the COMCEN energy repair on
; the REPAIR tab. Unless the game has already ended by a COMCEN knock-out ($0BA8 bit 7) the amount is
; added to bits 0-5 of the unit's unitEnergyTable entry and saturated at 50; the two visibility bits
; 6-7 are saved into the ORA operand at $5498 and put back afterwards.
; In:  the queued command; cmdParam1 = unit, cmdParam2 = energy to add; gameEndReason $0BA8
; Out: unitEnergyTable $F9C4[unit]; self-modifies $5499; read index += 3
; Called from: commandHandlerTable slot for $9A ($4D5E)
; ----------------------------------------------------------------------
cmdAddEnergy:
        jsr     readCmdArgs2            ; 5479  cmdParam1 = the unit, cmdParam2 = the energy to add; read index += 3
        ldy     cmdParam1               ; 547C  Y = the unit being repaired
        lda     gameEndReason           ; 547E  $0BA8 bit 7 set = a COMCEN has been knocked out
        bmi     L_549D                  ; 5481  no repairs once the game has been decided
        lda     unitEnergyTable,y       ; 5483  $F9C4: bits 0-5 energy 0-50, bits 6-7 = spotted / visible flags
        tax                             ; 5486  keep the whole byte in X
        and     #$C0                    ; 5487  isolate the two visibility bits
        sta     L_5498+1                ; 5489  patch them into the ORA immediate at $5498 so they survive the rewrite
        txa                             ; 548C  get the byte back
        and     #$3F                    ; 548D  the current energy on its own
        clc                             ; 548F  clear carry for the add
        adc     cmdParam2               ; 5490  add the repair amount
        cmp     #$32                    ; 5492  $32 = 50 = full energy
        bcc     L_5498                  ; 5494  still below the cap
        lda     #$32                    ; 5496  saturate at 50
L_5498:
        ora     #$FF                    ; 5498  immediate operand patched at $5489 with the saved visibility bits
        sta     unitEnergyTable,y       ; 549A  store the repaired energy
L_549D:
        rts                             ; 549D  done


; ----------------------------------------------------------------------
; cmdHaltGroup - Handler of command $9B (2 bytes: group byte), the group HALT order. Units 99..0 are
; scanned and for every living member (unitFlagsTable & $7C equal to the group byte | $40) the current
; position is copied into the destination and waypoint, the two 'arrived' bits (unitDestColTable bit 7
; and unitDisplayFlagsTable bit 7) are set, the cloak bits of the waypoint column are preserved
; through the ORA operand patched at $54CF, and the blitz bits of the waypoint row are dropped because
; a plain row value is written over them.
; In:  the queued command; cmdParam0 = group byte; unitTypeTable $F76C, unitFlagsTable $F708
; Out: $F7D0, $F834, $F898, $F8FC and $FB54 of every member; self-modifies $54D0; read index += 2
; Called from: commandHandlerTable slot for $9B ($4D60)
; ----------------------------------------------------------------------
cmdHaltGroup:
        jsr     readCmdArg1             ; 549E  cmdParam0 = the group byte (id in bits 2-4, side in bit 5); read index += 2
        lda     cmdParam0               ; 54A1  the group byte
        ora     #$40                    ; 54A3  bit 6 = 'in a group', so the byte can be compared with unitFlagsTable & $7C directly
        sta     cmdParam0               ; 54A5  keep it as the match value

; ----------------------------------------------------------------------
; Walk units 99..0 and stop every living member of the group where it stands.
; ----------------------------------------------------------------------
        ldy     #$63                    ; 54A7  start at unit 99 and walk down to 0
L_54A9:
        lda     unitTypeTable,y         ; 54A9  $F76C, bit 7 set = dead or free slot
        bmi     L_54DD                  ; 54AC  skip dead units
        lda     unitFlagsTable,y        ; 54AE  the unit's $F708 flags
        and     #$7C                    ; 54B1  group id, side and in-group bit
        cmp     cmdParam0               ; 54B3  does it belong to the group being halted?
        bne     L_54DD                  ; 54B5  no - next unit
        lda     unitColTable,y          ; 54B7  the member's current column
        sta     L_54CF+1                ; 54BA  patch it into the ORA immediate at $54CF
        ora     #$80                    ; 54BD  bit 7 = standing on its destination
        sta     unitDestColTable,y      ; 54BF  $F7D0 destination column = where the unit already is
        lda     unitDisplayFlagsTable,y ; 54C2  $FB54 draw flags
        ora     #$80                    ; 54C5  bit 7 = idle / at destination
        sta     unitDisplayFlagsTable,y ; 54C7  store the draw flags back
        lda     unitPathColTable,y      ; 54CA  $F898 waypoint column
        and     #$C0                    ; 54CD  keep only the cloak bits 6-7
L_54CF:
        ora     #$FF                    ; 54CF  immediate operand patched at $54BA with the member's current column
        sta     unitPathColTable,y      ; 54D1  waypoint = current position, cloak state untouched
        lda     unitRowTable,y          ; 54D4  the member's current row
        sta     unitDestRowTable,y      ; 54D7  $F834 destination row = where the unit already is
        sta     unitPathRowTable,y      ; 54DA  $F8FC waypoint row likewise; a row is below 64, so this also clears the blitz bits 6-7
L_54DD:
        dey                             ; 54DD  step down one unit
        bpl     L_54A9                  ; 54DE  continue until unit 0 has been tested
        rts                             ; 54E0  the whole group has stopped


; ----------------------------------------------------------------------
; cmdSetRecycler - Handler of command $9C (4 bytes: mode, column, row), sent by overlay A's
; chooseRecycler ($8436) during the setup of the game types that have a recycler. recyclerMode[side]
; takes the mode, with side = the sender's absolute side (playerSide, EOR 1 when the command is
; remote); for modes 0 and 1 the coordinates are mirrored for a remote sender and stored in
; recyclerCol/recyclerRow[side]. Our own recycler is additionally painted onto the overview map, the
; enemy one is not - it has to be spotted first.
; In:  the queued command; cmdParam0 = mode, cmdParam1/2 = column/row; playerSide $0B9F,
;     cmdFromOpponent
; Out: $92A6/$92A8/$92AA indexed by side, map redraw via $3F27; self-modifies $550C; read index += 4
; Called from: commandHandlerTable slot for $9C ($4D62)
; ----------------------------------------------------------------------
cmdSetRecycler:
        jsr     readCmdArgs3            ; 54E1  cmdParam0 = mode, cmdParam1/cmdParam2 = column/row; read index += 4
        lda     playerSide              ; 54E4  $0B9F = the side this machine plays
        ldy     cmdFromOpponent         ; 54E7  who sent the command?
        bpl     L_54ED                  ; 54E9  our own choice
        eor     #$01                    ; 54EB  remote: the sender is the other side
L_54ED:
        tay                             ; 54ED  Y = the sender's absolute side, the index of the per-side recycler tables
        sta     L_550B+1                ; 54EE  also stash it in the LDY immediate at $550B, because Y is reused before the draw call
        lda     cmdParam0               ; 54F1  the chosen recycler mode
        sta     recyclerMode,y          ; 54F3  $92A6[side]
        cmp     #$02                    ; 54F6  modes 2 and up carry no coordinates
        bcs     L_54FD                  ; 54F8  skip the mirror for those
        jsr     mirrorCmdCoordsIfRemote ; 54FA  mirror a remote placement into our coordinate frame
L_54FD:
        lda     cmdParam1               ; 54FD  recycler column
        sta     recyclerCol,y           ; 54FF  $92A8[side]
        lda     cmdParam2               ; 5502  recycler row
        sta     recyclerRow,y           ; 5504  $92AA[side]
        lda     cmdFromOpponent         ; 5507  whose recycler is it?
        bmi     L_5510                  ; 5509  the enemy recycler stays hidden until it is spotted
L_550B:
        ldy     #$FF                    ; 550B  immediate operand patched at $54EE with our own side index
        jsr     drawRecyclerOnMap       ; 550D  $3F27: write the recycler glyph $67 into the map cell (or under whichever unit stands there)
L_5510:
        rts                             ; 5510  done


; ----------------------------------------------------------------------
; cmdChooseSide - Handler of command $9D (2 bytes: the sender's side-preference token), the
; side-selection handshake. In a solo game playerSide is simply forced to 0. In a modem game only the
; opponent's copy of the command matters: our own token was parked in $90F8 by sendChooseSide, and the
; player with the higher token becomes side 1 (the carry of the CPY is rotated into A). gamePhase is
; then set to $DF, whose clear bit 5 releases the wait loop at $797C. If both tokens are identical
; nobody wins: a fresh random token with bit 7 set is drawn and the code falls into sendChooseSide to
; send command $9D again, leaving gamePhase untouched so the loop keeps waiting. That only terminates
; because the two machines' game RNGs have drifted apart before a game starts (they are forced into
; step only once the map seed is exchanged).
; In:  the queued command; cmdParam0 = his token, selectedUnit $90F8 = ours, isSoloTrainer $0BA5,
;     zp_76
; Out: playerSide $0B9F = 0 or 1 and gamePhase zp_B1 = $DF, or a retry through sendChooseSide
; Called from: commandHandlerTable slot for $9D ($4D64)
; ----------------------------------------------------------------------
cmdChooseSide:
        jsr     readCmdArg1             ; 5511  cmdParam0 = the sender's side-preference token (0 or $FF from the setup screen, or a random tie-break byte)
        lda     isSoloTrainer           ; 5514  $0BA5 bit 7 = PRACTICE WITH SOLO TRAINER
        bpl     L_551D                  ; 5517  modem game: settle it against the opponent
        lda     #$00                    ; 5519  solo: the human always plays side 0
        beq     L_552B                  ; 551B  always taken
L_551D:
        lda     cmdFromOpponent         ; 551D  which copy of the command is this?
        bpl     L_5532                  ; 551F  our own copy tells us nothing we did not already know
        lda     #$00                    ; 5521  build the resulting side number in A
        ldy     selectedUnit            ; 5523  $90F8 holds our own token, parked there by sendChooseSide
        cpy     cmdParam0               ; 5526  compare it with his; carry set means our token is the higher one
        beq     L_5533                  ; 5528  identical tokens - nobody wins, so re-pick
        rol     a                       ; 552A  A = the carry: 1 when our token was higher, so we take side 1
L_552B:
        sta     playerSide              ; 552B  $0B9F is 0 or 1 from now on
        lda     #$DF                    ; 552E  $DF clears bit 5 of the phase byte ...
        sta     gamePhase               ; 5530  ... which is the bit the side-selection loop at $797C spins on
L_5532:
        rts                             ; 5532  the side handshake is finished

L_5533:
        jsr     nextGameRandom          ; 5533  $FD5D: draw a fresh token; the two RNG states have drifted apart before a game starts, so the values probably differ
        ora     #$80                    ; 5536  bit 7 marks it as a tie-break retry token
        sta     playerSide              ; 5538  park it in playerSide, from where sendChooseSide picks it up below (fall-through)

; ----------------------------------------------------------------------
; sendChooseSide - The sending half of the $9D exchange: copies playerSide into cmdParam0 and into
; selectedUnit $90F8 (where cmdChooseSide later finds our own token) and queues the 2-byte command
; $9D. At this point playerSide still holds a preference token - 0 or $FF chosen by the setup screen,
; or a random byte with bit 7 set after a tie - not yet a side number.
; In:  playerSide $0B9F
; Out: cmdParam0 zp_73, selectedUnit $90F8, two bytes appended to the outgoing command ring
; Called from: overlay A's side-selection screen ($7976), and by fall-through from cmdChooseSide's tie
;     path
; ----------------------------------------------------------------------
sendChooseSide:
        lda     playerSide              ; 553B  our own preference token (0 or $FF from the setup screen, or a tie-break byte)
        sta     cmdParam0               ; 553E  the argument byte of command $9D
        sta     selectedUnit            ; 5540  $90F8 doubles as the 'our own pick' scratch that cmdChooseSide compares against
        lda     #$9D                    ; 5543  command $9D CHOOSE SIDE
        jmp     queueCmd2               ; 5545  append the 2-byte command to the outgoing ring


; ----------------------------------------------------------------------
; cmdDigIn - Handler of command $9F DIG IN (2 bytes: unit): calls setUnitDigBits with $20, i.e.
; unitStunTable bits 5-7 = 001. The per-unit step code then advances those bits by $20 for every round
; the unit stays still until it counts as fortified.
; In:  the queued command; cmdParam0 = unit after setUnitDigBits
; Out: unitStunTable $F960[unit] = (old & $1F) | $20
; Called from: commandHandlerTable slot for $9F ($4D68)
; ----------------------------------------------------------------------
cmdDigIn:
        lda     #$20                    ; 5548  $20 = unitStunTable bits 5-7 set to 001, 'digging in'
        bne     setUnitDigBits          ; 554A  always taken

; ----------------------------------------------------------------------
; cmdDigOut - Handler of command $A0 DIG OUT (2 bytes: unit): setUnitDigBits with 0 clears the
; dig-progress bits 5-7 of unitStunTable, and bit 6 of unitTypeTable - the 'fortified' flag the combat
; code consults - is cleared as well.
; In:  the queued command; cmdParam0 = unit
; Out: unitStunTable $F960[unit] &= $1F, unitTypeTable $F76C[unit] &= $BF
; Called from: commandHandlerTable slot for $A0 ($4D6A)
; ----------------------------------------------------------------------
cmdDigOut:
        lda     #$00                    ; 554C  0 = unitStunTable bits 5-7 cleared, neither digging nor dug in
        jsr     setUnitDigBits          ; 554E  clear the dig bits and leave Y = the unit
        lda     unitTypeTable,y         ; 5551  $F76C of that unit
        and     #$BF                    ; 5554  clear bit 6 = 'fortified', the flag the combat code consults
        sta     unitTypeTable,y         ; 5556  store the type byte back
        rts                             ; 5559  done


; ----------------------------------------------------------------------
; setUnitDigBits - Shared prologue of cmdDigIn and cmdDigOut: patches the new dig bits into the ORA
; operand at $5567, reads the unit argument, leaves it in Y and falls into applyDigBitsToUnit.
; In:  A = the new bits 5-7 ($20 or $00); the queued command
; Out: self-modifies $5568; cmdParam0 and Y = the unit; unitStunTable[unit] rewritten
; Called from: cmdDigIn (branch at $554A) and cmdDigOut ($554E)
; ----------------------------------------------------------------------
setUnitDigBits:
        sta     L_5567+1                ; 555A  patch the new dig bits into the ORA immediate at $5567
        jsr     readCmdArg1             ; 555D  cmdParam0 = the unit index; read index += 2
        ldy     cmdParam0               ; 5560  Y = the unit, and it stays there for cmdDigOut

; ----------------------------------------------------------------------
; applyDigBitsToUnit - The two-line worker shared by the single-unit and the group dig handlers:
; unitStunTable[Y] = (old & $1F) | patchedBits, so the low-nibble stun counter and bit 4 survive while
; the dig-state bits 5-7 are replaced wholesale.
; In:  Y = unit index; the ORA operand at $5568 patched by setUnitDigBits or by the group entry at
;     $5573
; Out: unitStunTable $F960[Y]
; Called from: the group loop at $558F, and by fall-through from setUnitDigBits
; ----------------------------------------------------------------------
applyDigBitsToUnit:
        lda     unitStunTable,y         ; 5562  $F960: low nibble = stun counter, bit 4 spare, bits 5-7 = dig state
        and     #$1F                    ; 5565  keep the stun counter and bit 4
L_5567:
        ora     #$FF                    ; 5567  immediate operand patched at $555A or $5573 with the new dig bits
        sta     unitStunTable,y         ; 5569  store the new dig state
        rts                             ; 556C  done


; ----------------------------------------------------------------------
; cmdGroupDigIn - Handler of command $A1 (2 bytes: group byte): patches the dig bits to $20 and runs
; the shared group loop that follows.
; In:  the queued command; cmdParam0 = group byte
; Out: unitStunTable $F960 of every living member = (old & $1F) | $20
; Called from: commandHandlerTable slot for $A1 ($4D6C)
; ----------------------------------------------------------------------
cmdGroupDigIn:
        lda     #$20                    ; 556D  $20 = dig bits 001 for every member
        bne     L_5573                  ; 556F  always taken

; ----------------------------------------------------------------------
; cmdGroupDigOut - Handler of command $A2 (2 bytes: group byte) and the entry of the shared group dig
; loop: patches the dig bits to 0, reads the group byte, ORs $40 into it so it can be compared with
; unitFlagsTable & $7C, and walks units 99..0 calling applyDigBitsToUnit for every living member.
; Unlike cmdDigOut it never clears bit 6 of unitTypeTable, so a group dug in as a group can only be
; un-fortified one unit at a time.
; In:  the queued command; cmdParam0 = group byte; unitTypeTable $F76C, unitFlagsTable $F708
; Out: unitStunTable $F960 of every member; self-modifies $5568; read index += 2
; Called from: commandHandlerTable slot for $A2 ($4D6E), and by branch from cmdGroupDigIn
; ----------------------------------------------------------------------
cmdGroupDigOut:
        lda     #$00                    ; 5571  0 = clear the dig bits of every member
L_5573:
        sta     L_5567+1                ; 5573  patch the ORA immediate shared with applyDigBitsToUnit
        jsr     readCmdArg1             ; 5576  cmdParam0 = the group byte; read index += 2
        lda     cmdParam0               ; 5579  the group byte
        ora     #$40                    ; 557B  bit 6 = 'in a group', so it matches unitFlagsTable & $7C directly
        sta     cmdParam0               ; 557D  keep it as the match value
        ldy     #$63                    ; 557F  walk units 99 down to 0
L_5581:
        lda     unitTypeTable,y         ; 5581  $F76C, bit 7 set = dead or free slot
        bmi     L_5592                  ; 5584  skip dead units
        lda     unitFlagsTable,y        ; 5586  the unit's $F708 flags
        and     #$7C                    ; 5589  group id, side and in-group bit
        cmp     cmdParam0               ; 558B  is it a member of this group?
        bne     L_5592                  ; 558D  no - next unit
        jsr     applyDigBitsToUnit      ; 558F  rewrite this member's dig bits
L_5592:
        dey                             ; 5592  step down one unit
        bpl     L_5581                  ; 5593  continue until unit 0 has been tested
        rts                             ; 5595  note that, unlike cmdDigOut, this never clears bit 6 of unitTypeTable


; ----------------------------------------------------------------------
; cmdSetFormation - Handler of command $A3 (3 bytes: group leader unit, rotation mode 0-2): reads the
; two arguments and jumps to rotateGroupFormation ($4C84), which transforms every member's offset from
; the leader's destination with the rule pair at $4C7E/$4C81 and re-issues the destinations.
; In:  the queued command; cmdParam1 = leader unit index, cmdParam2 = rotation mode
; Out: the destinations of all the leader's group members; read index += 3
; Called from: commandHandlerTable slot for $A3 ($4D70)
; ----------------------------------------------------------------------
cmdSetFormation:
        jsr     readCmdArgs2            ; 5596  cmdParam1 = the group leader's unit index, cmdParam2 = rotation mode 0-2; read index += 3
        jmp     rotateGroupFormation    ; 5599  $4C84: rotate every member's offset from the leader using the rules at $4C7E


; ----------------------------------------------------------------------
; cmdSetGameType - Handler of command $A4 (2 bytes): stores the argument into gameTypeOptions ($0BA3),
; which carries the game type in bits 0-2, the UNIT MENUS and DAMAGE switches in bits 3-4, CUSTOM
; rules in bit 6 and the custom-map flag in bit 7. Sent by overlay A's runMapTypeMenu ($833E) so both
; machines agree on the scenario before the map is generated.
; In:  the queued command; cmdParam0 = the options byte
; Out: gameTypeOptions $0BA3; read index += 2
; Called from: commandHandlerTable slot for $A4 ($4D72)
; ----------------------------------------------------------------------
cmdSetGameType:
        jsr     readCmdArg1             ; 559C  cmdParam0 = the options byte; read index += 2
        lda     cmdParam0               ; 559F  the options byte
        sta     gameTypeOptions         ; 55A1  $0BA3: bits 0-2 game type, bit 3 UNIT MENUS, bit 4 DAMAGE, bit 6 CUSTOM rules, bit 7 custom map
        rts                             ; 55A4  both machines now agree on the scenario


; ----------------------------------------------------------------------
; cmdSetupOption - Handler of command $A5 (3 bytes: kind, value), the generic carrier of overlay A's
; custom-rules editors. Kind 0 (HANDICAP) adds value*10 to the 16-bit counter $92A2/$92A4 indexed by
; the sender's absolute side; because that counter holds the points scored against a side, this is a
; head start credited to the sender's opponent, and the index has to be restored from the operand at
; $55C1 because multiply8x8 returns with X = 0. Kind 1 (TERRAIN PTS) stores the value in $92AC for the
; sender's opponent. Kind 2 and above - only kind 3, COMCEN SPEED, is ever sent - stores it in $92B4
; indexed 0 = us, 1 = him.
; In:  the queued command; cmdParam1 = kind, cmdParam2 = value; playerSide $0B9F, cmdFromOpponent
;     zp_76
; Out: $92A2/$92A4, $92AC or $92B4; self-modifies $55C2; read index += 3
; Called from: commandHandlerTable slot for $A5 ($4D74)
; ----------------------------------------------------------------------
cmdSetupOption:
        jsr     readCmdArgs2            ; 55A5  cmdParam1 = option kind, cmdParam2 = value; read index += 3
        lda     playerSide              ; 55A8  $0B9F = the side this machine plays
        ldy     cmdFromOpponent         ; 55AB  who sent the option?
        bpl     L_55B1                  ; 55AD  our own setting
        eor     #$01                    ; 55AF  remote: the other side
L_55B1:
        tax                             ; 55B1  X = the sender's absolute side
        sta     L_55C1+1                ; 55B2  stash it in the LDX immediate at $55C1, because multiply8x8 returns with X = 0
        ldy     cmdParam1               ; 55B5  the option kind
        bne     L_55D1                  ; 55B7  kinds 1 and up

; ----------------------------------------------------------------------
; Kind 0 - HANDICAP: add value*10 to the 16-bit score already credited against the sender's side.
; ----------------------------------------------------------------------
        lda     cmdParam2               ; 55B9  kind 0 = HANDICAP: the value counts in units of ten points
        ldy     #$0A                    ; 55BB  multiplier 10
        jsr     multiply8x8             ; 55BD  $FCCD: A = low byte and Y = high byte of value*10
        clc                             ; 55C0  clear carry for the 16-bit add
L_55C1:
        ldx     #$FF                    ; 55C1  immediate operand patched at $55B2: restore the sender's side index
        adc     sideScoreLo,x           ; 55C3  $92A2/$92A3 = the 16-bit points scored against that side, so this is a head start for his opponent
        sta     sideScoreLo,x           ; 55C6  store the new low byte
        tya                             ; 55C9  product high byte
        adc     sideScoreHi,x           ; 55CA  $92A4/$92A5 = the high halves of the two side scores
        sta     sideScoreHi,x           ; 55CD  store the new high byte
        rts                             ; 55D0  done


; ----------------------------------------------------------------------
; Kind 1 - TERRAIN PTS: the per-cell bonus for holding enemy ground, stored against the sender's
; opponent.
; ----------------------------------------------------------------------
L_55D1:
        cpy     #$02                    ; 55D1  kind 2 and up?
        bcs     L_55DF                  ; 55D3  yes - COMCEN SPEED and friends
        txa                             ; 55D5  kind 1 = TERRAIN PTS
        eor     #$01                    ; 55D6  the bonus is credited against the sender's opponent
        tax                             ; 55D8  X = that side
        lda     cmdParam2               ; 55D9  the value
        sta     terrainPointsBySide,x   ; 55DB  $92AC[side]: points per cell of enemy ground held, read at $47A9
        rts                             ; 55DE  done


; ----------------------------------------------------------------------
; Kind 2 and up - in practice only kind 3, COMCEN SPEED, indexed 0 = us / 1 = the opponent.
; ----------------------------------------------------------------------
L_55DF:
        ldx     #$00                    ; 55DF  kind 2 and up (only kind 3, COMCEN SPEED, is ever sent): index 0 = us
        lda     cmdFromOpponent         ; 55E1  who sent it?
        bpl     L_55E6                  ; 55E3  our own setting
        inx                             ; 55E5  index 1 = the opponent
L_55E6:
        lda     cmdParam2               ; 55E6  the value
        sta     comcenSpeedClass,x      ; 55E8  $92B4/$92B5; the value 4 is what makes $34E1 refuse to move a COMCEN at all
        rts                             ; 55EB  done


; ----------------------------------------------------------------------
; queueCmdBytePending - Entry used by the 1-byte commands ($8F, $92, $97, $9E): counts one command in
; pendingOwnCmdCount and falls into queueCmdByte to append the single byte.
; In:  A = the command byte
; Out: pendingOwnCmdCount $929F incremented, one byte appended to the outgoing ring
; Called from: $1814 in this file, and $79CC/$809A/$823C in both $6F00 overlays
; ----------------------------------------------------------------------
queueCmdBytePending:
        inc     pendingOwnCmdCount      ; 55EC  $929F counts commands queued but not yet executed; this entry is used by the 1-byte commands

; ----------------------------------------------------------------------
; queueCmdByte - Appends A to the 40-byte outgoing command ring at $9065, wrapping the write index
; $908D at 40, and always leaves an $FF end marker one byte past the new tail. It does nothing at all
; while a game film is being replayed, since the player issues no commands then.
; In:  A = byte, outgoingCmdWriteIndex $908D, filmPlaybackMode $0B9B
; Out: outgoingCmdRing $9065[], $908D advanced; Y clobbered
; Called from: queueCmd4/queueCmd3/queueCmd2 ($560A, $5612, $5617, $561C, $5622, $5627)
; ----------------------------------------------------------------------
queueCmdByte:
        bit     filmPlaybackMode        ; 55EF  $0B9B bit 7 = a film is being replayed
        bmi     L_5609                  ; 55F2  during playback the player issues no commands, so drop the byte
        ldy     outgoingCmdWriteIndex   ; 55F4  $908D = the write cursor of the 40-byte ring at $9065
        sta     outgoingCmdRing,y       ; 55F7  store the byte in the ring
        iny                             ; 55FA  advance the cursor
        cpy     #$28                    ; 55FB  $28 = 40 bytes in the ring
        bcc     L_5601                  ; 55FD  no wrap needed
        ldy     #$00                    ; 55FF  wrap back to the start
L_5601:
        sty     outgoingCmdWriteIndex   ; 5601  save the new write cursor
        lda     #$FF                    ; 5604  $FF = end-of-queue marker
        sta     outgoingCmdRing,y       ; 5606  always keep a terminator one byte past the last command byte
L_5609:
        rts                             ; 5609  done


; ----------------------------------------------------------------------
; queueCmd4 - Queues a 4-byte command: the code in A followed by cmdParam0, cmdParam1 and cmdParam2
; (used for
; $80 MOVE, $81, $8A, $8B, $8D, $98, $9C, $A6). It queues the code and cmdParam0 itself and then falls
; into
; queueCmd3, which counts the command once and queues the remaining two arguments.
; In:  A = command code, cmdParam0/1/2 zp_73-zp_75
; Out: four bytes in the outgoing ring, pendingOwnCmdCount $929F incremented once
; Called from: $183D, $349C, $4A4A, $4C0D, $5646 and overlay A ($79BF, $8438)
; ----------------------------------------------------------------------
queueCmd4:
        jsr     queueCmdByte            ; 560A  4-byte command: queue the command code held in A ...
        lda     cmdParam0               ; 560D  ... then cmdParam0, and fall into queueCmd3 for the rest

; ----------------------------------------------------------------------
; queueCmd3 - Queues a 3-byte command: the code in A followed by cmdParam1 and cmdParam2 (the commands
; of length 3 are $87, $88, $8E, $90, $9A, $A3 and $A5). It is also the tail of queueCmd4, which
; arrives here with A = cmdParam0 and its command code already in the ring, which is why the count is
; incremented here.
; In:  A = command code (or cmdParam0 when entered from queueCmd4), cmdParam1/cmdParam2
; Out: three bytes in the outgoing ring, pendingOwnCmdCount $929F incremented once
; Called from: $1939, $3C48, $4ACD, $6630 and both $6F00 overlays ($7667, $81A4, $84E9, $850E, $8544,
;     $85D8)
; ----------------------------------------------------------------------
queueCmd3:
        inc     pendingOwnCmdCount      ; 560F  count the command once (queueCmd4 arrives here having already queued its code)
        jsr     queueCmdByte            ; 5612  queue the command code, or cmdParam0 when entered from queueCmd4
        lda     cmdParam1               ; 5615  second argument
        jsr     queueCmdByte            ; 5617  queue it
        lda     cmdParam2               ; 561A  third argument
        jmp     queueCmdByte            ; 561C  queue it and return to the caller


; ----------------------------------------------------------------------
; queueCmd2 - Queues a 2-byte command: the code in A followed by cmdParam0 (the commands of length 2
; are the unit, group and reason commands $82-$86, $89, $8C, $91, $93-$96, $9B, $9D, $9F-$A2 and $A4).
; In:  A = command code, cmdParam0 zp_73
; Out: two bytes in the outgoing ring, pendingOwnCmdCount $929F incremented once
; Called from: $1AA9, $1C22, $20B8, $4B01, $5545, $5FA3, $62DE, $64E1, $65CD, $8340 and $C7A7
; ----------------------------------------------------------------------
queueCmd2:
        inc     pendingOwnCmdCount      ; 561F  count the command in pendingOwnCmdCount
        jsr     queueCmdByte            ; 5622  queue the command code held in A
        lda     cmdParam0               ; 5625  its single argument
        jmp     queueCmdByte            ; 5627  queue it and return to the caller


; outPacketFill: byteTable, 1 bytes. work variable of sendOutgoingPacket (bytes placed in the outgoing
; packet)
outPacketFill:
        .byte   $00                     ; 562A  .  number of bytes already placed in the outgoing packet by sendOutgoingPacket

; ----------------------------------------------------------------------
; sendOutgoingPacket - Builds the packet for the next lock-step exchange and hands it to the comm
; module. If our drone is under joystick control, is not paused and has not finished, it is first
; stepped one cell and an $8D heading/column/row report is queued. Whole commands are then copied out
; of the outgoing ring into the comm module's transmit buffer at $E027 as long as the packet stays
; below 5 bytes; a $92 is dropped while droneArrivalFlags bit 6 says the drone detonated instead of
; arriving, and an $8E found while no drone flight is in progress flushes the entire ring and sends an
; empty packet. Finally $E01F is set to the packet length and the comm module is asked to transmit.
; In:  outgoingCmdRing $9065 with $908D/$908E, droneControlFlags $920D, droneArrivalFlags $92F9,
;     pauseState
; Out: $E027.., $E01F, outgoingCmdReadIndex $908E, outPacketFill $562A; self-modifies $566B
; Called from: executeTurnCommands $4F02, disconnectFromOpponent $1C25 and overlay A $7979
; ----------------------------------------------------------------------
sendOutgoingPacket:
        bit     droneControlFlags       ; 562B  $920D bit 6 = our own drone is under joystick control
        bvc     L_5649                  ; 562E  no drone of ours to steer
        lda     pauseState              ; 5630  zp_BE is non-zero while either side is paused
        bne     L_5649                  ; 5632  do not step the drone while the game is paused
        lda     droneArrivalFlags       ; 5634  $92F9 bit 7 = the drone has already finished its flight
        bmi     L_5649                  ; 5637  nothing left to report
        jsr     steerDrone              ; 5639  overlay $731E: turn droneHeading toward the joystick and move the drone one cell  (also mapGenerator6F00:forestBlockClassSw)
        jsr     getDroneMapPosition     ; 563C  overlay $81FE: cmdParam1/cmdParam2 = droneViewOrigin + (6,5) = the drone's own map cell  (also mapGenerator6F00:waitFramesServicingComm)
        lda     droneHeading            ; 563F  $920B = the current 0-7 compass heading
        sta     cmdParam0               ; 5642  first argument of the report
        lda     #$8D                    ; 5644  command $8D DRONE POSITION
        jsr     queueCmd4               ; 5646  queue heading, column and row

; ----------------------------------------------------------------------
; Fill the outgoing packet from the command ring. Only whole commands are copied and the packet holds
; at most 4 command bytes, so a long burst of orders is spread over several exchanges.
; ----------------------------------------------------------------------
L_5649:
        ldy     outgoingCmdReadIndex    ; 5649  $908E = the read cursor of the outgoing ring
        lda     #$00                    ; 564C  0
        sta     outPacketFill           ; 564E  the packet starts out empty
L_5651:
        cpy     outgoingCmdWriteIndex   ; 5651  has the read cursor caught the write cursor?
        beq     L_56A3                  ; 5654  the ring is empty - send what we have
        lda     outgoingCmdRing,y       ; 5656  the next command byte in the ring
        sta     L_566A+1                ; 5659  patch it into the LDA # at $566A so the special-case tests below can look at it
        jsr     getCommandLength        ; 565C  $4D9F: total length of the command from commandLengthTable, also left in $92EE
        clc                             ; 565F  clear carry for the add
        adc     outPacketFill           ; 5660  how long the packet would be with this command in it
        cmp     #$05                    ; 5663  a packet carries at most 4 command bytes
        bcs     L_56A3                  ; 5665  it would not fit - leave it in the ring until next exchange
        ldx     outPacketFill           ; 5667  X = the write offset inside the packet buffer
L_566A:
        lda     #$FF                    ; 566A  immediate operand patched at $5659 with the command byte

; ----------------------------------------------------------------------
; Two special cases are filtered out here rather than at queue time, because whether they are still
; valid only becomes clear when the packet is built.
; ----------------------------------------------------------------------
        cmp     #$92                    ; 566C  $92 = 'the drone reached its target'
        bne     L_5679                  ; 566E  not a $92
        bit     droneArrivalFlags       ; 5670  $92F9 bit 6 = the drone detonated instead of being shot down
        bvc     L_5689                  ; 5673  bit 6 clear: the shot-down report is still valid, so copy it
        dex                             ; 5675  drop the stale $92: back the packet cursor up so the INX at $5696 cancels out
        jmp     L_568F                  ; 5676  step over it in the ring without copying anything

L_5679:
        cmp     #$8E                    ; 5679  $8E = 'detonate the drone'
        bne     L_5689                  ; 567B  not an $8E
        bit     droneControlFlags       ; 567D  $920D bit 7 = a drone flight of ours really is in progress
        bmi     L_5689                  ; 5680  it is - copy the detonate command normally
        ldy     outgoingCmdWriteIndex   ; 5682  stale detonate: throw the whole ring away by moving the read cursor to the write cursor ...
        ldx     #$00                    ; 5685  ... and send an empty packet this exchange
        beq     L_56A6                  ; 5687  always taken

; ----------------------------------------------------------------------
; Copy the remaining bytes of this command from the ring into the packet, wrapping the ring index at
; 40.
; ----------------------------------------------------------------------
L_5689:
        lda     outgoingCmdRing,y       ; 5689  copy one byte of the command
        sta     txPacketBuffer,x        ; 568C  $E027 is the comm module's 7-byte transmit buffer  (also trainerAiE000:txPacketBuffer)
L_568F:
        iny                             ; 568F  advance the ring cursor
        cpy     #$28                    ; 5690  $28 = 40 bytes in the ring
        bcc     L_5696                  ; 5692  no wrap needed
        ldy     #$00                    ; 5694  wrap back to the start
L_5696:
        inx                             ; 5696  advance the packet cursor
        dec     currentCommandLength    ; 5697  $92EE counts the bytes of this command down
        bne     L_5689                  ; 569A  copy the rest of the command
        stx     outPacketFill           ; 569C  remember how full the packet is
        cpx     #$04                    ; 569F  is there room for at least one more byte?
        bcc     L_5651                  ; 56A1  yes - look at the next command in the ring
L_56A3:
        ldx     outPacketFill           ; 56A3  X = the finished packet length
L_56A6:
        sty     outgoingCmdReadIndex    ; 56A6  the ring is consumed up to here
        stx     txPacketLength          ; 56A9  $E01F = the number of bytes the comm module should transmit  (also trainerAiE000:txPacketLength)
        ldx     #$01                    ; 56AC  comm function 1 = request a send
        jmp     commJumpTable           ; 56AE  $E000; the modem driver queues the packet, the solo trainer throws it away  (also trainerAiE000:commRequestEntry)


; ----------------------------------------------------------------------
; pollMenuSession - Reached while a link is up but no game is running (commSessionState $0B9C bit 7):
; if the carrier is gone the session is torn down, otherwise the menu-level packet handler runs. Note
; that the commandHandlerTable slot for command $99 also points here, but nothing in the game ever
; queues a $99, so that entry is unreachable filler.
; In:  the comm module's carrier byte $E03C
; Out: falls into handleMenuSessionPacket, or jumps to disconnectFromOpponent
; Called from: processCommandExchange $4DCE and $4E3E
; ----------------------------------------------------------------------
pollMenuSession:
        lda     linkStatus              ; 56B1  the comm module's carrier state byte  (also trainerAiE000:linkStatus)
        and     #$40                    ; 56B4  bit 6 = carrier present
        bne     handleMenuSessionPacket ; 56B6  still connected: service the menu-level traffic
        jmp     disconnectFromOpponent  ; 56B8  $1C17: carrier lost - tear the session down and hang up


; ----------------------------------------------------------------------
; handleMenuSessionPacket - Menu-level receive path: re-arms the 10-frame link poll delay and returns
; unless a packet has arrived. A 2-byte [$8C,$05] means the opponent hung up, so we hang up too;
; [$8C,$02] means he is ready for a new game and puts 'OPPONENT READY FOR NEW GAME.' on the status
; line, rate limited by the middle byte of the frame counter so the message cannot repeat every
; exchange. Every other packet just falls into completeExchange to be consumed.
; In:  $E01D, $E01E, $E020/$E021, frameCounterMid zp_46
; Out: linkPollDelay $9163 = 10, message $1F, zp_46 reset; falls into completeExchange
; Called from: pollMenuSession (branch at $56B6) and the text engine's pause handler $C737
; ----------------------------------------------------------------------
handleMenuSessionPacket:
        ldy     #$0A                    ; 56BB  10 frames
        sty     linkPollDelay           ; 56BD  $9163: do not poll the link again for 10 frames
        lda     exchangeFlags           ; 56C0  $E01D exchange flags  (also trainerAiE000:commInterfaceBlock)
        bmi     L_56C6                  ; 56C3  bit 7 = a packet from the opponent has arrived
        rts                             ; 56C5  nothing received - come back next poll

L_56C6:
        lda     rxPacketLength          ; 56C6  $E01E = length of the received packet  (also trainerAiE000:rxPacketLength)
        cmp     #$02                    ; 56C9  only 2-byte menu packets mean anything here
        bne     completeExchange        ; 56CB  wrong length - just finish the exchange
        lda     rxPacketBuffer          ; 56CD  first byte of the received packet  (also trainerAiE000:rxPacketBuffer)
        cmp     #$8C                    ; 56D0  $8C = session control
        bne     completeExchange        ; 56D2  not a session-control packet
        lda     rxPacketArg1            ; 56D4  the reason byte  (also trainerAiE000:rxPacketArg1)
        cmp     #$05                    ; 56D7  reason 5 = the opponent hung up
        bne     L_56DE                  ; 56D9  not a hang-up
        jmp     hangUpModem             ; 56DB  $1B33: drop our end of the line too

L_56DE:
        cmp     #$02                    ; 56DE  reason 2 = READY FOR A NEW GAME
        bne     completeExchange        ; 56E0  some other reason - ignore it
        lda     frameCounterMid         ; 56E2  zp_46, the middle byte of the free-running frame counter, is abused as a crude rate limit
        cmp     #$03                    ; 56E4  about 3*256 frames since the last announcement
        bcc     completeExchange        ; 56E6  too soon - say nothing this time
        lda     #$1F                    ; 56E8  message $1F
        jsr     queueMessage            ; 56EA  'OPPONENT READY FOR NEW GAME.'
        lda     #$00                    ; 56ED  0
        sta     frameCounterMid         ; 56EF  restart the rate limit (and, as a side effect, the frame counter's middle byte)

; ----------------------------------------------------------------------
; completeExchange - Makes sure the packet exchange currently in flight finishes. Nothing pending
; returns at once; an already completed exchange is consumed; if our own packet is out or a send is
; already requested it only waits; otherwise an empty packet is sent to give the opponent something to
; answer. It then spins for up to $4B ticks of linkWaitTimer, servicing the status line, until the
; exchange completes, and consumes it. A timeout hangs the modem up.
; In:  $E01D exchange flags, linkSessionState $0B9C
; Out: $E01F, linkWaitTimer zp_7F, the comm module's exchange state; or jumps to hangUpModem
; Called from: $1B2A, readyForNewGameHandshake $572B, handleMenuSessionPacket (four branches) and
;     $EE06
; ----------------------------------------------------------------------
completeExchange:
        ldy     exchangeFlags           ; 56F1  $E01D exchange flags  (also trainerAiE000:commInterfaceBlock)
        beq     L_572A                  ; 56F4  nothing in flight - nothing to finish
        tya                             ; 56F6  look at the flags
        and     #$40                    ; 56F7  bit 6 = the exchange has completed
        bne     L_5725                  ; 56F9  just consume it
        tya                             ; 56FB  look at the flags again
        and     #$20                    ; 56FC  bit 5 = our packet has already gone out
        bne     L_5712                  ; 56FE  only his answer is missing - wait for it
        tya                             ; 5700  look at the flags again
        and     #$01                    ; 5701  bit 0 = a send has already been requested
        bne     L_5712                  ; 5703  wait for it
        lda     #$00                    ; 5705  0
        sta     txPacketLength          ; 5707  $E01F = 0: nothing to say, so send a zero-length packet  (also trainerAiE000:txPacketLength)
        lda     linkSessionState        ; 570A  A carries the session state down to the comm module
        ldx     #$01                    ; 570D  comm function 1 = request a send
        jsr     commJumpTable           ; 570F  hand it over  (also trainerAiE000:commRequestEntry)
L_5712:
        lda     #$4B                    ; 5712  $4B = 75 ticks; zp_7F is decremented every 8th frame, so roughly ten seconds
        sta     linkWaitTimer           ; 5714  arm the timeout
L_5716:
        jsr     updateStatusMessage     ; 5716  $C4EF keeps the status line, chat and clock alive while we spin
        bit     exchangeFlags           ; 5719  $E01D exchange flags  (also trainerAiE000:commInterfaceBlock)
        bvs     L_5725                  ; 571C  bit 6: the exchange completed
        lda     linkWaitTimer           ; 571E  the timeout counts down in the raster IRQ
        bne     L_5716                  ; 5720  keep waiting
        jmp     hangUpModem             ; 5722  the opponent never answered - hang up the modem

L_5725:
        ldx     #$02                    ; 5725  comm function 2 = acknowledge and clear the finished exchange
        jmp     commJumpTable           ; 5727  $E000, then return to our caller  (also trainerAiE000:commRequestEntry)

L_572A:
        rts                             ; 572A  nothing to do


; ----------------------------------------------------------------------
; readyForNewGameHandshake - Run before a new game over an existing link: finishes any pending
; exchange, puts 'WAITING FOR OPPONENT...' up as a persistent message and then repeatedly sends
; [$8C,$02] and waits for the exchange to complete. When the opponent's packet is also [$8C,$02] the
; session state becomes 2 (game session), the message is cleared and the status line is wiped;
; [$8C,$05] means he hung up; anything else is consumed and the READY packet is sent again.
; In:  $E01D/$E01E/$E020/$E021
; Out: linkSessionState $0B9C = 2, persistentMessageId $92A1 cleared; or jumps to hangUpModem
; Called from: overlay A $795A
; ----------------------------------------------------------------------
readyForNewGameHandshake:
        jsr     completeExchange        ; 572B  make sure no half-finished exchange is left over from the previous game
        lda     #$0B                    ; 572E  message $0B
        sta     persistentMessageId     ; 5730  'WAITING FOR OPPONENT...' stays on the status line for the whole handshake
        jsr     queueMessage            ; 5733  show it now
L_5736:
        jsr     buildReadyPacket        ; 5736  put $8C,$02 = READY FOR A NEW GAME in the transmit buffer
        ldx     #$01                    ; 5739  comm function 1 = request a send
        jsr     commJumpTable           ; 573B  hand it over  (also trainerAiE000:commRequestEntry)
L_573E:
        jsr     updateStatusMessage     ; 573E  service the status line while waiting
        bit     exchangeFlags           ; 5741  $E01D exchange flags  (also trainerAiE000:commInterfaceBlock)
        bvc     L_573E                  ; 5744  spin until bit 6 says the exchange completed
        lda     rxPacketLength          ; 5746  length of the packet that came back  (also trainerAiE000:rxPacketLength)
        cmp     #$02                    ; 5749  a menu packet is exactly 2 bytes
        bne     L_5774                  ; 574B  anything else: consume it and try again
        lda     rxPacketBuffer          ; 574D  first byte of his packet  (also trainerAiE000:rxPacketBuffer)
        cmp     #$8C                    ; 5750  $8C = session control
        bne     L_5774                  ; 5752  not session control - try again
        lda     rxPacketArg1            ; 5754  his reason byte  (also trainerAiE000:rxPacketArg1)
        cmp     #$02                    ; 5757  2 = he is ready for a new game as well
        bne     L_576D                  ; 5759  not a READY - check for a hang-up
        lda     #$02                    ; 575B  2 = a game session has been agreed
        sta     linkSessionState        ; 575D  $0B9C session state
        lda     #$00                    ; 5760  0
        sta     persistentMessageId     ; 5762  take the WAITING message off the line
        ldx     #$02                    ; 5765  comm function 2 = consume the finished exchange
        jsr     commJumpTable           ; 5767  hand it over  (also trainerAiE000:commRequestEntry)
        jmp     clearMessageLine        ; 576A  $C39F wipes the status line and returns to overlay A's caller

L_576D:
        cmp     #$05                    ; 576D  reason 5 = he hung up instead
        bne     L_5774                  ; 576F  some other reason - consume it and resend
        jmp     hangUpModem             ; 5771  $1B33: drop our end of the line too

L_5774:
        ldx     #$02                    ; 5774  comm function 2 = consume whatever it was
        jsr     commJumpTable           ; 5776  hand it over  (also trainerAiE000:commRequestEntry)
        jmp     L_5736                  ; 5779  and send our READY packet again


; ----------------------------------------------------------------------
; buildReadyPacket - Fills the comm module's transmit buffer with the 2-byte packet $8C,$02 (session
; control, reason 2 = READY FOR A NEW GAME) and sets the transmit length $E01F to 2.
; In:  none
; Out: $E027 = $8C, $E028 = $02, $E01F = 2
; Called from: readyForNewGameHandshake $5736
; ----------------------------------------------------------------------
buildReadyPacket:
        lda     #$8C                    ; 577C  command $8C session control
        sta     txPacketBuffer          ; 577E  first byte of the comm module's transmit buffer  (also trainerAiE000:txPacketBuffer)
        lda     #$02                    ; 5781  reason 2 = READY FOR A NEW GAME
        sta     txPacketArg1            ; 5783  second byte of the transmit buffer  (also trainerAiE000:txPacketArg1)
        sta     txPacketLength          ; 5786  $E01F = 2 bytes to transmit (the same value as the reason byte only by coincidence)  (also trainerAiE000:txPacketLength)
        rts                             ; 5789  the packet is ready to be sent


; ----------------------------------------------------------------------
; recordExchangeToFilm - Appends one lock-step exchange to the game film held in the RAM under the I/O
; area. Nothing is written when the exchange carried no commands, when the battle is not running, or
; when the film buffer is full. A record is [ (splitIndex << 4) | length ] [ time stamp, 1 or 2 bytes
; ] [ length command bytes ]; the split index says how many of the bytes belong to the first player,
; so playback can restore the own/remote flag exactly. Every byte goes through writeFilmByte, which
; banks the I/O area out around the store, and filmPtr is advanced by the length of the record at the
; end.
; In:  mergedCmdLength $92EB, mergedCmdSplitIndex $92EC, mergedCmdBuffer $908F, filmPtr zp_BC/zp_BD,
;      filmEndPtr $92F1/$92F2, gamePhase zp_B1
; Out: film bytes written, filmPtr advanced; X = length, Y = record size
; Called from: executeTurnCommands $4EFA
; ----------------------------------------------------------------------
recordExchangeToFilm:
        lda     mergedCmdLength         ; 578A  $92EB = number of command bytes merged for this exchange
        beq     L_57BD                  ; 578D  nothing happened - do not waste a film record
        lda     gamePhase               ; 578F  zp_B1 is zero only during the battle
        bne     L_57BD                  ; 5791  the setup screens and menus are not filmed
        jsr     compareFilmPtrToEnd     ; 5793  $591E: C = 1 once filmPtr has reached filmEndPtr
        bcs     L_57BD                  ; 5796  the film buffer in the RAM under the I/O area is full
        ldx     #$00                    ; 5798  X = index into mergedCmdBuffer
        ldy     #$00                    ; 579A  Y = offset within the film record being written
        lda     mergedCmdSplitIndex     ; 579C  $92EC = how many of the merged bytes belong to the first player
        asl     a                       ; 579F  shift it ...
        asl     a                       ; 57A0  ... into ...
        asl     a                       ; 57A1  ... the high ...
        asl     a                       ; 57A2  ... nibble of the header byte
        ora     mergedCmdLength         ; 57A3  low nibble = the total length; both always fit in four bits
        jsr     writeFilmByte           ; 57A6  $5893 stores at (filmPtr),Y with $01 = $34 so the RAM under $D000 is visible
        iny                             ; 57A9  next byte of the record
        jsr     writeFilmTimestamp      ; 57AA  $57BE: one or two bytes of clock delta plus the sub-tick index; it advances Y itself

; ----------------------------------------------------------------------
; Copy the exchange's command bytes verbatim; playback feeds exactly these bytes back through
; executeTurnCommands, which is why a film replays identically whether the opponent was a person or
; the solo trainer.
; ----------------------------------------------------------------------
L_57AD:
        lda     mergedCmdBuffer,x       ; 57AD  copy the merged command bytes verbatim - this is what playback re-executes
        jsr     writeFilmByte           ; 57B0  write it to the film
        iny                             ; 57B3  next film byte
        inx                             ; 57B4  next command byte
        cpx     mergedCmdLength         ; 57B5  all of them written?
        bcc     L_57AD                  ; 57B8  no - keep copying
        jsr     advanceFilmPtr          ; 57BA  $57E7: filmPtr += Y, the length of the record just written
L_57BD:
        rts                             ; 57BD  done


; ----------------------------------------------------------------------
; writeFilmTimestamp - Writes the time stamp of one game-film record at (filmPtr),Y. The stamp is the
; game clock, stored as a 4-bit delta against the clock of the previous record whenever the clock has
; moved by 0..14 ticks (the usual case, since the clock ticks down once per round), otherwise as a
; full byte. The low nibble of the first byte always carries commandSliceIndex ($923C), the number of
; the 10-unit slice of the round this exchange belongs to; a zero high nibble is what tells the reader
; that an absolute clock byte follows. The previous clock is kept in the SBC operand at $57C3, which
; startGameSession ($7911) clears when a new film starts.
; In:  Y = offset of the next free byte inside the record; gameClock $91CB; commandSliceIndex $923C;
;     filmPtr $BC/$BD; the previous clock in the operand L_57C2+1 ($57C3)
; Out: 1 or 2 bytes written into the film buffer under the I/O area; Y advanced past them; L_57C2+1
;     updated to the current clock
; Called from: recordExchangeToFilm ($57AA) for every recorded exchange and finalizeFilm ($5808) for
;     the end-of-film marker.  The playback counterpart is inside processCommandExchange at
;     $4DE1-$4E04.
; ----------------------------------------------------------------------
writeFilmTimestamp:
        lda     gameClock               ; 57BE  current game clock (a round counter that runs down to 0: 63, 127 or 254 at kick-off depending on the game type)
        clc                             ; 57C1  clear carry so the SBC below subtracts one extra: delta = clock - previousClock - 1
L_57C2:
        sbc     #$00                    ; 57C2  operand L_57C2+1 ($57C3) = clock stored with the previous record, reset to 0 by startGameSession ($7911)
        cmp     #$F1                    ; 57C4  delta $F1..$FF means the clock fell by 0..14 ticks, which fits into one nibble
        bcc     L_57D2                  ; 57C6  branch when the clock moved by more than 14 ticks (or not backwards at all): the long two-byte form is needed
        asl     a                       ; 57C8  shift the delta nibble up into bits 4-7 of the time-stamp byte (1 of 4)
        asl     a                       ; 57C9  shift the delta nibble up (2 of 4)
        asl     a                       ; 57CA  shift the delta nibble up (3 of 4)
        asl     a                       ; 57CB  shift the delta nibble up (4 of 4); the low nibble is now free
        ora     commandSliceIndex       ; 57CC  low nibble = command slice number (unitOrderIndex/10, 0-9); playback compares it at $4DE3 and rejects the record if it does not match
        jmp     L_57DC                  ; 57CF  one byte is enough - go and store it

L_57D2:
        lda     commandSliceIndex       ; 57D2  long form: a first byte whose high nibble is 0 tells the reader that an absolute clock byte follows
        jsr     writeFilmByte           ; 57D5  store it at (filmPtr),Y in the RAM hidden under the I/O area
        iny                             ; 57D8  step to the next byte of the record
        lda     gameClock               ; 57D9  second byte of the long form = the absolute game clock
L_57DC:
        jsr     writeFilmByte           ; 57DC  store the time-stamp byte
        lda     gameClock               ; 57DF  the clock this record was stamped with...
        sta     L_57C2+1                ; 57E2  ...becomes the base of the next record's delta (self-modifies the SBC operand at $57C3)
        iny                             ; 57E5  count the time-stamp byte that was just written
        rts                             ; 57E6  back to the caller with Y past the time stamp


; ----------------------------------------------------------------------
; advanceFilmPtr - Adds the record length in Y to the 16-bit film write pointer filmPtr ($BC/$BD), so
; the next record starts where this one ended.
; In:  Y = number of bytes just written; filmPtr $BC/$BD
; Out: filmPtr advanced by Y; A clobbered
; Called from: processCommandExchange ($4E30) after replaying a record, recordExchangeToFilm ($57BA)
;     and finalizeFilm ($580B).
; ----------------------------------------------------------------------
advanceFilmPtr:
        tya                             ; 57E7  Y = number of bytes the record occupied
        clc                             ; 57E8  plain 16-bit add, no carry in
        adc     filmPtr                 ; 57E9  add to the low byte of the film pointer
        sta     filmPtr                 ; 57EB  store it back
        bcc     L_57F1                  ; 57ED  skip the high byte unless the add wrapped past $FF
        inc     filmPtrHi               ; 57EF  carry into the high byte of the film pointer
L_57F1:
        rts                             ; 57F1  done


; ----------------------------------------------------------------------
; finalizeFilm - Closes a game film that was being recorded: appends the end-of-film marker record
; ($FE = the game played itself out, $FF = it was aborted) with a time stamp if the buffer still has
; room, then rewinds and fills in the header at $D000 - total film length, format id, final clock,
; both 16-bit scores and the checksum over $D000-$D032 - and finally leaves filmPtr pointing at the
; end of the recording again.
; In:  filmPtr $BC/$BD, filmEndPtr $92F1/$92F2, gameEndReason $0BA8 (bit 0 = aborted), gameVersionByte
;     $0B94, gameClock $91CB, the two scores $92A2-$92A5, playerSide $0B9F
; Out: end marker appended, header bytes $D000/$D001 (length), $D02F (format id), $D031 (final clock),
;     $D00D-$D010 (scores) and $D033 (checksum) written; filmEndPtr = end of the recording; filmPtr =
;     filmEndPtr
; Called from: endGameToMainMenu ($1B18) only, and only when a film was actually being recorded.
; ----------------------------------------------------------------------
finalizeFilm:
        jsr     compareFilmPtrToEnd     ; 57F2  C=1 when the recording has already reached the $DFF0 limit
        bcs     L_580E                  ; 57F5  buffer full - there is no room for a marker, just write the header
        ldy     #$FE                    ; 57F7  $FE = 'the game reached its own end'; playback at $4E0B simply stops on it
        lda     gameEndReason           ; 57F9  why the game ended: 0 running, 1 aborted, $08 time up, $A0/$C0 a comcen was knocked out
        and     #$01                    ; 57FC  bit 0 is set only for reason 1 = the game was aborted
        beq     L_5801                  ; 57FE  played out to the end - keep the $FE marker
        iny                             ; 5800  $FF = aborted game; playback at $4E0F turns this into finishFilmPlayback
L_5801:
        tya                             ; 5801  the marker byte
        ldy     #$00                    ; 5802  offset 0 of the record = where a normal record keeps its length byte
        jsr     writeFilmByte           ; 5804  write the marker
        iny                             ; 5807  offset 1
        jsr     writeFilmTimestamp      ; 5808  the marker carries a time stamp like any other record
        jsr     advanceFilmPtr          ; 580B  filmPtr += 2 or 3 bytes of marker record

; ----------------------------------------------------------------------
; Rewind and fill in the 52-byte film header at $D000.  Offsets used here: $00/$01 length,
; $2F format id, $31 final clock, $0D-$10 the two scores, $33 checksum.  The rest of the header
; ($02/$03 command-stream offset, $04 solo flag, $05 side, $06-$0B map name, $0C options, $11-$24
; player names, $2B-$2D map seed, $30 starting clock) is written by initFilmStream,
; writeFilmSetupSnapshot and the save-film code in overlay A.
; ----------------------------------------------------------------------
L_580E:
        jsr     saveFilmEndAndRewind    ; 580E  filmEndPtr = end of the recording, then filmPtr back to $D000
        ldy     #$00                    ; 5811  header offset 0
        lda     filmEndPtr              ; 5813  low byte of the end address...
        sec                             ; 5816  ...minus the start of the buffer
        sbc     #$00                    ; 5817  $D000 low byte: length = filmEndPtr - $D000
        tax                             ; 5819  park the low byte of the length in X
        lda     filmEndPtrHi            ; 581A  high byte of the end address...
        sbc     #$D0                    ; 581D  ...minus $D0 = high byte of the length
        pha                             ; 581F  keep the high byte on the stack, writeFilmByte needs A
        txa                             ; 5820  low byte of the length back into A
        jsr     writeFilmByte           ; 5821  $D000 = film length, low byte
        iny                             ; 5824  header offset 1
        pla                             ; 5825  high byte of the length
        jsr     writeFilmByte           ; 5826  $D001 = film length, high byte
        lda     gameVersionByte         ; 5829  $0B94 = the game/film format id also used in the link version handshake
        ldy     #$2F                    ; 582C  header offset $2F
        jsr     writeFilmByte           ; 582E  $D02F = format id; verifyFilmHeader refuses a film whose id differs from this build's
        iny                             ; 5831  offset $30 already holds the clock at kick-off (writeFilmSetupSnapshot wrote it)
        iny                             ; 5832  step over it to offset $31
        lda     gameClock               ; 5833  clock left on the board when the game ended
        jsr     writeFilmByte           ; 5836  $D031 = final clock; finishFilmPlayback reads it back
        ldx     playerSide              ; 5839  start with the local player's score (playerSide 0 or 1)
        ldy     #$10                    ; 583C  header offset $10 = high byte of the recorder's own score

; ----------------------------------------------------------------------
; Write both 16-bit scores, own side first: $D00F/$D010 = this machine's score,
; $D00D/$D00E = the opponent's.  X alternates between the two sides with EOR #$01 and Y walks
; down $10, $0F, $0E, $0D.  finishFilmPlayback reads them back in exactly the same order, so a film
; always shows the scores from the point of view of whoever is watching it.
; ----------------------------------------------------------------------
L_583E:
        lda     sideScoreHi,x           ; 583E  score high byte of side X ($92A4 + side)
        jsr     writeFilmByte           ; 5841  store it
        dey                             ; 5844  next header offset down
        lda     sideScoreLo,x           ; 5845  score low byte of side X ($92A2 + side)
        jsr     writeFilmByte           ; 5848  store it
        dey                             ; 584B  next header offset down
        txa                             ; 584C  side index
        eor     #$01                    ; 584D  flip to the other side
        tax                             ; 584F  back into X
        cpy     #$0D                    ; 5850  stop once both score pairs ($D010..$D00D) are written
        bcs     L_583E                  ; 5852  second pass for the opponent's score
        jsr     computeFilmHeaderChecksum; 5854  sum $D000-$D032 into scratch18 (rewinds filmPtr as a side effect)
        ldy     #$33                    ; 5857  header offset $33
        jsr     writeFilmByte           ; 5859  $D033 = header checksum; falls straight into setFilmPtrToEnd

; ----------------------------------------------------------------------
; setFilmPtrToEnd - Points filmPtr ($BC/$BD) at filmEndPtr ($92F1/$92F2), i.e. back at the end of the
; film after the header has been read or written.
; In:  filmEndPtr $92F1/$92F2
; Out: filmPtr = filmEndPtr; A clobbered
; Called from: the tail of finalizeFilm (fall-through from $5859) and finishFilmPlayback ($59F9).
; ----------------------------------------------------------------------
setFilmPtrToEnd:
        lda     filmEndPtr              ; 585C  end of the recording, low byte
        sta     filmPtr                 ; 585F  into the film pointer
        lda     filmEndPtrHi            ; 5861  end of the recording, high byte
        sta     filmPtrHi               ; 5864  into the film pointer
        rts                             ; 5866  done


; ----------------------------------------------------------------------
; saveFilmEndAndRewind - Remembers the current film position as the end of the recording (filmEndPtr =
; filmPtr) and then rewinds filmPtr to the start of the buffer at $D000, ready for header access.
; In:  filmPtr $BC/$BD
; Out: filmEndPtr $92F1/$92F2 = old filmPtr; filmPtr = $D000; A clobbered
; Called from: finalizeFilm ($580E) when a recording is closed and finishFilmPlayback ($59D0) when a
;     replay reaches the end.
; ----------------------------------------------------------------------
saveFilmEndAndRewind:
        lda     filmPtr                 ; 5867  current film position, low byte
        sta     filmEndPtr              ; 5869  becomes the end-of-film limit
        lda     filmPtrHi               ; 586C  current film position, high byte
        sta     filmEndPtrHi            ; 586E  becomes the end-of-film limit
        jmp     rewindFilmPtr           ; 5871  and rewind to $D000 so the header can be read or written


; ----------------------------------------------------------------------
; readFilmByte - Reads one byte of the game film out of the 4 KB of RAM that lies underneath the I/O
; area ($D000-$DFFF).  Interrupts are disabled, the CIA2 interrupt mask (the modem NMI) is cleared,
; $01 is switched from $35 to $34 so the RAM shows through, the byte is fetched and everything is put
; back, including the mask the comm module keeps in $E033.  X is preserved through the self-modified
; LDX # at $5890.
; In:  Y = offset from filmPtr $BC/$BD
; Out: A = the byte; X, Y, filmPtr unchanged; CPU_PORT back at $35 and CIA2_ICR back at the comm
;     module's mask
; Called from: the film playback path in processCommandExchange ($4DD4-$4E23), verifyFilmHeader,
;     computeFilmHeaderChecksum and finishFilmPlayback in this file, from the film directory/load code
;     of overlay A ($7CDB-$7D35) and from the modem driver's $EE00 tail ($EE54-$EEAB).
; ----------------------------------------------------------------------
readFilmByte:
        stx     L_5890+1                ; 5874  self-modify the LDX # at $5891 so X survives the call
        ldx     #$7F                    ; 5877  $7F clears every mask bit when written to a CIA interrupt-control register
        sei                             ; 5879  no interrupts while the I/O area is banked away
        stx     CIA2_ICR                ; 587A  disable all CIA2 interrupt sources, i.e. the modem UART's NMI
        nop                             ; 587D  3-cycle gap: the mask is written twice as a guard against an NMI arriving mid-store
        stx     CIA2_ICR                ; 587E  second write of the mask
        ldx     #$34                    ; 5881  $34 = RAM everywhere except that the KERNAL/BASIC ROMs stay out; the $D000 I/O block disappears
        stx     CPU_PORT                ; 5883  switch the film buffer into view
        lda     (filmPtr),y             ; 5885  read the film byte
        inc     CPU_PORT                ; 5887  $34 -> $35 with one instruction: I/O visible again
        ldx     ciaIcrShadow            ; 5889  the comm module's copy of the CIA2 interrupt mask it wants installed  (also trainerAiE000:ciaIcrShadow)
        stx     CIA2_ICR                ; 588C  re-enable the modem NMI sources
        cli                             ; 588F  interrupts back on
L_5890:
        ldx     #$FF                    ; 5890  operand patched at $5874: restores the caller's X
        rts                             ; 5892  A holds the film byte


; ----------------------------------------------------------------------
; writeFilmByte - The write half of readFilmByte: stores A at (filmPtr),Y in the RAM under the I/O
; area, with the same 'sei / kill the CIA2 NMI / $01=$34 / restore' dance.  X is preserved through the
; self-modified LDX # at $58AF.
; In:  A = byte to store; Y = offset from filmPtr $BC/$BD
; Out: one byte written to the film buffer; A, X, Y unchanged
; Called from: recordExchangeToFilm and writeFilmTimestamp ($57A6-$57DC), finalizeFilm ($5804-$5859),
;     writeFilmSetupSnapshot ($5936), initFilmStream ($59A1/$59A7) and the save-film code of overlay A
;     ($79F9-$7AD0).
; ----------------------------------------------------------------------
writeFilmByte:
        stx     L_58AF+1                ; 5893  self-modify the LDX # at $58B0 so X survives the call
        ldx     #$7F                    ; 5896  $7F clears every mask bit of a CIA interrupt-control register
        sei                             ; 5898  no interrupts while the I/O area is banked away
        stx     CIA2_ICR                ; 5899  disable all CIA2 interrupt sources (the modem NMI)
        nop                             ; 589C  3-cycle gap between the two mask writes
        stx     CIA2_ICR                ; 589D  second write of the mask
        ldx     #$34                    ; 58A0  $34 = the $D000 I/O block replaced by the RAM underneath
        stx     CPU_PORT                ; 58A2  switch the film buffer into view
        sta     (filmPtr),y             ; 58A4  store the film byte
        inc     CPU_PORT                ; 58A6  $34 -> $35: I/O visible again
        ldx     ciaIcrShadow            ; 58A8  the comm module's wanted CIA2 interrupt mask  (also trainerAiE000:ciaIcrShadow)
        stx     CIA2_ICR                ; 58AB  re-enable the modem NMI sources
        cli                             ; 58AE  interrupts back on
L_58AF:
        ldx     #$FF                    ; 58AF  operand patched at $5893: restores the caller's X
        rts                             ; 58B1  done


; ----------------------------------------------------------------------
; copyPageUnderIo - Copies one 256-byte page with the I/O area banked out, used to move the unit
; tables in and out of the film's start-of-game snapshot.  Source and destination are the operands of
; the LDA abs,Y at $58C3/$58C4 and the STA abs,Y at $58C6/$58C7; the caller patches them first.  As
; disassembled both instructions show $FFFB (irqVectorHi) because that is the unpatched byte pair
; sitting in the file.
; In:  source address in the operand at $58C3/$58C4, destination in the operand at $58C6/$58C7
; Out: 256 bytes copied; A, X, Y clobbered; CPU_PORT and CIA2_ICR restored
; Called from: writeFilmSetupSnapshot ($5952/$596B) here, from overlay A ($7F87, $8184) and from the
;     modem driver's $EE00 tail ($EE73, $EE8C); the operand bytes are also patched by all three.
; ----------------------------------------------------------------------
copyPageUnderIo:
        ldx     #$7F                    ; 58B2  $7F clears every mask bit of a CIA interrupt-control register
        sei                             ; 58B4  no interrupts while the I/O area is banked away
        stx     CIA2_ICR                ; 58B5  disable all CIA2 interrupt sources (the modem NMI)
        nop                             ; 58B8  3-cycle gap between the two mask writes
        stx     CIA2_ICR                ; 58B9  second write of the mask
        ldx     #$34                    ; 58BC  $34 = the $D000 I/O block replaced by the RAM underneath
        stx     CPU_PORT                ; 58BE  bank the film buffer in
        ldy     #$00                    ; 58C0  copy 256 bytes, index 0 upwards
L_58C2:
        lda     irqVectorHi,y           ; 58C2  source operand at $58C3/$58C4, patched by the caller (shown as $FFFB = the unpatched bytes)
L_58C5:
        sta     irqVectorHi,y           ; 58C5  destination operand at $58C6/$58C7, patched by the caller (shown as $FFFB = the unpatched bytes)
        iny                             ; 58C8  next byte
        bne     L_58C2                  ; 58C9  loop until Y wraps back to 0, i.e. after all 256 bytes
        inc     CPU_PORT                ; 58CB  $34 -> $35: I/O visible again
        ldx     ciaIcrShadow            ; 58CD  the comm module's wanted CIA2 interrupt mask  (also trainerAiE000:ciaIcrShadow)
        stx     CIA2_ICR                ; 58D0  re-enable the modem NMI sources
        cli                             ; 58D3  interrupts back on
        rts                             ; 58D4  done


; ----------------------------------------------------------------------
; rewindFilmPtr - Sets filmPtr ($BC/$BD) to $D000, the first byte of the film / saved-game buffer in
; the RAM under the I/O area.
; In:  -
; Out: filmPtr = $D000; A clobbered
; Called from: resetGameVariables ($0C5B), verifyFilmHeader, computeFilmHeaderChecksum,
;     writeFilmSetupSnapshot, initFilmStream, the tail of saveFilmEndAndRewind ($5871) and the modem
;     driver's $EE00 tail ($EE99).
; ----------------------------------------------------------------------
rewindFilmPtr:
        lda     #$00                    ; 58D5  low byte of $D000
        sta     filmPtr                 ; 58D7  into the film pointer
        lda     #$D0                    ; 58D9  high byte of $D000 - the film buffer starts right under the VIC registers
        sta     filmPtrHi               ; 58DB  into the film pointer
        rts                             ; 58DD  done


; ----------------------------------------------------------------------
; verifyFilmHeader - Sanity check on a film that has just been loaded from disk: recomputes the
; checksum over header bytes $D000-$D032 and compares it with the stored one at $D033, then checks
; that the format id at $D02F matches this build's gameVersionByte ($0B94).  On success it returns the
; film's solo/modem flag from $D004.
; In:  the film header in the RAM under I/O
; Out: C=0 and A = $D004 (the isSoloTrainer flag the film was recorded with) when the film is good;
;     C=1 when it is not, and the caller prints 'NOT A GAME FILM!' (message $09)
; Called from: the LOAD GAME FILM code of overlay A ($81F4).
; ----------------------------------------------------------------------
verifyFilmHeader:
        jsr     rewindFilmPtr           ; 58DE  point filmPtr at the start of the header
        jsr     computeFilmHeaderChecksum; 58E1  sum $D000-$D032 into scratch18
        ldy     #$33                    ; 58E4  header offset $33 = the stored checksum
        jsr     readFilmByte            ; 58E6  read it
        cmp     scratch18               ; 58E9  compare with the checksum just computed
        bne     L_58F7                  ; 58EB  mismatch: this is not a valid film
        ldy     #$2F                    ; 58ED  header offset $2F = the format id the film was written with
        jsr     readFilmByte            ; 58EF  read it
        cmp     gameVersionByte         ; 58F2  must equal this build's gameVersionByte $0B94
        beq     L_58F9                  ; 58F5  same version - accept the film
L_58F7:
        sec                             ; 58F7  C=1 tells the caller to reject the file
        rts                             ; 58F8  reject

L_58F9:
        ldy     #$04                    ; 58F9  header offset $04 = the solo-trainer flag stored with the film
        jsr     readFilmByte            ; 58FB  read it into A for the caller
        clc                             ; 58FE  C=0 = the film is valid
        rts                             ; 58FF  done


; ----------------------------------------------------------------------
; computeFilmHeaderChecksum - Adds up the 51 header bytes $D000-$D032 into scratch18 ($18).  The ADC
; inside the loop is never preceded by a CLC, so the carry out of each byte feeds into the next one;
; readFilmByte is careful not to disturb the carry.  Also leaves filmPtr rewound to $D000.
; In:  the film header in the RAM under I/O
; Out: scratch18 = the checksum; filmPtr = $D000; A/Y clobbered
; Called from: finalizeFilm ($5854) when writing a film and verifyFilmHeader ($58E1) when checking
;     one.
; ----------------------------------------------------------------------
computeFilmHeaderChecksum:
        jsr     rewindFilmPtr           ; 5900  point filmPtr at $D000 so Y is a plain header offset
        lda     #$00                    ; 5903  start the running sum at zero
        sta     scratch18               ; 5905  scratch18 accumulates the checksum
        ldy     #$32                    ; 5907  walk header offsets $32 down to $00 - 51 bytes, everything except the checksum byte itself
        clc                             ; 5909  clear the carry once; from here it chains from byte to byte
L_590A:
        jsr     readFilmByte            ; 590A  fetch header byte Y (readFilmByte leaves the carry alone)
        adc     scratch18               ; 590D  add it to the running sum, carrying in from the previous add
        sta     scratch18               ; 590F  keep the sum
        dey                             ; 5911  next header offset down
        bpl     L_590A                  ; 5912  loop until offset 0 has been added and Y goes negative
        rts                             ; 5914  scratch18 now holds the checksum


; ----------------------------------------------------------------------
; waitForExchangeIdle - Spins on the per-frame command exchange until the packet exchange in flight
; has completed, which the exchange code signals by clearing linkTimeoutCounter ($9223);
; checkLinkTimeout sets bit 7 of that counter while a packet is outstanding.
; In:  linkTimeoutCounter $9223
; Out: returns when $9223 is zero; everything serviceCommandExchange touches
; Called from: overlay A after sending setup commands ($797C, $7C09, $7C1C).
; ----------------------------------------------------------------------
waitForExchangeIdle:
        jsr     serviceCommandExchange  ; 5915  one pass of the lock-step exchange plus the status-line service ($4DA9)
        lda     linkTimeoutCounter      ; 5918  non-zero while a packet exchange is still outstanding (bit 7 is set by checkLinkTimeout)
        bne     waitForExchangeIdle     ; 591B  keep servicing the link until the exchange settles
        rts                             ; 591D  exchange complete


; ----------------------------------------------------------------------
; compareFilmPtrToEnd - 16-bit unsigned compare of filmPtr against filmEndPtr.  While recording,
; filmEndPtr is the $DFF0 buffer limit, so C=1 means the film buffer is full; while playing back it is
; the real end of the film, so C=1 means the replay has run out of records.
; In:  filmPtr $BC/$BD, filmEndPtr $92F1/$92F2
; Out: C=1 when filmPtr >= filmEndPtr; A clobbered
; Called from: processCommandExchange ($4DC1) on the playback path, recordExchangeToFilm ($5793) and
;     finalizeFilm ($57F2).
; ----------------------------------------------------------------------
compareFilmPtrToEnd:
        lda     filmPtr                 ; 591E  film pointer, low byte
        cmp     filmEndPtr              ; 5920  compare with the limit, low byte (sets the borrow for the SBC below)
        lda     filmPtrHi               ; 5923  film pointer, high byte
        sbc     filmEndPtrHi            ; 5925  subtract the limit's high byte: C=1 when filmPtr >= filmEndPtr
        rts                             ; 5928  only the carry matters to the caller


; ----------------------------------------------------------------------
; writeFilmSetupSnapshot - Writes the start-of-game snapshot into the film header so that a replay can
; rebuild the battlefield exactly: the 20 setup bytes $92A2-$92B5 (scores, recycler mode and position,
; terrain points, drones, missiles, comcen speeds) go to header offsets $34-$47, the eight 100-byte
; unit arrays $F640-$F95F (column, row, flags, type, destination column/row, waypoint column/row) are
; copied to $D048-$D367, and the game clock is stored at $D030.
; In:  $92A2-$92B5, the unit arrays at $F640-$F95F, gameClock $91CB
; Out: $D034-$D047, $D048-$D367 and $D030 filled; filmPtr left at $D000; the copyPageUnderIo operands
;     at $58C3-$58C7 left patched
; Called from: startGameSession in overlay A ($7B80) when a recorded game begins.
; ----------------------------------------------------------------------
writeFilmSetupSnapshot:
        lda     #$34                    ; 5929  point filmPtr at header offset $34 ($D034)...
        sta     filmPtr                 ; 592B  ...low byte
        lda     #$D0                    ; 592D  ...high byte
        sta     filmPtrHi               ; 592F  filmPtr = $D034, so Y counts from 0 through the setup block
        ldy     #$00                    ; 5931  start at the first setup byte

; ----------------------------------------------------------------------
; Copy the 20 bytes of per-game settings $92A2-$92B5 (both 16-bit scores, recycler mode, recycler
; column and row per side, terrain points, drones left, missiles left, comcen reveal timers and the
; two comcen speed classes) into header offsets $34-$47.
; ----------------------------------------------------------------------
L_5933:
        lda     sideScoreLo,y           ; 5933  $92A2 + Y: the 20-byte block of per-game settings starting with the two 16-bit scores
        jsr     writeFilmByte           ; 5936  store it in the header
        iny                             ; 5939  next setting
        cpy     #$14                    ; 593A  20 bytes, $92A2 through $92B5
        bcc     L_5933                  ; 593C  loop over the whole settings block

; ----------------------------------------------------------------------
; Snapshot the first eight unit arrays ($F640-$F95F, 800 bytes) into $D048-$D367.  The copy is done
; with four 256-byte page copies whose destination is always source - $25F8; because 800 is not a
; multiple of 256 the four pages overlap, so only the first $20 bytes of the very first copy
; ($F640-$F65F) actually survive - the rest is redone by the three copies that follow.  The arrays
; covered are column, row, flags/facing, type, destination column, destination row, waypoint column
; and waypoint row, i.e. everything needed to place the units again at the start of a replay.
; ----------------------------------------------------------------------
        lda     #$48                    ; 593E  destination low byte $48
        sta     L_58C5+1                ; 5940  into the STA operand of copyPageUnderIo ($58C6)
        lda     #$D0                    ; 5943  destination high byte $D0
        sta     L_58C5+2                ; 5945  -> destination $D048
        lda     #$40                    ; 5948  source low byte $40
        sta     L_58C2+1                ; 594A  into the LDA operand of copyPageUnderIo ($58C3)
        lda     #$F6                    ; 594D  source high byte $F6
        sta     L_58C2+2                ; 594F  -> source $F640, the unit column array
        jsr     copyPageUnderIo         ; 5952  copy $F640-$F73F to $D048-$D147 (only the first $20 bytes of this survive - see the block note)
        jsr     getFilmSnapshotDestAddr ; 5955  A=$68, X=$D2: the top destination page $D268
        sta     L_58C5+1                ; 5958  destination low byte
        stx     L_58C5+2                ; 595B  destination high byte
        jsr     getFilmSnapshotSrcAddr  ; 595E  A=$60, X=$F8: the matching top source page $F860
        sta     L_58C2+1                ; 5961  source low byte
        stx     L_58C2+2                ; 5964  source high byte
        lda     #$02                    ; 5967  three more page copies to do (counter runs 2, 1, 0)
        sta     scratch19               ; 5969  scratch19 is the page counter
L_596B:
        jsr     copyPageUnderIo         ; 596B  copy one page; first $F860->$D268, then $F760->$D168, then $F660->$D068
        dec     L_58C2+2                ; 596E  step the source down one page
        dec     L_58C5+2                ; 5971  step the destination down one page
        dec     scratch19               ; 5974  one page done
        bpl     L_596B                  ; 5976  loop while the counter is still non-negative - three iterations
        jsr     rewindFilmPtr           ; 5978  back to $D000 so Y is a plain header offset again
        ldy     #$30                    ; 597B  header offset $30
        lda     gameClock               ; 597D  clock at kick-off; finalizeFilm later stores the final clock at offset $31
        jmp     writeFilmByte           ; 5980  $D030 = starting clock, and return through writeFilmByte


; ----------------------------------------------------------------------
; getFilmSnapshotDestAddr - Returns the constant address $D268 in A (low) and X (high): the highest
; destination page of the unit snapshot inside the film header, and the page just below the start of
; the command stream at $D368.  It exists as a routine so that the modem driver's $EE00 tail can reach
; the same constant when it restores a snapshot.
; In:  -
; Out: A = $68, X = $D2
; Called from: writeFilmSetupSnapshot ($5955), initFilmStream ($59AA) and the modem driver's $EE00
;     tail ($EE76).
; ----------------------------------------------------------------------
getFilmSnapshotDestAddr:
        lda     #$68                    ; 5983  low byte of $D268
        ldx     #$D2                    ; 5985  high byte of $D268 - the last page of the unit snapshot in the film header
        rts                             ; 5987  done


; ----------------------------------------------------------------------
; getFilmSnapshotSrcAddr - Returns the constant address $F860 in A (low) and X (high): the highest
; source page of the unit snapshot, i.e. the last full page of the eight unit arrays that end at
; $F95F.  Shared with the snapshot restore code in the modem driver's $EE00 tail.
; In:  -
; Out: A = $60, X = $F8
; Called from: writeFilmSetupSnapshot ($595E) and the modem driver's $EE00 tail ($EE7F).
; ----------------------------------------------------------------------
getFilmSnapshotSrcAddr:
        lda     #$60                    ; 5988  low byte of $F860
        ldx     #$F8                    ; 598A  high byte of $F860 - the top source page of the unit-array snapshot
        rts                             ; 598C  done


; ----------------------------------------------------------------------
; setFilmLimitToBufferEnd - Sets filmEndPtr to $DFF0, the highest address a recording may reach.
; While a film is being recorded compareFilmPtrToEnd tests against this value, so it is the point
; where recording silently stops; the last 16 bytes of the $D000 page are left unused.
; In:  -
; Out: filmEndPtr $92F1/$92F2 = $DFF0; A clobbered
; Called from: startGameSession in overlay A ($78D1) at the start of every game.
; ----------------------------------------------------------------------
setFilmLimitToBufferEnd:
        lda     #$F0                    ; 598D  low byte of $DFF0
        sta     filmEndPtr              ; 598F  into the recording limit
        lda     #$DF                    ; 5992  high byte of $DFF0 - the last 16 bytes of the buffer are kept free
        sta     filmEndPtrHi            ; 5994  into the recording limit
        rts                             ; 5997  done


; ----------------------------------------------------------------------
; initFilmStream - Prepares the film for recording once the snapshot is in place: writes the offset of
; the first command record ($0368) into header bytes $D002/$D003 and sets filmPtr to $D368, the first
; byte after the 800-byte unit snapshot.
; In:  -
; Out: $D002/$D003 = $0368; filmPtr = $D368; A/X/Y clobbered
; Called from: startGameSession in overlay A ($7B8C), right after writeFilmSetupSnapshot.
; ----------------------------------------------------------------------
initFilmStream:
        jsr     rewindFilmPtr           ; 5998  point filmPtr at $D000 so Y is a plain header offset
        lda     #$68                    ; 599B  low byte of the command-stream offset $0368
        ldx     #$02                    ; 599D  X builds the high byte of the offset and then of the address
        ldy     #$02                    ; 599F  header offset $02
        jsr     writeFilmByte           ; 59A1  $D002 = $68
        iny                             ; 59A4  header offset $03
        inx                             ; 59A5  X = 3
        txa                             ; 59A6  high byte of the offset $0368
        jsr     writeFilmByte           ; 59A7  $D003 = $03; the film records therefore start $0368 bytes into the file
        jsr     getFilmSnapshotDestAddr ; 59AA  A=$68, X=$D2 - the last snapshot page
        sta     filmPtr                 ; 59AD  filmPtr low byte = $68
        inx                             ; 59AF  one page past the snapshot: $D2 -> $D3
        stx     filmPtrHi               ; 59B0  filmPtr = $D368, the first free byte for command records
        rts                             ; 59B2  done


; ----------------------------------------------------------------------
; isCmdRingNearlyFull - Works out how much room is left in the 40-byte outgoing command ring at
; $9065-$908C and reports C=1 when fewer than 5 bytes are free, which is less than the longest command
; plus its arguments.  checkFirePressed calls it first, so while the ring is congested the fire button
; simply does nothing and no order can be lost.  Equal read and write indices mean the ring is empty,
; which is reported as plenty of room.
; In:  outgoingCmdWriteIndex $908D, outgoingCmdReadIndex $908E
; Out: C=1 = nearly full (refuse new orders), C=0 = room to spare; A clobbered
; Called from: checkFirePressed ($0EB1) only.
; ----------------------------------------------------------------------
isCmdRingNearlyFull:
        lda     outgoingCmdReadIndex    ; 59B3  index the transmitter has reached
        sec                             ; 59B6  prepare a true subtraction
        sbc     outgoingCmdWriteIndex   ; 59B7  free space = readIndex - writeIndex when the reader is ahead
        beq     L_59CC                  ; 59BA  both indices equal: the ring is empty, so it is certainly not full
        bcs     L_59C8                  ; 59BC  reader ahead of the writer: the difference is already the free space
        lda     outgoingCmdReadIndex    ; 59BE  writer has wrapped past the reader...
        clc                             ; 59C1  prepare a plain add
        adc     #$28                    ; 59C2  ...so add the ring size of 40 bytes ($9065-$908C)...
        sec                             ; 59C4  prepare a true subtraction
        sbc     outgoingCmdWriteIndex   ; 59C5  ...before taking the difference again
L_59C8:
        cmp     #$05                    ; 59C8  5 bytes is the safety margin - the longest command is 4 bytes
        bcc     L_59CE                  ; 59CA  less than 5 bytes free: report 'nearly full'
L_59CC:
        clc                             ; 59CC  C=0: there is room for another order
        rts                             ; 59CD  done

L_59CE:
        sec                             ; 59CE  C=1: the caller must refuse the order
        rts                             ; 59CF  done


; ----------------------------------------------------------------------
; finishFilmPlayback - End of a game film: reached when the replay runs past the last record, when the
; record length byte is $FF or when the aborted-game end marker is read.  It rewinds, restores the
; game clock and both 16-bit scores from the header (which finalizeFilm wrote when the film was made),
; marks the game as over, queues message 2 'END OF FILM.', locks the player out of the controls and
; starts the short end-of-game countdown.
; In:  the film header at $D000, playerSide $0B9F
; Out: gameClock $91CB, scores $92A2-$92A5, gameEndReason $0BA8 incremented, filmPtr back at the end,
;     gameOverFlags $923F = $C0, persistentMessageId $92A1 and chatMessageLine $0548 = $9E,
;     gameEndCountdown $92C9 = $7D
; Called from: processCommandExchange ($4DC6) - both from the end-of-buffer test at $4DC4 and from the
;     end-marker test at $4E0F.
; ----------------------------------------------------------------------
finishFilmPlayback:
        jsr     saveFilmEndAndRewind    ; 59D0  remember where the film ended and rewind filmPtr to $D000
        ldy     #$31                    ; 59D3  header offset $31 = the clock the recorded game ended on
        jsr     readFilmByte            ; 59D5  read it
        sta     gameClock               ; 59D8  show the same time left as the original game did
        ldx     playerSide              ; 59DB  the viewer's own side (0 or 1)
        ldy     #$10                    ; 59DE  header offset $10 = high byte of the recorder's score, which becomes the viewer's

; ----------------------------------------------------------------------
; Read both 16-bit scores back out of the header in the same own-side-first order finalizeFilm wrote
; them in, so a replay always shows US and THEM from the point of view of whoever is watching.
; ----------------------------------------------------------------------
L_59E0:
        jsr     readFilmByte            ; 59E0  score high byte from the header
        sta     sideScoreHi,x           ; 59E3  $92A4 + side
        dey                             ; 59E6  next header offset down
        jsr     readFilmByte            ; 59E7  score low byte from the header
        sta     sideScoreLo,x           ; 59EA  $92A2 + side
        dey                             ; 59ED  next header offset down
        txa                             ; 59EE  side index
        eor     #$01                    ; 59EF  flip to the other side
        tax                             ; 59F1  back into X
        cpy     #$0D                    ; 59F2  stop once both score pairs ($D010..$D00D) have been read
        bcs     L_59E0                  ; 59F4  second pass for the other side
        inc     gameEndReason           ; 59F6  0 means 'a game is running'; anything else ends it - here it just has to become non-zero
        jsr     setFilmPtrToEnd         ; 59F9  put filmPtr back at the end so compareFilmPtrToEnd keeps saying 'finished'
        lda     #$02                    ; 59FC  message 2 = 'END OF FILM.' ($8707, sound 3)
        jsr     queueMessage            ; 59FE  queue it for the status line
        lda     #$C0                    ; 5A01  bits 7 and 6: game over, and bit 6 blocks the fire button in checkFirePressedInteractive
        sta     gameOverFlags           ; 5A03  $923F = $C0
        lda     #$9E                    ; 5A06  $9E is a bit-7 terminator that prints nothing
        sta     persistentMessageId     ; 5A08  a negative persistentMessageId makes the status bar fall back to the CLICKS/US/THEM score line
        sta     chatMessageLine         ; 5A0B  and blank the chat line at $0548
        lda     #$7D                    ; 5A0E  $7D: the IRQ counts this up towards $80 every 8 frames, so about 24 frames of grace
        sta     gameEndCountdown        ; 5A10  non-zero gameEndCountdown also stops runGameFrames and the cursor handling
        rts                             ; 5A13  done


; ----------------------------------------------------------------------
; checkLinkTimeout - Watchdog used while the game is waiting for the opponent's packet.  It arms
; linkTimeoutCounter ($9223) by setting bit 7, which is what makes the raster IRQ count it up (one
; step every 8 frames, or every 16 while gamePhase is $FF, saturating at $FF).  Below $E1 it just
; returns C=0 and the caller goes back to its frame loop; from $E1 on it puts 'PHONE TROUBLE.' on the
; status line and returns C=1, which makes the caller busy-wait instead; at $FF the link is declared
; dead - during the battle it hangs up, redials and starts the wait again, in the setup phases it
; disconnects, says 'TRY AGAIN.' and jumps back to the main menu.
; In:  linkTimeoutCounter $9223, gamePhase $B1 (bits 6-7 clear = the battle is running),
;     sndVoiceSoundId $67C8
; Out: C=1 = keep waiting in a tight loop, C=0 = come back next frame; $9223 bit 7 set; may set
;     linkSessionState $0B9C = $FF, prevSoloTrainerFlag $0BA6 = $C8 and jump to returnToMainMenu
; Called from: waitForCarrier ($1BA3) and the wait-for-packet loop of processCommandExchange ($4E50).
; ----------------------------------------------------------------------
checkLinkTimeout:
        lda     linkTimeoutCounter      ; 5A14  how long the current packet has been outstanding
L_5A17:
        ora     #$80                    ; 5A17  bit 7 arms the counter: updateFrameTimers ($12B4) only advances it while it is negative
        sta     linkTimeoutCounter      ; 5A19  write it back
        cmp     #$E1                    ; 5A1C  $E1 = 97 steps past the armed value $80; at one step per 8 frames that is roughly 13 seconds of silence
        bcc     L_5A57                  ; 5A1E  still early days - let the caller carry on with its normal frame loop
        cmp     #$FF                    ; 5A20  $FF = the counter has saturated: the link is gone
        bne     L_5A49                  ; 5A22  between $E1 and $FE: only warn
        lda     gamePhase               ; 5A24  0 while the battle runs; bits 6-7 mark the side-selection, setup, film and menu phases
        and     #$C0                    ; 5A26  isolate those two phase bits
        beq     L_5A42                  ; 5A28  the battle is running: try to get the call back rather than throwing the game away
        lda     #$FF                    ; 5A2A  $FF = link lost
        sta     linkSessionState        ; 5A2C  record it in the session state
        jsr     disconnectFromOpponent  ; 5A2F  send the disconnect command and hang the modem up
        lda     #$23                    ; 5A32  message $23 = 'TRY AGAIN.' ($0608, sound 3)
        jsr     showStatusMessage       ; 5A34  show it on the status line
        lda     #$C8                    ; 5A37  $C8 in prevSoloTrainerFlag forces the GET OPPONENT ON PHONE prompt next time round
        sta     prevSoloTrainerFlag     ; 5A39  store it
        jsr     waitFrames              ; 5A3C  A is still $C8, so this waits 200 frames before leaving the message
        jmp     returnToMainMenu        ; 5A3F  abandon the game and rebuild the main menu

L_5A42:
        jsr     voicePauseReconnect     ; 5A42  battle case: hang up, ask for a voice pause and dial again
        lda     #$00                    ; 5A45  restart the watchdog from zero...
        beq     L_5A17                  ; 5A47  ...always taken: re-enter at $5A17, which ORs in bit 7 and returns C=0
L_5A49:
        lda     #$15                    ; 5A49  sound $15 is the phone-trouble warble
        cmp     sndVoiceSoundId         ; 5A4B  voice 0 of the sound driver
        beq     L_5A55                  ; 5A4E  already complaining - do not restart the message
        lda     #$22                    ; 5A50  message $22 = 'PHONE TROUBLE.' ($05FA, sound $15)
        jsr     showStatusMessage       ; 5A52  show it on the status line
L_5A55:
        sec                             ; 5A55  C=1: the caller keeps spinning here instead of running game frames
        rts                             ; 5A56  done

L_5A57:
        clc                             ; 5A57  C=0: nothing wrong yet, carry on normally
        rts                             ; 5A58  done


; Overview-map tile patterns.  On the strategic map each battlefield cell is only 2 multicolour pixels
; wide and 4 scanlines tall, so a whole cell is described by four bytes - one per scanline - each of
; which carries both horizontal halves of the character (bits 7-4 for the left cell of the pair, bits
; 3-0 for the right); the quadrant mask in quadrantPixelMask picks the half that is wanted.  A pixel
; pair of %00 is background (VIC background 0 = 7 yellow), %01 is the screen RAM upper nibble (6 blue
; = own units), %10 the lower nibble (2 red = enemy units) and %11 the colour RAM nibble (the terrain
; colour).  overviewPatternLo/Hi are a split pointer table indexed by the terrain index (map cell -
; $40, or cell - $20 for the $21-$3F contour set), 33 entries covering indices 0-$20;
; buildOverviewQuadrantNoColour reads them at $5C40/$5C44.
overviewPatternLo:
        .byte   $9B,$9F,$A3,$A7,$AB,$9F,$A3,$A7; 5A59  ........  terrain 0 = blank ($5A9B), 1-4 = the four lower-contour edges ($5A9F/A3/A7/AB)
        .byte   $AB                     ; 5A61  terrain 5-8 repeat the same four contour edges, 9-12 all use the full-height stripe $5AAF
        .byte   $AF,$AF,$AF,$AF,$B3,$B3,$B3; 5A62  .......
        .byte   $B3                     ; 5A69  terrain 13-20 all use $5AB3 (a single solid scanline), 21-23 start the river set ($5AC7/CB/CF)
        .byte   $B3,$B3,$B3,$B3,$C7,$CB,$CF; 5A6A  .......
        .byte   $D3                     ; 5A71  terrain 24 = $5AD3 (river), 25-28 = $5AC3 (river bank), 29-31 = $5ABB (forest stipple)
        .byte   $C3,$C3,$C3,$C3,$BB,$BB,$BB; 5A72  .......
        .byte   $BB                     ; 5A79  terrain $20 = $5ABB, the last forest entry

; High bytes of the same 33 pointers - every pattern lives in this page, so all entries are $5A.
overviewPatternHi:
        .byte   $5A,$5A,$5A,$5A,$5A,$5A,$5A,$5A; 5A7A  ZZZZZZZZ  all patterns are in page $5A (entries 0-7)
        .byte   $5A                     ; 5A82  entries 8-15
        .byte   $5A,$5A,$5A,$5A,$5A,$5A,$5A; 5A83  ZZZZZZZ
        .byte   $5A                     ; 5A8A  entries 16-23
        .byte   $5A,$5A,$5A,$5A,$5A,$5A,$5A; 5A8B  ZZZZZZZ
        .byte   $5A                     ; 5A92  entries 24-31
        .byte   $5A,$5A,$5A,$5A,$5A,$5A,$5A; 5A93  ZZZZZZZ
        .byte   $5A                     ; 5A9A  entry 32, the last terrain index

; The 15 distinct 4-byte patterns, one row per pattern.  Two of them are not reached through the
; pointer table at all: $5AB7 (solid) is the unit blob loaded at $5C14 and $5ABF (every other
; scanline) is the cloaked-unit blob loaded at $5C1C.  In the bit art below $33 lights the right pixel
; of a half, $CC the left one and $FF both.
overviewPatterns:
        .byte   $00,$00,$00,$00         ; 5A9B  ....  $5A9B blank - terrain index 0 (map cell $40), all background
        .byte   $00                     ; 5A9F  $5A9F contour edge: the lower two scanlines only
        .byte   $00,$33,$33             ; 5AA0  .33
        .byte   $00                     ; 5AA3  $5AA3 contour edge: lower two scanlines plus one pixel on scanline 1
        .byte   $CC,$33,$33             ; 5AA4  .33
        .byte   $33                     ; 5AA7  $5AA7 contour edge: the top scanline only
        .byte   $00,$00,$00             ; 5AA8  ...
        .byte   $33                     ; 5AAB  $5AAB contour edge: top two scanlines
        .byte   $CC,$00,$00             ; 5AAC  ...
        .byte   $33                     ; 5AAF  $5AAF full-height stripe - terrain indices 9-12
        .byte   $33,$33,$33             ; 5AB0  333
        .byte   $00                     ; 5AB3  $5AB3 one solid scanline - terrain indices 13-$20's base shape
        .byte   $FF,$00,$00             ; 5AB4  ...
        .byte   $FF                     ; 5AB7  $5AB7 solid 2x4 blob = a unit; loaded directly at $5C14 and recoloured by the $55/$AA mask
        .byte   $FF,$FF,$FF             ; 5AB8  ...
        .byte   $33                     ; 5ABB  $5ABB alternating stipple - the forest terrain
        .byte   $CC,$33,$CC             ; 5ABC  .3.
        .byte   $FF                     ; 5ABF  $5ABF every other scanline = a cloaked unit; loaded directly at $5C1C
        .byte   $00,$FF,$00             ; 5AC0  ...
        .byte   $CC                     ; 5AC3  $5AC3 river bank
        .byte   $FF,$FF,$33             ; 5AC4  ..3
        .byte   $33                     ; 5AC7  $5AC7 river, notched at the top
        .byte   $FF,$FF,$FF             ; 5AC8  ...
        .byte   $CC                     ; 5ACB  $5ACB river, notched at the top on the other side
        .byte   $FF,$FF,$FF             ; 5ACC  ...
        .byte   $FF                     ; 5ACF  $5ACF river, notched at the bottom
        .byte   $FF,$FF,$33             ; 5AD0  ..3
        .byte   $FF                     ; 5AD3  $5AD3 river, notched at the bottom on the other side
        .byte   $FF,$FF,$CC             ; 5AD4  ...

; ----------------------------------------------------------------------
; drawOverviewMap - Draws the whole strategic overview: the 40x40 battlefield squeezed into the 20x20
; character window at screen (1,1)-(20,20), four map cells (a 2x2 block) per character.  For every
; character it clears the 8-byte build buffer at $90B8, ORs in the four cell patterns as quadrants,
; overlays the quarter/midfield lines, copies the 8 scanlines into the $A000 bitmap, writes $62 into
; screen RAM (upper nibble 6 = blue for own units, lower nibble 2 = red for the enemy) and the terrain
; colour into colour RAM at +$4C00.
; In:  the $F000 map, the unit arrays, the patched side branches at $5BF7/$5BFE
; Out: 400 characters of bitmap, screen RAM and colour RAM rewritten; vicBg0Shadow $9004 = 7 (yellow);
;     mapCellPtr $48/$49, scratch18/scratch19 and glyphBuildBuffer clobbered
; Called from: manualLookupCheck ($09E3), the battlefield screen builder ($2682, $4020, $4137),
;     overlay A/B in many places ($76B8, $7702, $774C, $7B0C, $7D5C, $83F8, $8553), the $0400 block
;     ($0758) and the text engine ($C794).
; ----------------------------------------------------------------------
drawOverviewMap:
        lda     #$07                    ; 5AD7  colour 7 = yellow: the plain-ground background of the whole overview
        sta     vicBg0Shadow            ; 5AD9  VIC background 0 shadow; the IRQ copies it to $D021
        lda     #$00                    ; 5ADC  map pointer low byte...
        sta     mapCellPtr              ; 5ADE  mapCellPtr low byte
        lda     #$F0                    ; 5AE0  ...and high byte: the first cell of the 40x40 map at $F000
        sta     mapCellPtrHi            ; 5AE2  mapCellPtr high byte - the pointer now addresses map cell (0,0)
        lda     #$01                    ; 5AE4  first character row of the window
        sta     scratch18               ; 5AE6  scratch18 = character row 1..20

; ----------------------------------------------------------------------
; Outer loop over the 20 character rows.  Each pass covers two map rows; after the inner loop has
; walked 20 characters (40 map cells) the pointer is advanced by another 40 to skip the odd map row
; that the same characters already covered.
; ----------------------------------------------------------------------
L_5AE8:
        lda     #$01                    ; 5AE8  first character column of the window
        sta     scratch19               ; 5AEA  scratch19 = character column 1..20

; ----------------------------------------------------------------------
; Inner loop: build one overview character out of a 2x2 block of map cells.
; ----------------------------------------------------------------------
L_5AEC:
        ldx     #$07                    ; 5AEC  8 scanlines to clear
        lda     #$00                    ; 5AEE  blank
L_5AF0:
        sta     glyphBuildBuffer,x      ; 5AF0  clear scanline X of the character being built at $90B8
        dex                             ; 5AF3  next scanline down
        bpl     L_5AF0                  ; 5AF4  until all 8 are blank
        ldx     #$00                    ; 5AF6  quadrant 0 = top-left
        ldy     #$00                    ; 5AF8  map offset 0 = the cell at (col, row)
        lda     (mapCellPtr),y          ; 5AFA  read the map cell
        jsr     buildOverviewQuadrant   ; 5AFC  OR its 2x4 pattern into the top-left quadrant and pick up its terrain colour
        iny                             ; 5AFF  map offset 1 = the cell to the right
        inx                             ; 5B00  quadrant 1 = top-right
        lda     (mapCellPtr),y          ; 5B01  read the map cell
        jsr     buildOverviewQuadrant   ; 5B03  OR it into the top-right quadrant
        ldy     #$28                    ; 5B06  map offset $28 = 40 = the same column one map row down
        inx                             ; 5B08  quadrant 2 = bottom-left
        lda     (mapCellPtr),y          ; 5B09  read the map cell
        jsr     buildOverviewQuadrant   ; 5B0B  OR it into the bottom-left quadrant
        iny                             ; 5B0E  map offset $29 = the cell diagonally down-right
        inx                             ; 5B0F  quadrant 3 = bottom-right
        lda     (mapCellPtr),y          ; 5B10  read the map cell
        jsr     buildOverviewQuadrant   ; 5B12  OR it into the bottom-right quadrant; its terrain colour is the one that ends up in colour RAM
        jsr     calcCellPointers        ; 5B15  bitmapPtr/screenPtr for character (scratch19, scratch18); returns Y = character row, X = column
        jsr     drawOverviewQuarterLine ; 5B18  add the quarter or midfield line to scanline 0 if this character row carries one
        ldy     #$07                    ; 5B1B  8 scanlines to store
L_5B1D:
        lda     glyphBuildBuffer,y      ; 5B1D  scanline Y of the finished character
        sta     (bitmapPtr),y           ; 5B20  into the $A000 bitmap
        dey                             ; 5B22  next scanline
        bpl     L_5B1D                  ; 5B23  until all 8 are written
        lda     #$62                    ; 5B25  $62: multicolour pair %01 -> colour 6 blue (own units), %10 -> colour 2 red (enemy units)
        ldy     #$00                    ; 5B27  one byte per character
        sta     (screenPtr),y           ; 5B29  into screen RAM at $8C00
        lda     screenPtrHi             ; 5B2B  screen RAM page...
        clc                             ; 5B2D  carry must be clear before the page add below
        adc     #$4C                    ; 5B2E  ...+ $4C00 turns $8Cxx into $D8xx = colour RAM
        sta     screenPtrHi             ; 5B30  screenPtr now points at the colour RAM byte of this character
        lda     tileColour              ; 5B32  terrain colour of the last cell of the 2x2 block, left in tileColour by buildOverviewQuadrant
        sta     (screenPtr),y           ; 5B34  colour RAM supplies the %11 pixel pairs, i.e. the terrain itself
        lda     mapCellPtr              ; 5B36  advance the map pointer...
        clc                             ; 5B38  plain 16-bit add of the two-cell step
        adc     #$02                    ; 5B39  ...by two cells - one character wide
        sta     mapCellPtr              ; 5B3B  store the advanced map pointer, low byte
        lda     mapCellPtrHi            ; 5B3D  carry into the map pointer high byte
        adc     #$00                    ; 5B3F  propagate the carry into the high byte
        sta     mapCellPtrHi            ; 5B41  store the advanced map pointer, high byte
        inc     scratch19               ; 5B43  next character column
        lda     scratch19               ; 5B45  the character column just finished
        cmp     #$15                    ; 5B47  columns 1 to 20 inclusive
        bcc     L_5AEC                  ; 5B49  keep going along the row
        lda     mapCellPtr              ; 5B4B  the row just drawn consumed 40 cells = one map row...
        clc                             ; 5B4D  plain 16-bit add of the 40-cell step
        adc     #$28                    ; 5B4E  ...so skip the second map row it also covered
        sta     mapCellPtr              ; 5B50  store the advanced map pointer, low byte
        lda     mapCellPtrHi            ; 5B52  carry into the map pointer high byte
        adc     #$00                    ; 5B54  propagate the carry into the high byte
        sta     mapCellPtrHi            ; 5B56  store the advanced map pointer, high byte
        inc     scratch18               ; 5B58  next character row
        lda     scratch18               ; 5B5A  the character row just finished
        cmp     #$15                    ; 5B5C  rows 1 to 20 inclusive
        bcs     L_5B63                  ; 5B5E  all 20 rows done
        jmp     L_5AE8                  ; 5B60  next character row of the overview

L_5B63:
        rts                             ; 5B63  the whole 20x20 overview has been redrawn


; Quadrant geometry of an overview character.  Quadrant 0 = top-left, 1 = top-right, 2 = bottom-left,
; 3 = bottom-right; each is 2 multicolour pixels wide and 4 scanlines tall.
quadrantPixelMask:
        .byte   $F0,$0F,$F0,$0F         ; 5B64  ....  AND mask per quadrant: $F0 keeps the left pixel pair of the character, $0F the right one

; quadrantRowOffset: byteTable, 4 bytes. first scanline of quadrant 0-3: 0,0,4,4 (4 rows each)
quadrantRowOffset:
        .byte   $00,$00,$04,$04         ; 5B68  ....  first scanline per quadrant: 0 for the two top quadrants, 4 for the two bottom ones

; ----------------------------------------------------------------------
; drawOverviewQuarterLine - Puts the field markings back on top of an overview character.  Modem Wars
; is played on a football pitch, and the overview shows the quarter lines at map rows 10, 20 and 30,
; which land on the first scanline of character rows 6, 11 and 16.  On the midfield row (11) both
; pixel pairs of the character get colour 1, on rows 6 and 16 only the right-hand one; a half that
; already reads %1010 - a solid enemy-unit blob - is left alone so the line does not put a blue dot in
; the middle of a red unit.  Only scanline 0 of the buffer at $90B8 is touched.
; In:  Y = character row of the overview (1-20); glyphBuildBuffer $90B8
; Out: glyphBuildBuffer[0] possibly modified; A/X/Y clobbered; self-modifies the operands at $5B95 and
;     $5BA8
; Called from: drawOverviewMap ($5B18) for every character and drawMapCell ($5CFD) when a single cell
;     is redrawn.
; ----------------------------------------------------------------------
drawOverviewQuarterLine:
        ldx     #$00                    ; 5B6C  X = 0 means 'draw both pixel pairs'
        cpy     #$0B                    ; 5B6E  character row 11 = map rows 20/21 - the midfield line
        beq     L_5B7B                  ; 5B70  midfield: leave X at 0 so both halves get a marker
        cpy     #$06                    ; 5B72  character row 6 = map rows 10/11 - the first quarter line
        beq     L_5B7A                  ; 5B74  row 6: only the right-hand pixel carries this quarter line
        cpy     #$10                    ; 5B76  character row 16 = map rows 30/31 - the third quarter line
        bne     L_5BAC                  ; 5B78  any other row carries no field marking at all
L_5B7A:
        dex                             ; 5B7A  X = $FF: quarter lines are drawn in the right-hand pixel only
L_5B7B:
        stx     L_5B94+1                ; 5B7B  self-modify the LDA # at $5B95 - it gates the left-hand half below
        lda     glyphBuildBuffer        ; 5B7E  scanline 0 of the character being built
        and     #$F0                    ; 5B81  keep the left pixel pair (pixels 0 and 1)
        tay                             ; 5B83  hold it in Y while the right half is dealt with
        lda     glyphBuildBuffer        ; 5B84  scanline 0 again
        and     #$0F                    ; 5B87  keep the right pixel pair (pixels 2 and 3)
        tax                             ; 5B89  hold it in X
        eor     #$0A                    ; 5B8A  %1010 = both pixels colour 2 = a solid enemy blob
        beq     L_5B94                  ; 5B8C  leave an enemy unit alone rather than dotting it
        txa                             ; 5B8E  the right half
        and     #$0C                    ; 5B8F  keep pixel 2, clear pixel 3
        ora     #$01                    ; 5B91  pixel 3 (the rightmost of the character) := colour 1
        tax                             ; 5B93  back into X
L_5B94:
        lda     #$FF                    ; 5B94  operand patched at $5B7B: $FF for a quarter line, $00 for the midfield line
        bmi     L_5BA3                  ; 5B96  quarter line: only the right-hand pixel is drawn, skip the left half
        tya                             ; 5B98  the left half
        eor     #$A0                    ; 5B99  %10100000 = both pixels colour 2 = a solid enemy blob
        beq     L_5BA3                  ; 5B9B  leave an enemy unit alone
        tya                             ; 5B9D  the left half again
        and     #$C0                    ; 5B9E  keep pixel 0, clear pixel 1
        ora     #$10                    ; 5BA0  pixel 1 := colour 1
        tay                             ; 5BA2  back into Y
L_5BA3:
        sty     L_5BA7+1                ; 5BA3  self-modify the ORA # at $5BA8 with the finished left half
        txa                             ; 5BA6  the finished right half
L_5BA7:
        ora     #$FF                    ; 5BA7  operand patched at $5BA3: recombine the two halves
        sta     glyphBuildBuffer        ; 5BA9  write scanline 0 of the character back
L_5BAC:
        rts                             ; 5BAC  done (also the exit for rows that carry no line)


; ----------------------------------------------------------------------
; buildOverviewQuadrant - Colour-aware entry of the overview cell renderer.  It looks the terrain
; colour of map cell A up (terrainClassTable $07BE indexed by the terrain index, then
; terrainColourTable $07EA) and leaves it in tileColour ($32) for the caller to write into colour RAM,
; then falls into buildOverviewQuadrantNoColour to OR the pattern into quadrant X.  A cell holding a
; unit is coloured after the terrain hidden under it.  Cell $40 and any out-of-range value leave the
; colour untouched, so the character keeps the colour of the previous quadrant.
; In:  A = map cell byte ($21-$6B terrain, or $80|unitIndex); X = quadrant 0-3; Y = anything
;     (restored)
; Out: tileColour $32 = the terrain colour nibble; the quadrant ORed into glyphBuildBuffer $90B8;
;     A/X/Y restored
; Called from: drawOverviewMap ($5AFC, $5B03, $5B0B, $5B12), once per cell of the 2x2 block.
; ----------------------------------------------------------------------
buildOverviewQuadrant:
        sty     L_5C79+1                ; 5BAD  stash Y in the LDY # at $5C7A so the exit can restore it
        stx     L_5C77+1                ; 5BB0  stash the quadrant number in the LDX # at $5C78; $5C4F reads it back
        pha                             ; 5BB3  keep the raw cell byte for the pattern lookup
        tay                             ; 5BB4  test bit 7 of the cell byte
        bpl     L_5BBA                  ; 5BB5  plain terrain cell - use it directly
        lda     unitTerrainUnderAlias,y ; 5BB7  a unit stands here: colour after the terrain it hides ($FAF0 + index, reached as $FA70 + $80 + index)
L_5BBA:
        sec                             ; 5BBA  prepare a true subtraction of the terrain base
        sbc     #$40                    ; 5BBB  terrain cells $41-$60 become indices 1-$20
        beq     L_5BD0                  ; 5BBD  cell $40 is the blank tile: keep the colour of the previous quadrant
        bcs     L_5BC3                  ; 5BBF  cell above $40 - the index is already right
        adc     #$20                    ; 5BC1  cells below $40 (the $21-$3F contour set) become index cell-$20
L_5BC3:
        cmp     #$21                    ; 5BC3  33 valid terrain indices, 0 to $20
        bcs     L_5BD0                  ; 5BC5  out of range: keep the colour of the previous quadrant
        tay                             ; 5BC7  index the class table with it
        ldx     D_07BE,y                ; 5BC8  terrainClassTable read as $07BE + index: class 0-8 (0 plain, 1 river, 2 forest, 3/5 contour, 4/6 hill, 7 recycler)
        lda     terrainColourTable,x    ; 5BCB  $07EA: 7 yellow plain, 3 cyan river, 5 green forest, 8 orange contours and hills, 0 black
        sta     tileColour              ; 5BCE  the caller stores this in colour RAM for the whole character
L_5BD0:
        pla                             ; 5BD0  the cell byte again
        jmp     L_5BDA                  ; 5BD1  enter buildOverviewQuadrantNoColour past the two saves we have already done


; ----------------------------------------------------------------------
; buildOverviewQuadrantNoColour - ORs the 2x4 pixel pattern of map cell A into quadrant X of the
; 8-byte character buffer at $90B8, without touching the colour.  Terrain cells pick their 4-byte
; pattern through overviewPatternLo/Hi.  A cell holding a unit gets the solid blob $5AB7 (or $5ABF
; when the unit is cloaked, bit 7 of the waypoint-column array $F898) masked with overviewUnitPattern
; $922B, which is $55 (every pixel pair %01 = blue) for our own units and $AA (%10 = red) for the
; opponent's.  A unit is drawn only if it is ours, if the omniscient replay view is on, if reveal-all
; is set or if it has been spotted (bit 7 of the energy array $F9C4); otherwise, and for dead units
; and while the comcen map system is knocked out ($9242 bit 7), the terrain hidden under the unit is
; drawn instead.
; In:  A = map cell byte; X = quadrant 0-3; Y = anything (restored); overviewUnitPattern $922B,
;     comcenBattleStatus $9242, filmPlaybackMode $0B9B, revealAllUnits $92FC, unit arrays
;     $F76C/$F898/$F9C4/$FAF0
; Out: four scanlines of glyphBuildBuffer $90B8 ORed; A/X/Y restored; self-modifies the operands at
;     $5C19, $5C65, $5C66, $5C68, $5C6A, $5C74, $5C78, $5C7A
; Called from: drawMapCell ($5CF6) and, past its two save instructions, from buildOverviewQuadrant
;     ($5BD1).
; ----------------------------------------------------------------------
buildOverviewQuadrantNoColour:
        sty     L_5C79+1                ; 5BD4  stash Y in the LDY # at $5C7A so the exit can restore it
        stx     L_5C77+1                ; 5BD7  stash the quadrant number in the LDX # at $5C78; $5C4F reads it back
L_5BDA:
        ldy     #$FF                    ; 5BDA  $FF = no unit recolouring, let the pattern through unchanged
        sty     L_5C67+1                ; 5BDC  self-modify the AND # at $5C68 (the unit colour mask)
        tay                             ; 5BDF  Y = the cell byte, so unit arrays are indexed through their $80-biased aliases
        bpl     L_5C2B                  ; 5BE0  bit 7 clear = plain terrain: skip everything unit related
        lda     comcenBattleStatus      ; 5BE2  $9242 = the comcen's map system; bit 7 set means it has been knocked out
        bmi     L_5C23                  ; 5BE5  no map system: the overview shows bare terrain, units are invisible
        lda     unitPathColTableAlias,y ; 5BE7  waypoint-column array $F898 + index (via the $F818 alias); bit 7 = the unit is cloaked
        sta     L_5C18+1                ; 5BEA  park it in the LDX # at $5C19 - the cloak test runs after the pattern has been chosen
        lda     unitTypeTableAlias,y    ; 5BED  unit type array $F76C + index (via the $F6EC alias); bit 7 = destroyed
        bmi     L_5C23                  ; 5BF0  dead unit: draw the terrain it is lying on instead
        lda     overviewUnitPattern     ; 5BF2  $922B = $55: every pixel pair %01, i.e. screen RAM upper nibble = colour 6 blue
        cpy     #$B2                    ; 5BF5  $B2 = $80|50: C=1 for unit indices 50-99, the second half of the unit array
D_5BF7:
        bcc     L_5BFB                  ; 5BF7  opcode patched by patchOwnSideBranches ($7BE1): BCC for side 0, BCS for side 1, so it is taken for OUR units
        eor     #$FF                    ; 5BF9  opponent's unit: $55 becomes $AA, every pixel pair %10 = screen RAM lower nibble = colour 2 red
L_5BFB:
        sta     L_5C67+1                ; 5BFB  this mask recolours the solid $FF pattern (self-modifies the AND # at $5C68)
D_5BFE:
        bcc     L_5C14                  ; 5BFE  opcode patched by patchUnitSideBranches ($2A16), inverted when a replay is watching THEM: units of the watched side are always visible
        lda     filmPlaybackMode        ; 5C00  $0B9B: bit 7 = film playback, bits 0-1 = the viewpoint
        cmp     #$82                    ; 5C03  $80|2 = playback with the SHOW BOTH viewpoint
        bcs     L_5C11                  ; 5C05  omniscient replay: the enemy unit is drawn too
        lda     revealAllUnits          ; 5C07  $92FC bit 7 = reveal every unit (set when the game ends)
        bmi     L_5C11                  ; 5C0A  reveal-all: draw the enemy unit
        lda     unitEnergyTableAlias,y  ; 5C0C  energy array $F9C4 + index (via the $F944 alias); bit 7 = this enemy has been spotted
        bpl     L_5C23                  ; 5C0F  never spotted: only the terrain it is standing on is shown
L_5C11:
        lda     L_5C67+1                ; 5C11  dead instruction - A is overwritten by the LDA # below and no flag is tested (leftover)
L_5C14:
        lda     #$B7                    ; 5C14  pattern $5AB7 = the solid 2x4 unit blob
        ldy     #$5A                    ; 5C16  high byte of the pattern address
L_5C18:
        ldx     #$FF                    ; 5C18  operand patched at $5BEA with the unit's waypoint-column byte; bit 7 = cloaked
        bpl     L_5C49                  ; 5C1A  not cloaked: keep the solid blob
        lda     #$BF                    ; 5C1C  cloaked: pattern $5ABF, only every other scanline is drawn
        ldy     #$5A                    ; 5C1E  high byte of the pattern address
        jmp     L_5C49                  ; 5C20  go and plot it

L_5C23:
        lda     #$FF                    ; 5C23  $FF = no unit recolouring...
        sta     L_5C67+1                ; 5C25  ...so the terrain pattern goes through untouched (self-modifies the AND # at $5C68)
        lda     unitTerrainUnderAlias,y ; 5C28  terrain byte hidden underneath the unit ($FAF0 + index, via the $FA70 alias)

; ----------------------------------------------------------------------
; Shared terrain path: turn the cell byte into a terrain index 0-$20 and fetch the matching 4-byte
; pattern out of the split pointer table at $5A59/$5A7A.
; ----------------------------------------------------------------------
L_5C2B:
        sec                             ; 5C2B  prepare a true subtraction of the terrain base
        sbc     #$40                    ; 5C2C  terrain cells $41-$60 become indices 1-$20
        beq     L_5C38                  ; 5C2E  cell $40 is the blank tile
        bcs     L_5C34                  ; 5C30  cell above $40 - the index is already right
        adc     #$20                    ; 5C32  cells below $40 (the $21-$3F contour set) become index cell-$20
L_5C34:
        cmp     #$21                    ; 5C34  33 valid terrain indices, 0 to $20
        bcc     L_5C3F                  ; 5C36  in range - look the pattern up
L_5C38:
        lda     #$9B                    ; 5C38  blank pattern $5A9B: nothing but background
        ldy     #$5A                    ; 5C3A  high byte of the pattern address
        jmp     L_5C49                  ; 5C3C  go and plot it

L_5C3F:
        tay                             ; 5C3F  terrain index into Y
        lda     overviewPatternLo,y     ; 5C40  low byte of this terrain's 4-byte pattern
        pha                             ; 5C43  park it while the high byte is fetched
        lda     overviewPatternHi,y     ; 5C44  high byte (always $5A)
        tay                             ; 5C47  into Y for the store below
        pla                             ; 5C48  low byte back into A

; ----------------------------------------------------------------------
; Plot the chosen 4-byte pattern into the quadrant: each of the four scanlines is masked with the unit
; colour mask, then with the half-of-the-character mask, and ORed into the build buffer.
; ----------------------------------------------------------------------
L_5C49:
        sta     L_5C64+1                ; 5C49  self-modify the LDA abs,Y at $5C64 with the pattern address, low byte
        sty     L_5C64+2                ; 5C4C  ...and high byte
        ldx     L_5C77+1                ; 5C4F  the quadrant number saved on entry
        lda     quadrantPixelMask,x     ; 5C52  $F0 = the left pixel pair of the character, $0F = the right one
        sta     L_5C69+1                ; 5C55  self-modify the second AND # at $5C6A
        lda     quadrantRowOffset,x     ; 5C58  0 for the top quadrants, 4 for the bottom ones
        tax                             ; 5C5B  X = first scanline of the quadrant inside the 8-byte buffer
        clc                             ; 5C5C  plain add, no carry in
        adc     #$04                    ; 5C5D  each quadrant is 4 scanlines tall
        sta     L_5C73+1                ; 5C5F  self-modify the CPX # at $5C74 with the end scanline
        ldy     #$00                    ; 5C62  start at byte 0 of the pattern
L_5C64:
        lda     irqVectorHi,y           ; 5C64  operand patched at $5C49: scanline Y of the chosen pattern
L_5C67:
        and     #$FF                    ; 5C67  operand patched at $5BDC/$5BFB/$5C25: $55 own unit, $AA enemy unit, $FF terrain
L_5C69:
        and     #$FF                    ; 5C69  operand patched at $5C55: keep only this quadrant's half of the byte
        ora     glyphBuildBuffer,x      ; 5C6B  OR into the character being built at $90B8
        sta     glyphBuildBuffer,x      ; 5C6E  store it back
        iny                             ; 5C71  next pattern byte
        inx                             ; 5C72  next scanline of the character
L_5C73:
        cpx     #$FF                    ; 5C73  operand patched at $5C5F: first scanline + 4
        bcc     L_5C64                  ; 5C75  four scanlines per quadrant
L_5C77:
        ldx     #$FF                    ; 5C77  operand patched at $5BB0/$5BD7: restore the caller's quadrant number
L_5C79:
        ldy     #$FF                    ; 5C79  operand patched at $5BAD/$5BD4: restore the caller's Y
L_5C7B:
        rts                             ; 5C7B  done (drawMapCell borrows this RTS for its blackout exit)


; ----------------------------------------------------------------------
; drawMapCell - Redraws a single battlefield cell everywhere it can currently be seen.  On the
; battlefield screen that means the 16x16 tile in the 7x5 tactical view (only when the cell is inside
; it) plus one 2x4-pixel quadrant of the strategic overview, which is rebuilt in place: the character
; is read back out of the bitmap, the quadrant's old pixels are cleared, the new pattern is ORed in,
; the field markings are restored and the character is written back.  On the comcen radar screen it
; hands the work to drawScreen1Cell.  Nothing is drawn at all during the comcen knock-out blackout.
; In:  A = the map cell byte, X = map column, Y = map row (saved in savedRegA/X/Y $54-$56);
;     currentScreen $90FB; viewOriginCol/Row $A5/$A6; comcenStunStatus $922A bit 6
; Out: bitmap, screen RAM and colour RAM updated; A/X/Y restored to the values passed in;
;     self-modifies the operands at $5CD1, $5CE9, $5CEF, $5CFA and $5CFC
; Called from: the unit redraw helpers at $3F89 and $437D and, as a tail jump, from $48AE - i.e.
;     whenever a unit moves, is hit or is removed.
; ----------------------------------------------------------------------
drawMapCell:
        bit     comcenStunStatus        ; 5C7C  $922A: bit 6 is set while the comcen knock-out blackout sequence runs
        bvs     L_5C7B                  ; 5C7F  blackout in progress - draw nothing (borrows the RTS at the end of the routine above)
        jsr     saveRegsAXY             ; 5C81  $C0E8: A/X/Y -> savedRegA/savedRegX/savedRegY = cell byte, column, row
        ldx     currentScreen           ; 5C84  0 = battlefield screen, 1 = comcen radar screen, 3 = menus
        beq     L_5C8C                  ; 5C87  battlefield: the tactical view and the overview both need updating
        jmp     L_5D0A                  ; 5C89  any other screen: skip straight to the screen-1 test

L_5C8C:
        lda     savedRegX               ; 5C8C  map column of the cell
        sec                             ; 5C8E  prepare a true subtraction
        sbc     viewOriginCol           ; 5C8F  position relative to the left edge of the tactical view
        cmp     #$07                    ; 5C91  the view is 7 cells wide; an unsigned compare also rejects columns left of the origin
        bcs     L_5CA1                  ; 5C93  outside the view - only the overview needs the update
        lda     savedRegY               ; 5C95  map row of the cell
        sec                             ; 5C97  prepare a true subtraction
        sbc     viewOriginRow           ; 5C98  position relative to the top edge of the tactical view
        cmp     #$05                    ; 5C9A  the view is 5 cells tall
        bcs     L_5CA1                  ; 5C9C  outside the view
        jsr     drawViewportCell        ; 5C9E  redraw the 16x16 tile inside the tactical view

; ----------------------------------------------------------------------
; Overview update: find the character that holds this cell, read it back out of the bitmap, erase just
; the quadrant belonging to the cell, OR the new pattern in, restore the field marking and write the
; character back.  The colour RAM nibble is deliberately left alone - a single cell cannot change the
; colour of the 2x2 block it shares.
; ----------------------------------------------------------------------
L_5CA1:
        jsr     restoreRegsAXY          ; 5CA1  $C030: A/X/Y = cell byte, column, row again
        txa                             ; 5CA4  map column
        lsr     a                       ; 5CA5  two map columns share one overview character
        clc                             ; 5CA6  the LSR above may have left the carry set - clear it before the add
        adc     #$01                    ; 5CA7  the overview window starts at screen column 1
        tax                             ; 5CA9  X = character column
        tya                             ; 5CAA  map row
        lsr     a                       ; 5CAB  two map rows share one overview character
        clc                             ; 5CAC  the LSR above may have left the carry set - clear it before the add
        adc     #$01                    ; 5CAD  the overview window starts at screen row 1
        tay                             ; 5CAF  Y = character row
        sty     L_5CFB+1                ; 5CB0  self-modify the LDY # at $5CFC - the quarter-line pass needs the character row
        stx     L_5CF9+1                ; 5CB3  self-modify the LDX # at $5CFA (the value it loads is never used - see $5CF9)
        jsr     calcCellPointersXY      ; 5CB6  bitmapPtr/screenPtr of that character cell
        ldy     #$07                    ; 5CB9  8 scanlines
L_5CBB:
        lda     (bitmapPtr),y           ; 5CBB  read scanline Y of the character out of the bitmap...
        sta     glyphBuildBuffer,y      ; 5CBD  ...into the build buffer at $90B8
        dey                             ; 5CC0  next scanline
        bpl     L_5CBB                  ; 5CC1  until all 8 have been copied back
        lda     savedRegY               ; 5CC3  map row of the cell
        and     #$01                    ; 5CC5  odd row = the lower half of the character
        asl     a                       ; 5CC7  quadrants 2 and 3 are the lower ones
        sta     L_5CD0+1                ; 5CC8  self-modify the ADC # at $5CD1
        lda     savedRegX               ; 5CCB  map column of the cell
        and     #$01                    ; 5CCD  odd column = the right half of the character
        clc                             ; 5CCF  clear the carry for the quadrant add below
L_5CD0:
        adc     #$FF                    ; 5CD0  operand patched at $5CC8: quadrant = (row&1)*2 + (col&1), so 0-3
        tax                             ; 5CD2  X = quadrant number
        pha                             ; 5CD3  keep it across the clearing loop
        lda     quadrantPixelMask,x     ; 5CD4  $F0 or $0F: the half of the byte this quadrant owns
        eor     #$FF                    ; 5CD7  invert it into an AND mask that clears exactly those pixels
        sta     L_5CE8+1                ; 5CD9  self-modify the AND # at $5CE9
        lda     quadrantRowOffset,x     ; 5CDC  first scanline of the quadrant, 0 or 4
        tax                             ; 5CDF  X = scanline index into the build buffer
        adc     #$04                    ; 5CE0  +4 scanlines; the carry is still clear from the ADC at $5CD0, whose result can never exceed 3
        sta     L_5CEE+1                ; 5CE2  self-modify the CPX # at $5CEF with the end scanline
L_5CE5:
        lda     glyphBuildBuffer,x      ; 5CE5  scanline X of the character
L_5CE8:
        and     #$FF                    ; 5CE8  operand patched at $5CD9: blank this quadrant's pixels, leave the neighbour's alone
        sta     glyphBuildBuffer,x      ; 5CEA  store it back
        inx                             ; 5CED  next scanline
L_5CEE:
        cpx     #$FF                    ; 5CEE  operand patched at $5CE2: first scanline + 4
        bcc     L_5CE5                  ; 5CF0  four scanlines per quadrant
        pla                             ; 5CF2  quadrant number back off the stack
        tax                             ; 5CF3  into X for the call below
        lda     savedRegA               ; 5CF4  the new map cell byte
        jsr     buildOverviewQuadrantNoColour; 5CF6  OR its pattern into the freshly cleared quadrant (the colour is left untouched)
L_5CF9:
        ldx     #$FF                    ; 5CF9  operand patched at $5CB3; dead - drawOverviewQuarterLine opens with LDX #$00 (leftover)
L_5CFB:
        ldy     #$FF                    ; 5CFB  operand patched at $5CB0: the character row, which the quarter-line test needs
        jsr     drawOverviewQuarterLine ; 5CFD  put the quarter or midfield line back on scanline 0 if this row carries one
        ldy     #$07                    ; 5D00  8 scanlines
L_5D02:
        lda     glyphBuildBuffer,y      ; 5D02  scanline Y of the rebuilt character
        sta     (bitmapPtr),y           ; 5D05  back into the $A000 bitmap
        dey                             ; 5D07  next scanline
        bpl     L_5D02                  ; 5D08  until all 8 are written
L_5D0A:
        lda     currentScreen           ; 5D0A  which screen is up
        cmp     #$01                    ; 5D0D  screen 1 = the comcen radar/scanner map
        bne     L_5D17                  ; 5D0F  not the radar screen - nothing more to do
        jsr     restoreRegsAXY          ; 5D11  A/X/Y = cell byte, column, row
        jsr     drawScreen1Cell         ; 5D14  redraw the cell in the radar window as well
L_5D17:
        jmp     restoreRegsAXY          ; 5D17  leave with A/X/Y exactly as the caller passed them


; ----------------------------------------------------------------------
; drawViewportCell - Draws one map cell as a 16x16 pixel tile (2x2 characters) in the 7x5 tactical
; view on the right of the battlefield screen.  The screen position is character column 2*(col -
; viewOriginCol) + 25 and row 2*(row - viewOriginRow) + 1.  renderMapTile reads the cell's own map
; coordinates out of viewOriginCol/Row, so those are borrowed for the duration and restored
; afterwards; it builds the 32-byte tile in the buffer at $04C8 and writes the four screen RAM colour
; bytes itself, and this routine copies the 32 bytes into the bitmap - the first 16 at the tile
; origin, the second 16 one character row (320 bytes) lower.
; In:  savedRegA/X/Y ($54/$55/$56) = cell byte, map column, map row; viewOriginCol/Row $A5/$A6
; Out: four character cells of the $A000 bitmap and the matching screen/colour RAM written;
;     drawCellSaveCol/Row $90F6/$90F7 used as scratch; viewOriginCol/Row restored; self-modifies the
;     operand at $5D58/$5D59
; Called from: drawMapCell ($5C9E) and the view scroller scrollViewBitmap ($1F1B, $1F44) when a new
;     row or column is exposed.
; ----------------------------------------------------------------------
drawViewportCell:
        lda     viewOriginCol           ; 5D1A  current left edge of the tactical view
        sta     drawCellSaveCol         ; 5D1C  keep it: viewOriginCol is about to be borrowed
        lda     viewOriginRow           ; 5D1F  current top edge of the tactical view
        sta     drawCellSaveRow         ; 5D21  keep it too
        ldy     savedRegY               ; 5D24  map row of the cell
        ldx     savedRegX               ; 5D26  map column of the cell
        txa                             ; 5D28  map column of the cell
        sec                             ; 5D29  prepare a true subtraction
        sbc     viewOriginCol           ; 5D2A  0-6: position of the cell inside the 7-cell-wide view
        stx     viewOriginCol           ; 5D2C  renderMapTile reads the cell's own column out of $A5
        asl     a                       ; 5D2E  each cell is 2 characters wide
        clc                             ; 5D2F  the ASL above may have set the carry - clear it before the add
        adc     #$19                    ; 5D30  the tactical view starts at screen character column 25
        tax                             ; 5D32  X = character column of the tile
        tya                             ; 5D33  map row of the cell
        sec                             ; 5D34  prepare a true subtraction
        sbc     viewOriginRow           ; 5D35  0-4: position of the cell inside the 5-cell-tall view
        sty     viewOriginRow           ; 5D37  renderMapTile reads the cell's own row out of $A6
        asl     a                       ; 5D39  each cell is 2 characters tall
        clc                             ; 5D3A  the ASL above may have set the carry - clear it before the add
        adc     #$01                    ; 5D3B  the tactical view starts at screen character row 1
        tay                             ; 5D3D  Y = character row of the tile
        jsr     calcCellPointersXY      ; 5D3E  bitmapPtr = the tile's top-left character, screenPtr = its screen RAM byte
        ldy     #$00                    ; 5D41  renderMapTile writes screen and colour RAM at (screenPtr),Y
        lda     savedRegA               ; 5D43  the map cell byte
        jsr     renderMapTile           ; 5D45  build the 32-byte 16x16 multicolour tile in $04C8 and colour the four screen cells
        lda     bitmapPtr               ; 5D48  bitmap destination, low byte...
        sta     L_5D57+1                ; 5D4A  ...into the STA operand at $5D58
        lda     bitmapPtrHi             ; 5D4D  ...high byte...
        sta     L_5D57+2                ; 5D4F  ...into the STA operand at $5D59
        ldy     #$00                    ; 5D52  32 tile bytes, index 0 upwards
L_5D54:
        lda     play4Row12,y            ; 5D54  byte Y of the tile buffer at $04C8
L_5D57:
        sta     irqVectorHi,y           ; 5D57  operand patched at $5D4A/$5D68: the current row of characters in the $A000 bitmap
        iny                             ; 5D5A  next tile byte
        cpy     #$20                    ; 5D5B  32 bytes = four 8-byte character cells
        bcs     L_5D74                  ; 5D5D  whole tile copied
        cpy     #$10                    ; 5D5F  after 16 bytes the upper pair of characters is done
        bne     L_5D54                  ; 5D61  still in the upper row - keep copying
        clc                             ; 5D63  plain 16-bit add of the $130 step
        lda     bitmapPtr               ; 5D64  bitmap origin of the tile...
        adc     #$30                    ; 5D66  ...plus $130; Y is already $10, so the next store lands at origin + $140 = 320 bytes = one character row lower
        sta     L_5D57+1                ; 5D68  new destination, low byte
        lda     bitmapPtrHi             ; 5D6B  bitmap origin of the tile, high byte...
        adc     #$01                    ; 5D6D  ...plus the carry and the $100 of the $130 step
        sta     L_5D57+2                ; 5D6F  new destination, high byte
        bne     L_5D54                  ; 5D72  always taken - a bitmap address in $A000-$BFFF never has a zero high byte
L_5D74:
        lda     drawCellSaveCol         ; 5D74  the real left edge of the view
        sta     viewOriginCol           ; 5D77  put it back
        lda     drawCellSaveRow         ; 5D79  the real top edge of the view
        sta     viewOriginRow           ; 5D7C  put it back
        rts                             ; 5D7E  done


; ----------------------------------------------------------------------
; drawScreen1Cell - Redraws one map cell on screen 1, the comcen radar/scanner map, where the window
; is 21 cells wide by 17 tall at character (3,3) and every cell is a single 8x8 glyph.  If the cell
; holds our own comcen the window has to follow it, so the whole job is handed to the overlay routine
; at $7959; otherwise the cell is clipped against the window, the bitmap and colour RAM pointers are
; built from the row/column tables at $9300 with a +3 character offset, the radar palette is set (dark
; grey for our units and the terrain, white for the enemy) and the overlay routines at $74C3 and $7396
; turn the cell byte into a glyph and plot it.  viewOriginCol/Row are borrowed as the cell's
; coordinates while that happens.
; In:  savedRegA/X/Y ($54/$55/$56) = cell byte, map column, map row; viewOriginCol/Row $A5/$A6 =
;     top-left cell of the radar window; comcenUnit $9236
; Out: one glyph in the bitmap and one byte in colour RAM;
;     radarOwnUnitColour/radarEnemyUnitColour/radarTerrainColour $9237-$9239 set; bitmapPtr/screenPtr
;     and drawCellSaveCol/Row $90F6/$90F7 used; viewOriginCol/Row restored
; Called from: drawMapCell ($5D14) and, in both $6F00 overlay variants, from $794F.
; ----------------------------------------------------------------------
drawScreen1Cell:
        lda     savedRegA               ; 5D7F  the map cell byte
        bpl     L_5D8D                  ; 5D81  plain terrain - nothing special about it
        and     #$7F                    ; 5D83  strip the unit marker to get the unit index
        cmp     comcenUnit              ; 5D85  $9236 = 49 or 99, this player's own command centre
        bne     L_5D8D                  ; 5D88  some other unit - draw it normally
        jmp     scrollRadarOnComcenMove ; 5D8A  the radar window is centred on our comcen, so a comcen move scrolls the whole window ($7959)

L_5D8D:
        lda     savedRegX               ; 5D8D  map column of the cell
        sec                             ; 5D8F  prepare a true subtraction
        sbc     viewOriginCol           ; 5D90  column relative to the left edge of the radar window
        tax                             ; 5D92  keep it in X for the bitmap column table
        cmp     #$15                    ; 5D93  the radar window shows 21 columns
        bcc     L_5D98                  ; 5D95  inside the window
L_5D97:
        rts                             ; 5D97  off the window - nothing to redraw

L_5D98:
        lda     savedRegY               ; 5D98  map row of the cell
        sec                             ; 5D9A  prepare a true subtraction
        sbc     viewOriginRow           ; 5D9B  row relative to the top edge of the radar window
        tay                             ; 5D9D  keep it in Y for the bitmap row table
        cmp     #$11                    ; 5D9E  the radar window shows 17 rows
        bcs     L_5D97                  ; 5DA0  off the window
        lda     #$0B                    ; 5DA2  dead instruction - the next one loads the very same value (leftover)
        lda     #$0B                    ; 5DA4  colour $0B = dark grey
        sta     radarOwnUnitColour      ; 5DA6  $9237: the colour our own units are plotted in on the radar
        sta     radarTerrainColour      ; 5DA9  $9239: and the colour the terrain glyphs get
        lda     #$01                    ; 5DAC  colour 1 = white
        sta     radarEnemyUnitColour    ; 5DAE  $9238: enemy blips stand out against the grey
        clc                             ; 5DB1  plain 16-bit add of row base + column offset
        lda     bitmapRowLoTableFrom3,y ; 5DB2  bitmap row address table $9300, offset by 3 rows - the window starts at character row 3
        adc     bitmapColLoTableFrom3,x ; 5DB5  column*8 table $9332, offset by 3 columns - and at character column 3
        sta     bitmapPtr               ; 5DB8  bitmapPtr low byte
        lda     bitmapRowHiTableFrom3,y ; 5DBA  high half of the row table ($9319 + 3)
        adc     bitmapColHiTableFrom3,x ; 5DBD  high half of the column table ($935A + 3)
        sta     bitmapPtrHi             ; 5DC0  bitmapPtr high byte
        clc                             ; 5DC2  plain 16-bit add
        lda     screenRowLoTableFrom3,y ; 5DC3  screen RAM row address table $9382, offset by 3 rows
        adc     #$03                    ; 5DC6  plus the 3-column indent of the window
        sta     screenPtr               ; 5DC8  screenPtr low byte
        lda     screenRowHiTableFrom3,y ; 5DCA  high half of the screen row table ($939B + 3)
        adc     #$00                    ; 5DCD  propagate the carry
        sta     screenPtrHi             ; 5DCF  screenPtr high byte
        clc                             ; 5DD1  plain 16-bit add
        txa                             ; 5DD2  the cell's column inside the window
        adc     screenPtr               ; 5DD3  add it to the row address
        sta     screenPtr               ; 5DD5  screenPtr low byte = row base + 3 + window column
        lda     screenPtrHi             ; 5DD7  screenPtr high byte...
        adc     #$4C                    ; 5DD9  $8C00 + $4C00 = $D800: point straight at colour RAM
        sta     screenPtrHi             ; 5DDB  screenPtr now addresses colour RAM directly
        lda     viewOriginCol           ; 5DDD  current left edge of the radar window
        sta     drawCellSaveCol         ; 5DDF  keep it: viewOriginCol is about to be borrowed
        lda     viewOriginRow           ; 5DE2  current top edge of the radar window
        sta     drawCellSaveRow         ; 5DE4  keep it too
        jsr     restoreRegsAXY          ; 5DE7  A = cell byte, X = map column, Y = map row
        stx     viewOriginCol           ; 5DEA  getRadarCellGlyph reads the cell's own column out of $A5
        sty     viewOriginRow           ; 5DEC  ...and its row out of $A6
        ldy     #$00                    ; 5DEE  Y = 0, the offset drawRadarGlyph stores the colour byte at
        lda     savedRegA               ; 5DF0  the map cell byte
        jsr     getRadarCellGlyph       ; 5DF2  overlay routine $74C3: cell byte -> glyph index in A (bit 7 = noise) and colour in tileColour  (also mapGenerator6F00:contourFillWithInterior)
        jsr     drawRadarGlyph          ; 5DF5  overlay routine $7396: plot the 8x8 glyph at (bitmapPtr) and its colour at (screenPtr),Y  (also mapGenerator6F00:jitterForestSeedColumn)
        lda     drawCellSaveCol         ; 5DF8  the real left edge of the radar window
        sta     viewOriginCol           ; 5DFB  put it back
        lda     drawCellSaveRow         ; 5DFD  the real top edge of the radar window
        sta     viewOriginRow           ; 5E00  put it back
        rts                             ; 5E02  done


; ----------------------------------------------------------------------
; clearOverviewWindow - Points the text window at the 20x20 overview panel (left column 1, top row 1,
; width 20, bottom row limit 21) and blanks it with the text engine's window clear.
; In:  -
; Out: zp_39-zp_3C = 1/21/1/20, the overview area cleared
; Called from: mainStart ($0A98), clearCurrentScreenWindows ($2DF1) and overlay A ($76FC, $8235)
; ----------------------------------------------------------------------
clearOverviewWindow:
        lda     #$01                    ; 5E03  text window left edge = screen character column 1
        sta     textWindowLeft          ; 5E05  textWindowLeft = 1
        sta     textWindowTop           ; 5E07  and textWindowTop = 1: the overview frame starts at character (1,1)
        lda     #$14                    ; 5E09  20 characters wide
        sta     textWindowWidth         ; 5E0B  textWindowWidth = 20 (the 40x40 map is drawn 4x4 pixels per cell inside this frame)
        lda     #$15                    ; 5E0D  row limit 21 (exclusive), i.e. rows 1-20
        sta     textWindowBottom        ; 5E0F  textWindowBottom = 21
        jmp     bootDriveDataBit        ; 5E11  tail-call the text engine's window clear ($C06A) and return to the caller  (also textEngineC000:clearTextWindow)


; uiLevelTextColourTable: byteTable, 3 bytes. Info-panel text colour per uiLevel: $09 (browsing), $05
; (unit selected), $04 (group). Read at $22AE (showUnitInfoPanel) and $64E7.
uiLevelTextColourTable:
        .byte   $09,$05,$04             ; 5E14  ...  3 bytes indexed by uiLevel ($9209): 0 browsing = colour 9, 1 unit selected = colour 5, 2 group = colour 4

; ----------------------------------------------------------------------
; handleOwnUnitClick - Entry taken by the battlefield main loop when the player clicks on one of his
; own, unstunned units (index already in selectedUnit $90F8). A unit that is a member of a group is
; handed to runGroupOptionsMenu; a lone unit falls through into runUnitOptionsMenu.
; In:  selectedUnit $90F8, unitDisplayFlagsTable $FB54 bit 6
; Out: control passes to one of the two option-menu routines, both of which end in exitUnitMenu
; Called from: the battlefield fire-button handler ($20CC)
; ----------------------------------------------------------------------
handleOwnUnitClick:
        ldy     selectedUnit            ; 5E17  index (0-99) of the own, unstunned unit the player just clicked on
        lda     unitDisplayFlagsTable,y ; 5E1A  its local flag copy $FB54: bit 7 idle, bit 6 in a group, bits 2-5 group key, bits 0-1 facing
        and     #$40                    ; 5E1D  test bit 6 = 'this unit belongs to a group'
        beq     runUnitOptionsMenu      ; 5E1F  lone unit: fall through into the single-unit branch
        jmp     runGroupOptionsMenu     ; 5E21  group member: the whole group is commanded instead


; ----------------------------------------------------------------------
; runUnitOptionsMenu - Single-unit branch of handleOwnUnitClick, also entered by JMP from the group
; menu item DIRECT UNIT ($62F6). It highlights the unit and shows its info panel (which waits there
; until the stick moves or the button is pressed again); a deflected stick then starts SETTING
; DESTINATION, while a second click builds and runs the UNIT OPTIONS menu - but only when the UNIT
; MENUS option (gameTypeOptions $0BA3 bit 3) is on. The chosen item queues its command byte with the
; unit index as the argument.
; In:  selectedUnit $90F8, unit arrays $F76C/$F898/$F8FC/$F960/$FB54, joyDirection zp_5D, gamePhase
;      zp_B1, gameTypeOptions $0BA3 bit 3, comcenUnit $9236
; Out: highlightedUnit/highlightedGroupKey/uiLevel/menuLoopMode and the menu state set, viewport and
;      info panel redrawn, menuIdleSnapshot+1 patched, one of the commands $82/$84/$86/$93/$95/$9F/$A0
;      queued (or $87 through the target cursor, $88 through the join-group menu); falls into
;      exitUnitMenu
; Called from: handleOwnUnitClick ($5E1F) and the group menu item DIRECT UNIT ($62F6)
; ----------------------------------------------------------------------
runUnitOptionsMenu:
        lda     #$00                    ; 5E24  0 into the three text-engine mode bytes below
        sta     rawGlyphMode            ; 5E26  rawGlyphMode off: printChar translates ASCII again
        sta     textCentreWidth         ; 5E28  textCentreWidth 0 = print left-aligned at the cursor
        sta     textFillGlyph           ; 5E2B  fill/pad with glyph 0 (space)
        lda     #$FF                    ; 5E2D  $FF = no group highlighted
        sta     highlightedGroupKey     ; 5E2F  clear the viewport group highlight
        lda     selectedUnit            ; 5E32  the clicked unit...
        sta     highlightedUnit         ; 5E35  ...is the one the viewport highlights
        jsr     drawViewport            ; 5E38  repaint the 7x5-cell tactical view with the new highlight
        lda     #$01                    ; 5E3B  UI level 1
        sta     uiLevel                 ; 5E3D  uiLevel 1 = a single unit is selected (info-panel colour 5)
        jsr     setCursorShapeSquare    ; 5E40  sprite 0 becomes the hollow-square cell cursor
        lda     #$05                    ; 5E43  colour 5 = the unit-level text colour (uiLevelTextColourTable[1])
        sta     textColour              ; 5E45  ink colour for the info panel
        sta     textPadColour           ; 5E47  and the same colour for the blanks that pad centred lines
        jsr     clearInfoPanel          ; 5E49  clear the info-panel window (columns 25-38, rows 12-21)
        jsr     showUnitInfoPanel       ; 5E4C  draw the unit's status there; that routine loops until the player deflects the stick, clicks again or the display is interrupted

; ----------------------------------------------------------------------
; Work out what ended the info panel: the unit died, the stick was deflected (start a move order) or
; the fire button was pressed (open the options menu).
; ----------------------------------------------------------------------
        ldy     selectedUnit            ; 5E4F  re-read the unit: the battle kept running under the panel
        lda     unitTypeTable,y         ; 5E52  unit type byte $F76C, bit 7 = destroyed
        bmi     L_5E65                  ; 5E55  the unit was killed while its panel was up: just leave
        lda     joyDirection            ; 5E57  joystick nibble latched by the IRQ, active low ($0F = centred)
        cmp     #$0F                    ; 5E59  still centred?
        beq     L_5E68                  ; 5E5B  centred: then it was the fire button that ended the panel
        lda     #$00                    ; 5E5D  clear the input lockout...
        sta     inputLockoutTimer       ; 5E5F  ...so the destination cursor reacts to the stick immediately
        jsr     promptForDestination    ; 5E62  stick deflected: run SETTING / DESTINATION for this unit (ends in command $80)
L_5E65:
        jmp     exitUnitMenu            ; 5E65  back to the battlefield loop with uiLevel 0

L_5E68:
        jsr     checkFirePressed        ; 5E68  was there a fresh fire-button press? (Z = 1 when yes)
        bne     L_5E65                  ; 5E6B  no press - the panel simply timed out: leave
        lda     gameTypeOptions         ; 5E6D  game type and option bits $0BA3
        and     #$08                    ; 5E70  bit 3 = the UNIT MENUS option (set by the FULL WAR/$1D and DEFENDER/$1E presets, or by hand in a custom game)
        beq     L_5E65                  ; 5E72  this scenario has no unit option menus: leave

; ----------------------------------------------------------------------
; Second click with the UNIT MENUS option on: re-establish the highlight and the cursor sprite
; (showUnitInfoPanel used sprites 1-4 for the unit picture) and set up the menu state.
; ----------------------------------------------------------------------
        lda     #$FF                    ; 5E74  $FF = no group highlight
        sta     highlightedGroupKey     ; 5E76  clear the group highlight again
        lda     selectedUnit            ; 5E79  the clicked unit...
        sta     highlightedUnit         ; 5E7C  ...stays highlighted
        jsr     drawViewport            ; 5E7F  repaint the view: showUnitInfoPanel moved the picture sprites and the path marker
        lda     #$01                    ; 5E82  UI level 1
        sta     uiLevel                 ; 5E84  uiLevel = 1 (unit)
        jsr     setCursorShapeSquare    ; 5E87  restore the square cursor sprite (the info panel may have used sprites 1-4)
        lda     #$FF                    ; 5E8A  $FF
        sta     menuLoopMode            ; 5E8C  menuLoopMode $FF: the menu loop animates this unit's path marker (no group preview)
        lda     #$01                    ; 5E8F  1
        sta     uiLevel                 ; 5E91  redundant - uiLevel was already set to 1 at $5E84
        lda     #$00                    ; 5E94  0
        sta     menuSelectedItem        ; 5E96  start the selection on the first menu row

; ----------------------------------------------------------------------
; Build the UNIT OPTIONS list. Re-entered from $5F22 when the menu loop reports that the unit's idle
; flag changed, because that swaps the DIG items for the BLITZ items. Every item is appended with
; addMenuItem, which also prints its name on the next row.
; ----------------------------------------------------------------------
buildUnitOptionsMenu:
        lda     #$05                    ; 5E99  unit-level colour 5
        sta     textColour              ; 5E9B  ink colour
        sta     textPadColour           ; 5E9D  pad colour
        jsr     clearInfoPanel          ; 5E9F  clear the info panel before printing the menu
        lda     #$0A                    ; 5EA2  $CB0A = '    UNIT' + CR
        ldy     #$CB                    ; 5EA4  high byte of the heading string
        jsr     printString             ; 5EA6  print the first heading line
        jsr     printStringAtPointer    ; 5EA9  print the string that follows it, $CB13 '   OPTIONS' + CR + CR
        ldy     selectedUnit            ; 5EAC  unit index for the flag look-ups below
        ldx     #$00                    ; 5EAF  0
        stx     menuItemCount           ; 5EB1  start with an empty item list
        ldx     #$02                    ; 5EB4  item id 2 = CLOAKING ON
        lda     unitPathColTable,y      ; 5EB6  $F898 (waypoint column), bit 6 = 'cloaking ordered'
        and     #$40                    ; 5EB9  test the cloak-order bit
        beq     L_5EBF                  ; 5EBB  not cloaked: offer CLOAKING ON
        ldx     #$03                    ; 5EBD  already cloaked: item id 3 = NO CLOAKING
L_5EBF:
        jsr     addMenuItem             ; 5EBF  append the item and print its name

; ----------------------------------------------------------------------
; The second item depends on what the unit is doing: a unit that stands still can DIG IN or DIG OUT, a
; unit that is moving can be given or denied a BLITZ order. The same flag byte is snapshotted into the
; menu loop so that the list is rebuilt when that state changes.
; ----------------------------------------------------------------------
        ldy     selectedUnit            ; 5EC2  unit index
        lda     unitDisplayFlagsTable,y ; 5EC5  $FB54 flags, bit 7 = the unit is standing still
        ora     #$01                    ; 5EC8  force the byte nonzero: 0 is the 'do not watch the unit' sentinel of the menu loop
        sta     menuIdleSnapshot+1      ; 5ECA  self-modify the LDA #imm at menuIdleSnapshot ($602A): the loop returns $80 when this bit 7 changes
        bpl     L_5EDB                  ; 5ECD  bit 7 clear = the unit is moving: offer the blitz items
        ldx     #$11                    ; 5ECF  item id 17 = DIG OUT
        lda     unitStunTable,y         ; 5ED1  $F960, bits 5-7 = dug-in / digging state
        and     #$E0                    ; 5ED4  isolate the dig bits
        bne     L_5EE8                  ; 5ED6  already dug in or digging: DIG OUT
        inx                             ; 5ED8  otherwise item id 18 = DIG IN
        bne     L_5EE8                  ; 5ED9  always taken (X = $12)
L_5EDB:
        ldx     #$0A                    ; 5EDB  item id 10 = SET BLITZ
        ldy     selectedUnit            ; 5EDD  unit index
        lda     unitPathRowTable,y      ; 5EE0  $F8FC (waypoint row), bit 6 = 'blitz ordered'
        and     #$40                    ; 5EE3  test the blitz-order bit
        beq     L_5EE8                  ; 5EE5  blitz off: offer SET BLITZ
        inx                             ; 5EE7  blitz on: item id 11 = BLITZ OFF
L_5EE8:
        jsr     addMenuItem             ; 5EE8  append the dig/blitz item

; ----------------------------------------------------------------------
; JOIN GROUP is offered to every unit except the command centre; a BOOMER additionally gets SET TARGET
; or CLEAR TARGET while a battle is in progress.
; ----------------------------------------------------------------------
        ldy     selectedUnit            ; 5EEB  unit index
        cpy     comcenUnit              ; 5EEE  is this the player's own command centre (unit 49 or 99)?
        beq     L_5F0E                  ; 5EF1  the comcen cannot join a group and has no weapon target
        ldx     #$07                    ; 5EF3  item id 7 = JOIN GROUP
        jsr     addMenuItem             ; 5EF5  append it
        lda     gamePhase               ; 5EF8  gamePhase zp_B1 is 0 only while the battle runs
        bne     L_5F0E                  ; 5EFA  setup or film phase: no target item
        ldx     selectedUnit            ; 5EFC  unit index
        jsr     getBoomerTarget         ; 5EFF  C = 1 and A = the boomer's target byte if unit X is a BOOMER (type 2)
        bcc     L_5F0E                  ; 5F02  not a boomer: no target item
        ldx     #$04                    ; 5F04  item id 4 = CLEAR TARGET
        tay                             ; 5F06  test the target byte's bit 7
        bmi     L_5F0B                  ; 5F07  bit 7 set = a target is locked: offer CLEAR TARGET
        ldx     #$05                    ; 5F09  no target yet: item id 5 = SET TARGET
L_5F0B:
        jsr     addMenuItem             ; 5F0B  append the target item
L_5F0E:
        ldx     #$0C                    ; 5F0E  item id 12 = (EXIT), always the last row
        jsr     addMenuItem             ; 5F10  append it
        lda     #$0F                    ; 5F13  screen row 15
        sta     menuFirstRow            ; 5F15  the item list starts on row 15 of the info panel
        jsr     resetPathMarker         ; 5F18  put the mini-map path marker back on the unit
        jsr     runMenuLoopWatchUnit    ; 5F1B  run the selector: A = chosen item id, Y = chosen row (or $80)
        cpy     #$80                    ; 5F1E  $80 = the unit's idle flag changed while the menu was open
        bne     L_5F25                  ; 5F20  no: act on the chosen item
        jmp     buildUnitOptionsMenu    ; 5F22  yes: DIG vs BLITZ is wrong now, rebuild the whole menu


; ----------------------------------------------------------------------
; Dispatch on the chosen item id. Every branch either queues a command byte through the common tail at
; $5F9E (argument = the unit index) or runs a sub-dialogue of its own.
; ----------------------------------------------------------------------
L_5F25:
        sta     menuSelectedItem        ; 5F25  keep the chosen item id (from here on $91D5 holds an id, not a row)
        ldy     selectedUnit            ; 5F28  leftover load - every path below reloads X or Y (dead)
        lda     menuSelectedItem        ; 5F2B  the chosen item id
        cmp     #$02                    ; 5F2E  CLOAKING ON?
        bne     L_5F36                  ; 5F30  no
        ldy     #$82                    ; 5F32  command $82 = cloak on (2 bytes: unit)
        bne     L_5F3C                  ; 5F34  always taken
L_5F36:
        cmp     #$03                    ; 5F36  NO CLOAKING?
        bne     L_5F4A                  ; 5F38  no
        ldy     #$84                    ; 5F3A  command $84 = cloak off (2 bytes: unit)
L_5F3C:
        ldx     selectedUnit            ; 5F3C  unit index
        lda     unitPathColTable,x      ; 5F3F  flip the local copy of the cloak bit at once...
        eor     #$40                    ; 5F42  ...bit 6 of $F898...
        sta     unitPathColTable,x      ; 5F44  ...so the display shows the new state before the command is executed
        tya                             ; 5F47  command byte back into A
        bne     L_5F9E                  ; 5F48  always taken: queue it
L_5F4A:
        cmp     #$04                    ; 5F4A  CLEAR TARGET?
        bne     L_5F52                  ; 5F4C  no
        lda     #$86                    ; 5F4E  command $86 = clear the boomer's target (2 bytes: unit)
        bne     L_5F9E                  ; 5F50  always taken
L_5F52:
        cmp     #$05                    ; 5F52  SET TARGET?
        bne     L_5F6A                  ; 5F54  no
        jsr     printSettingHeaderForLevel; 5F56  print 'SETTING' centred in the uiLevel colour
        lda     #$61                    ; 5F59  $CB61 = 'TARGET'
        ldy     #$CB                    ; 5F5B  high byte
        jsr     printStringCentred      ; 5F5D  print it centred under SETTING
        lda     #$05                    ; 5F60  cursor mode 5
        sta     cursorTargetMode        ; 5F62  zp_BB > 0 = range-limited attack cursor
        jsr     runTargetCursorLoop     ; 5F64  let the player pick the target; the cursor loop queues command $87 itself
        jmp     exitUnitMenu            ; 5F67  done

L_5F6A:
        cmp     #$07                    ; 5F6A  JOIN GROUP?
        bne     L_5F74                  ; 5F6C  no
        jsr     runJoinGroupMenu        ; 5F6E  run the group picker; it queues command $88 itself
        jmp     exitUnitMenu            ; 5F71  done

L_5F74:
        cmp     #$0A                    ; 5F74  SET BLITZ?
        bne     L_5F7C                  ; 5F76  no
        ldy     #$93                    ; 5F78  command $93 = blitz on (2 bytes: unit)
        bne     L_5F82                  ; 5F7A  always taken
L_5F7C:
        cmp     #$0B                    ; 5F7C  BLITZ OFF?
        bne     L_5F90                  ; 5F7E  no
        ldy     #$95                    ; 5F80  command $95 = blitz off (2 bytes: unit)
L_5F82:
        ldx     selectedUnit            ; 5F82  unit index
        lda     unitPathRowTable,x      ; 5F85  flip the local blitz bit immediately...
        eor     #$40                    ; 5F88  ...bit 6 of $F8FC...
        sta     unitPathRowTable,x      ; 5F8A  ...for instant menu feedback
        tya                             ; 5F8D  command byte back into A
        bne     L_5F9E                  ; 5F8E  always taken
L_5F90:
        cmp     #$11                    ; 5F90  DIG OUT?
        bne     L_5F98                  ; 5F92  no
        lda     #$A0                    ; 5F94  command $A0 = dig out (2 bytes: unit)
        bne     L_5F9E                  ; 5F96  always taken
L_5F98:
        cmp     #$12                    ; 5F98  DIG IN?
        bne     exitUnitMenu            ; 5F9A  anything else (item 12 = (EXIT)) just closes the menu
        lda     #$9F                    ; 5F9C  command $9F = dig in (2 bytes: unit)
L_5F9E:
        ldy     selectedUnit            ; 5F9E  the unit index...
        sty     cmdParam0               ; 5FA1  ...is the single argument of all of these commands
        jsr     queueCmd2               ; 5FA3  queue 'command byte, unit' in the outgoing ring, then fall into exitUnitMenu

; ----------------------------------------------------------------------
; exitUnitMenu - Shared tail of every unit/group menu path: drops back to UI level 0 and releases the
; info panel, then returns to the battlefield main loop.
; In:  -
; Out: uiLevel $9209 = 0, infoPanelUnit $91D7 = $FF
; Called from: $5E65, $5F67, $5F71, $5F9A and the fall-through at $5FA3, $6339 and $64D7
; ----------------------------------------------------------------------
exitUnitMenu:
        lda     #$00                    ; 5FA6  0
        sta     uiLevel                 ; 5FA8  uiLevel 0 = back to browsing the map
        lda     #$FF                    ; 5FAB  $FF
        sta     infoPanelUnit           ; 5FAD  no unit owns the info panel any more
        rts                             ; 5FB0  return to the battlefield main loop


; ----------------------------------------------------------------------
; addMenuItem - Appends item id X to menuItemIdList[menuItemCount++] and prints the item's name on the
; current text line. Ids 0-11 come from the strings at $C9D2, ids 12-18 from those at $CAB2; both are
; printed with printTableEntry, which strides 8 bytes, so the first group is indexed with 2*id (its
; strings are padded with leading blanks onto a 16-byte grid). Every string ends with a CR, so the
; cursor drops to the next row by itself. Ids 1 (MOVE TO GOAL) and 9 (SELF DESTRCT) exist in the
; string table but are never passed to this routine in this build.
; In:  X = item id (0 FORMATION, 1 MOVE TO GOAL, 2 CLOAKING ON, 3 NO CLOAKING, 4 CLEAR TARGET,
;      5 SET TARGET, 6 MEMBERSHIP, 7 JOIN GROUP, 8 DIRECT UNIT, 9 SELF DESTRCT, 10 SET BLITZ,
;      11 BLITZ OFF, 12 (EXIT), 13 ENERGY, 14 BATTLE, 15 RADAR, 16 DRONE, 17 DIG OUT, 18 DIG IN);
;      menuItemCount $91D4 and the text cursor
; Out: menuItemIdList[n] = id, menuItemCount incremented, one line printed; A/X/Y clobbered
; Called from: runUnitOptionsMenu ($5EBF, $5EE8, $5EF5, $5F0B, $5F10), runGroupOptionsMenu ($6268,
;      $628E, $6293, $6298, $629D, $62A2) and runRepairWhichMenu ($6568, $6575)
; ----------------------------------------------------------------------
addMenuItem:
        txa                             ; 5FB1  item id into A
        ldy     menuItemCount           ; 5FB2  next free slot of the item list
        sta     menuItemIdList,y        ; 5FB5  remember which id sits on which row
        inc     menuItemCount           ; 5FB8  one more item
        sec                             ; 5FBB  prepare an exact subtraction
        sbc     #$0C                    ; 5FBC  ids 12-18 come from the second string table
        bcc     L_5FC7                  ; 5FBE  ids 0-11: the 16-byte-strided UNIT/GROUP OPTIONS strings
        tax                             ; 5FC0  X = id - 12
        lda     #$B2                    ; 5FC1  $CAB2 = ' (EXIT)', base of the 8-byte console strings
        ldy     #$CA                    ; 5FC3  high byte
        bne     L_5FCE                  ; 5FC5  always taken
L_5FC7:
        txa                             ; 5FC7  id 0-11 back into A
        asl     a                       ; 5FC8  double it: printTableEntry strides 8 bytes but these strings sit on a 16-byte grid
        tax                             ; 5FC9  index for printTableEntry
        lda     #$D2                    ; 5FCA  $C9D2 = 'FORMATION', base of the option strings
        ldy     #$C9                    ; 5FCC  high byte
L_5FCE:
        jmp     printTableEntry         ; 5FCE  print the entry at base + 8*X; each string ends with CR so the cursor drops to the next row


; ----------------------------------------------------------------------
; runMenuLoopWithFlag - Alternative entry for the console and setup menus: with C = 1 it behaves
; exactly like runMenuLoop; with C = 0 it puts $FF into the slow-down row operand menuSlowRowTest+1 so
; there is no extra pause before the last item, and leaves the unit-idle watch operand untouched.
; In:  C flag, menu state menuItemCount/menuSelectedItem/menuFirstRow
; Out: as runMenuLoopWatchUnit
; Called from: runMainMenu ($1C59) and overlay A ($7F40, $835D, $83CE)
; ----------------------------------------------------------------------
runMenuLoopWithFlag:
        bcs     runMenuLoop             ; 5FD1  C = 1: behave exactly like runMenuLoop
        ldx     #$FF                    ; 5FD3  C = 0: $FF disables the extra pause before the last row
        bmi     L_5FE1                  ; 5FD5  always taken; this path skips the store at $5FD9, so menuIdleSnapshot+1 keeps whatever the previous menu left there (harmless: these callers use the pseudo-units $F0/$FE/$FF, for which the watch is skipped anyway)

; ----------------------------------------------------------------------
; runMenuLoop - Menu selector without the unit-idle watch: patches menuIdleSnapshot+1 ($602A) to 0 and
; falls into runMenuLoopWatchUnit, which then sets the slow-down row to itemCount-2.
; In:  menu state menuItemCount/menuSelectedItem/menuFirstRow, selectedUnit $90F8, menuLoopMode $612E
; Out: A = the selected item id, Y = the selected row
; Called from: runJoinGroupMenu ($61BC), runRepairWhichMenu ($6581), runInGameOptionsMenu ($65A9),
;      runFormationOptionsMenu ($661D) and runMenuLoopWithFlag ($5FD1)
; ----------------------------------------------------------------------
runMenuLoop:
        lda     #$00                    ; 5FD7  0
        sta     menuIdleSnapshot+1      ; 5FD9  patch menuIdleSnapshot+1 ($602A): 0 turns the unit-idle watch off

; ----------------------------------------------------------------------
; runMenuLoopWatchUnit - The generic vertical menu selector used by every in-game option menu. It arms
; a 20-frame input lockout, highlights the current row, and then loops once per frame: it stirs the
; RNG, runs the update that suits the phase (updateGameFrame during a battle, the '*' / F1 hot keys
; plus serviceCommandExchange in the setup and film phases), keeps the info panel on the selected
; unit, blinks the selected row every 20 frames, updates the map feedback chosen by menuLoopMode (path
; marker, or a live preview of the group the current row stands for) and reads the joystick: up/down
; step through the rows without wrap-around, fire confirms. It returns early with A = Y = $80 when the
; watched unit's idle flag ($FB54 bit 7) no longer matches the snapshot in menuIdleSnapshot+1, and it
; forces the last item ((EXIT)) when the unit dies, the game-over countdown starts or checkUiContinue
; asks for an abort.
; In:  menuItemCount $91D4, menuSelectedItem $91D5, menuFirstRow $91D6, menuLoopMode $612E,
;      selectedUnit $90F8, joyDirection zp_5D, joyRepeatTimer zp_6C, menuBlinkTimer zp_6E, gamePhase
;      zp_B1, gameEndCountdown $92C9, gameEndReason $0BA8 and the patched operands
;      menuIdleSnapshot+1 / menuSlowRowTest+1
; Out: A = menuItemIdList[menuSelectedItem], Y = menuSelectedItem (or A = Y = $80 meaning 'rebuild the
;      menu'), menuSelectedItem updated, selectedGroupKey/highlightedGroupKey updated in group-preview
;      mode, menuBlinkState, inputLockoutTimer $0B7D, joyRepeatTimer, menuMoveDirection+1 patched; the
;      selected row is left highlighted
; Called from: runUnitOptionsMenu ($5F1B), runGroupOptionsMenu ($62AA) and through runMenuLoop
; ----------------------------------------------------------------------
runMenuLoopWatchUnit:
        ldx     menuItemCount           ; 5FDC  number of rows
        dex                             ; 5FDF  ...
        dex                             ; 5FE0  count - 2 = the row just before the last one
L_5FE1:
        stx     menuSlowRowTest+1       ; 5FE1  patch the CMP #imm at menuSlowRowTest ($60BA): landing on this row adds a pause
        lda     #$14                    ; 5FE4  20 frames
        sta     inputLockoutTimer       ; 5FE6  ignore held-over input for 20 frames...
        sta     joyRepeatTimer          ; 5FE9  ...and do not step the selection for 20 frames
        lda     #$01                    ; 5FEB  1
        sta     menuBlinkState          ; 5FED  menuBlinkState 1 = the selected row is currently shown normally
L_5FF0:
        jsr     toggleMenuRowHighlight  ; 5FF0  highlight the selected row and restart the 20-frame blink timer

; ----------------------------------------------------------------------
; One pass per frame while the menu is open: keep the world running, watch the unit, update the map
; feedback, blink the selected row and read the joystick.
; ----------------------------------------------------------------------
menuLoopFrame:
        jsr     nextGameRandom          ; 5FF3  stir the 24-bit game RNG once per pass
        lda     gamePhase               ; 5FF6  gamePhase zp_B1
        cmp     #$FF                    ; 5FF8  $FF = the pre-battle setup phase (overlay A resident)
        beq     L_6002                  ; 5FFA  yes: poll overlay A's hot keys
        and     #$C0                    ; 5FFC  bits 6/7 flag the non-battle phases
        beq     L_6013                  ; 5FFE  both clear: a battle is running - do the full per-frame update
        bne     L_600D                  ; 6000  otherwise only service the opponent link
L_6002:
        jsr     handleStarKeyToggle     ; 6002  setup phase: overlay A's '*' hot key (DESTROY-mode toggle)
        lda     isSoloTrainer           ; 6005  $0BA5 bit 7 = solo trainer game
        bpl     L_600D                  ; 6008  modem game: no F1 handler
        jsr     handleDisplayToggleKey  ; 600A  trainer only: F1 switches the BEGINNER/STANDARD display  (also trainerSetupEC00:storeGroupCentreRow)
L_600D:
        jsr     serviceCommandExchange  ; 600D  run one step of the lock-step command exchange
        jmp     L_6068                  ; 6010  skip the battle-only bookkeeping


; ----------------------------------------------------------------------
; Battle phase: run one game frame, then check that the watched unit is still alive and still in the
; same movement state.
; ----------------------------------------------------------------------
L_6013:
        lda     selectedUnit            ; 6013  the selected unit...
        sta     infoPanelUnit           ; 6016  ...keeps owning the info panel while the menu is open
        jsr     updateGameFrame         ; 6019  one frame of the battle simulation and its display
        bit     gamePhase               ; 601C  gamePhase bit 6 = paused / waiting for the opponent
        bvs     L_6068                  ; 601E  paused: do not touch the unit or the map highlight
        ldx     selectedUnit            ; 6020  selected unit
        bmi     L_6068                  ; 6023  $F0/$FE/$FF are console pseudo-units, not real units
        lda     unitTypeTable,x         ; 6025  type byte, bit 7 = destroyed
        bmi     menuLoopExitLast        ; 6028  the unit died under the menu: close it on the last item
menuIdleSnapshot:
        lda     #$FF                    ; 602A  operand patched at $5ECA/$6273 with the unit's $FB54 flags (or with 0 by runMenuLoop = watch disabled)
        beq     L_6037                  ; 602C  0: the caller does not want the unit watched
        eor     unitDisplayFlagsTable,x ; 602E  compare the snapshot with the live flags
        and     #$80                    ; 6031  only the 'standing still' bit matters
        beq     L_6037                  ; 6033  unchanged: carry on
        tay                             ; 6035  A = Y = $80 tells the caller to rebuild the menu
        rts                             ; 6036  early return with the rebuild code


; ----------------------------------------------------------------------
; Map feedback while the menu is open: either animate the selected unit's path marker or, in the
; join-group picker, highlight the group that the current row stands for.
; ----------------------------------------------------------------------
L_6037:
        lda     menuLoopMode            ; 6037  $FF unit menu, $01 group menu, $00/$20 join-group picker (side bit)
        bmi     L_6065                  ; 603A  unit menu: just animate the path marker
        lda     menuLoopMode            ; 603C  re-read the mode
        and     #$01                    ; 603F  odd = group menu
        bne     L_6065                  ; 6041  group menu: also just animate the path marker
        lda     menuSelectedItem        ; 6043  join-group picker: the selected row is the group number
        cmp     #$08                    ; 6046  never true - the list has 8 rows (0-7); probably an off-by-one, the (EXIT) row looks like it was meant to clear the highlight
        bne     L_604F                  ; 6048  always taken
        lda     #$FF                    ; 604A  would mean 'no group highlighted'
        jmp     L_6057                  ; 604C  unreachable in this build

L_604F:
        asl     a                       ; 604F  group number...
        asl     a                       ; 6050  ...times 4 = the group-id field of $FB54
        ora     menuLoopMode            ; 6051  add the player's side bit ($20 for units 50-99)
        sta     selectedGroupKey        ; 6054  the group the highlight is previewing
L_6057:
        cmp     highlightedGroupKey     ; 6057  is the viewport already showing that group?
        beq     L_6068                  ; 605A  yes: nothing to redraw
        sta     highlightedGroupKey     ; 605C  remember the highlighted group
        jsr     drawViewport            ; 605F  repaint the viewport with the new group highlighted
        jmp     L_6068                  ; 6062  on to the blink handling

L_6065:
        jsr     animatePathMarkerToDestination; 6065  walk the mini-map path marker one step along the unit's route

; ----------------------------------------------------------------------
; Blink the selected row, then decide whether the menu has to close (game over, or an abort request
; from checkUiContinue).
; ----------------------------------------------------------------------
L_6068:
        lda     menuBlinkTimer          ; 6068  zp_6E, counted down by the IRQ
        bne     L_606F                  ; 606A  not expired yet
        jsr     toggleMenuRowHighlight  ; 606C  every 20 frames: flip the highlight of the selected row
L_606F:
        lda     gameEndCountdown        ; 606F  $92C9 counts down while the game-over sequence runs
        bne     menuLoopExitLast        ; 6072  game over: close the menu on the last item
        lda     gameEndReason           ; 6074  $0BA8 is nonzero once the game has ended
        bne     L_6088                  ; 6077  after the game the abort test is skipped so the console menus stay usable
        jsr     checkUiContinue         ; 6079  returns 0 when SPACE, a screen change or a pause wants this menu closed
        bne     L_6088                  ; 607C  keep going
menuLoopExitLast:
        ldy     menuItemCount           ; 607E  number of items
        dey                             ; 6081  index of the last one
        sty     menuSelectedItem        ; 6082  force the selection onto the last item, normally (EXIT)
        jmp     menuLoopReturnSelection ; 6085  return that selection


; ----------------------------------------------------------------------
; Joystick handling: fire confirms the selection; up/down step through the rows without wrap-around,
; with the usual 16/6-frame auto-repeat plus an extra pause before the last row.
; ----------------------------------------------------------------------
L_6088:
        lda     joyDirection            ; 6088  joystick nibble, active low
        cmp     #$0F                    ; 608A  centred?
        bne     L_6093                  ; 608C  deflected: try to move the selection
        jsr     checkFirePressed        ; 608E  Z = 1 on a fresh fire-button press
        beq     menuLoopReturnSelection ; 6091  fire: confirm the current selection
L_6093:
        ldy     joyRepeatTimer          ; 6093  auto-repeat delay still running?
        bne     L_60B7                  ; 6095  yes: nothing to do this frame
        lda     joyDirection            ; 6097  joystick nibble
        ora     #$0C                    ; 6099  force the left/right bits high: only up and down move the selection
        ldy     #$FF                    ; 609B  -1 = one row up
        cmp     #$0E                    ; 609D  $0E = bit 0 low = up
        beq     L_60A7                  ; 609F  up
        ldy     #$01                    ; 60A1  +1 = one row down
        cmp     #$0D                    ; 60A3  $0D = bit 1 low = down
        bne     L_60B7                  ; 60A5  left/right only: ignore
L_60A7:
        tya                             ; 60A7  the step (-1 or +1)
        sty     menuMoveDirection+1     ; 60A8  patch the LDY #imm at menuMoveDirection ($60BE) with the direction
        clc                             ; 60AB  clear carry for the add
        adc     menuSelectedItem        ; 60AC  candidate row = current + step
        jsr     setJoystickRepeatDelay  ; 60AF  16 frames before the first repeat, 6 between repeats (A is preserved)
        cmp     menuItemCount           ; 60B2  past the last row (or $FF above the first)?
        bcc     menuSlowRowTest         ; 60B5  in range
L_60B7:
        jmp     menuLoopFrame           ; 60B7  out of range - the selection does not wrap: just keep looping

menuSlowRowTest:
        cmp     #$FF                    ; 60BA  operand = itemCount-2, or $FF when runMenuLoopWithFlag turned the pause off
        bne     L_60C6                  ; 60BC  not the slow-down row
menuMoveDirection:
        ldy     #$FF                    ; 60BE  operand = the move direction stored at $60A8
        bmi     L_60C6                  ; 60C0  moving up: no extra delay
        ldy     #$1E                    ; 60C2  30 frames
        sty     joyRepeatTimer          ; 60C4  moving down onto the second-to-last row: pause so the stick cannot run straight into (EXIT)
L_60C6:
        pha                             ; 60C6  keep the new row
        jsr     unhighlightMenuRow      ; 60C7  restore the colours of the row being left
        pla                             ; 60CA  new row back
        sta     menuSelectedItem        ; 60CB  it is the selection now
        lda     #$02                    ; 60CE  sound 2 = menu-move click
        jsr     playSound               ; 60D0  play it
        jmp     L_5FF0                  ; 60D3  highlight the new row and loop

menuLoopReturnSelection:
        lda     menuBlinkState          ; 60D6  0 = the row is already highlighted
        beq     L_60DE                  ; 60D9  nothing to do
        jsr     toggleMenuRowHighlight  ; 60DB  leave the chosen row highlighted
L_60DE:
        ldy     menuSelectedItem        ; 60DE  the chosen row
        lda     menuItemIdList,y        ; 60E1  A = its item id; Y still holds the row index
        rts                             ; 60E4  return to the menu's dispatcher


; ----------------------------------------------------------------------
; unhighlightMenuRow - Removes the highlight from the selected menu row, but only if it is currently
; applied (menuBlinkState = 0); otherwise it returns at once. Falls into toggleMenuRowHighlight.
; In:  menuBlinkState $92C5, menuSelectedItem, menuFirstRow, textWindowWidth zp_3C, textColour zp_3D
; Out: the row's colour bytes restored, menuBlinkState = 1
; Called from: the menu loop ($60C7), runRepairWhichMenu ($6593), runInGameOptionsMenu ($65DD, $65E3)
;      and overlay A ($845C)
; ----------------------------------------------------------------------
unhighlightMenuRow:
        lda     menuBlinkState          ; 60E5  0 = the row is currently EORed (highlighted)
        bne     L_612D                  ; 60E8  already normal: nothing to undo

; ----------------------------------------------------------------------
; toggleMenuRowHighlight - Flips the highlight of menu row menuFirstRow + menuSelectedItem by EORing
; the colour byte of every cell of that row with textColour, flips menuBlinkState and restarts the
; 20-frame blink timer. Special case for the REPAIR WHICH menu (selectedUnit = $FF, row < 4): if the
; comcen is stunned it throws its return address away and jumps into the menu loop's exit path so the
; menu closes on (EXIT); otherwise it also blinks that comcen system's marker on the schematic.
; In:  menuSelectedItem, menuFirstRow, textWindowWidth zp_3C, textColour zp_3D, selectedUnit $90F8,
;      comcenStunStatus $922A, menuBlinkState $92C5
; Out: menuBlinkTimer zp_6E = 20, the row's colour bytes EORed, menuBlinkState flipped, screenPtr
;      zp_B6/zp_B7 and the text cursor moved
; Called from: the menu loop ($5FF0, $606C, $60DB), unhighlightMenuRow and overlay A ($8066, $847B)
; ----------------------------------------------------------------------
toggleMenuRowHighlight:
        lda     #$14                    ; 60EA  20 frames
        sta     menuBlinkTimer          ; 60EC  restart the blink countdown
        lda     selectedUnit            ; 60EE  selected unit, or a console pseudo-unit
        bpl     L_610B                  ; 60F1  a real unit: no schematic marker to blink
        cmp     #$FF                    ; 60F3  $FF marks the REPAIR WHICH menu ($F0 = main menu, $FE = in-game options)
        bne     L_610B                  ; 60F5  some other menu
        ldy     menuSelectedItem        ; 60F7  selected row
        cpy     #$04                    ; 60FA  rows 0-3 are the four comcen systems
        bcs     L_610B                  ; 60FC  the (EXIT) row has no schematic marker
        lda     comcenStunStatus        ; 60FE  $922A bit 7 = the comcen is knocked out
        bpl     L_6108                  ; 6101  comcen alive: blink its system marker as well
        pla                             ; 6103  comcen stunned: discard the return address...
        pla                             ; 6104  ...of the menu-loop caller...
        jmp     menuLoopExitLast        ; 6105  ...and force the loop to close on the last item (only valid when called from runMenuLoopWatchUnit)

L_6108:
        jsr     toggleSystemMarker      ; 6108  blink the marker of the selected comcen system on the schematic

; ----------------------------------------------------------------------
; EOR the colour byte of every cell of the selected row. In multicolour bitmap mode the text engine's
; screenPtr points into colour RAM $D800, which supplies the colour of the glyph pixels, so this makes
; the row alternate between its text colour and black.
; ----------------------------------------------------------------------
L_610B:
        lda     menuFirstRow            ; 610B  first row of the item list
        clc                             ; 610E  clear carry
        adc     menuSelectedItem        ; 610F  + selection = screen row of the selected item
        ldy     #$00                    ; 6112  0
        sty     textCursorCol           ; 6114  start at the left edge of the text window
        jsr     setCursorRow            ; 6116  set the row and recompute screenPtr (which points into colour RAM $D800 in multicolour mode); X and Y are preserved
        ldy     textWindowWidth         ; 6119  window width in characters
        dey                             ; 611B  index of the last cell
L_611C:
        lda     (screenPtr),y           ; 611C  colour byte of this cell
        eor     textColour              ; 611E  EOR with the text colour: a cell holding textColour turns black, and back again on the next call
        sta     (screenPtr),y           ; 6120  write it back
        dey                             ; 6122  previous cell
        bpl     L_611C                  ; 6123  all cells of the row
        lda     menuBlinkState          ; 6125  flip the state flag...
        eor     #$01                    ; 6128  ...between 1 (normal) and 0 (EORed)
        sta     menuBlinkState          ; 612A  store it
L_612D:
        rts                             ; 612D  done


; menuLoopMode: byteTable, 1 bytes. Variable byte embedded in code: map-highlight mode of the menu
; loop ($FF unit menu, $01 group menu, $00/$20 join-group picker side bit).
menuLoopMode:
        .byte   $00                     ; 612E  .  map-highlight mode of the menu loop: $FF unit menu (animate the path marker), $01 group menu, $00/$20 join-group picker (the value is the player's side bit)

; ----------------------------------------------------------------------
; runJoinGroupMenu - The JOIN GROUP picker. Lists the seven groups ALPHA..GROVER with their current
; member counts plus an (EXIT) row from screen row 13, preselecting the group the unit is already in.
; The menu loop runs in group-preview mode, so moving the selection highlights that group on the
; viewport. Picking a group calls addSelectedUnitToGroup, which queues command $88; if the unit is too
; far from the group (C = 1, TOO FAR! shown) the list is redrawn and the question repeated.
; In:  selectedUnit $90F8, unitDisplayFlagsTable $FB54 (group number bits 2-4, side bit 5)
; Out: uiLevel = 2, menuItemCount = 8, menuFirstRow = 13, menuLoopMode = the unit's side bit,
;      selectedGroupKey = the previewed/chosen group, command $88 unit,group queued; $9207 = $FF
; (dead)
; Called from: the UNIT OPTIONS item JOIN GROUP ($5F6E)
; ----------------------------------------------------------------------
runJoinGroupMenu:
        lda     #$02                    ; 612F  UI level 2
        sta     uiLevel                 ; 6131  uiLevel 2 = group (info-panel colour 4)
        ldy     selectedUnit            ; 6134  the unit that wants to join
        lda     unitDisplayFlagsTable,y ; 6137  $FB54 flags
        and     #$1C                    ; 613A  group number in bits 2-4
        lsr     a                       ; 613C  shift it down...
        lsr     a                       ; 613D  ...to 0-6
        tax                             ; 613E  into X
        stx     menuSelectedItem        ; 613F  preselect the group the unit currently belongs to

; ----------------------------------------------------------------------
; (Re)draw the picker: heading, one row per group with its name and member count, then the (EXIT) row.
; ----------------------------------------------------------------------
drawJoinGroupList:
        lda     #$00                    ; 6142  0
        sta     rawGlyphMode            ; 6144  ASCII translation on
        sta     textCentreWidth         ; 6146  no automatic centring
        sta     textFillGlyph           ; 6149  blank with glyph 0
        lda     #$04                    ; 614B  colour 4 = the group-level text colour
        sta     textColour              ; 614D  ink
        sta     textPadColour           ; 614F  pad
        jsr     clearInfoPanel          ; 6151  clear the info panel
        lda     #$25                    ; 6154  $CB25 = 'WHICH GROUP:'
        ldy     #$CB                    ; 6156  high byte
        jsr     printString             ; 6158  print the heading
        jsr     printNewline            ; 615B  next row
        lda     #$00                    ; 615E  0
        sta     cellColA                ; 6160  zp_22 is reused here as the group counter 0-6

; ----------------------------------------------------------------------
; One row per group 0-6: the name from the 8-byte table at $C92C, a space, then the number of living
; members of that group on the clicking unit's side, right-aligned to two columns.
; ----------------------------------------------------------------------
L_6162:
        lda     #$2C                    ; 6162  $C92C = the seven 8-byte group names ALPHA..GROVER
        ldy     #$C9                    ; 6164  high byte
        ldx     cellColA                ; 6166  group number
        jsr     printTableEntry         ; 6168  print the name at $C92C + 8*group
        lda     #$A0                    ; 616B  $A0 = space
        jsr     printChar               ; 616D  separate the name from the count
        lda     cellColA                ; 6170  group number...
        asl     a                       ; 6172  ...times 4...
        asl     a                       ; 6173  ...= the group-id field of the flag byte
        ldy     selectedUnit            ; 6174  the joining unit
        cpy     #$32                    ; 6177  index < 50 = one of player 0's units (a fixed test: the side comes from the index, not from who is playing)
        bcc     L_617D                  ; 6179  side 0: leave bit 5 clear
        ora     #$20                    ; 617B  side 1: set bit 5 of the group key
L_617D:
        jsr     countGroupMembers       ; 617D  A = number of living members of that group
        cmp     #$0A                    ; 6180  two digits?
        bcs     L_618B                  ; 6182  yes: no padding
        pha                             ; 6184  keep the count
        lda     #$A0                    ; 6185  $A0 = space
        jsr     printChar               ; 6187  right-align single-digit counts
        pla                             ; 618A  count back
L_618B:
        ldy     #$00                    ; 618B  high byte of the value to print
        jsr     printDecimal            ; 618D  print the member count
        jsr     printNewline            ; 6190  next row
        inc     cellColA                ; 6193  next group
        lda     cellColA                ; 6195  ...
        cmp     #$07                    ; 6197  seven groups
        bcc     L_6162                  ; 6199  loop
        lda     #$B2                    ; 619B  $CAB2 = ' (EXIT)' as the eighth row
        ldy     #$CA                    ; 619D  high byte
        jsr     printString             ; 619F  print it
        lda     #$0D                    ; 61A2  screen row 13
        sta     menuFirstRow            ; 61A4  the list starts on row 13 of the info panel
        lda     #$08                    ; 61A7  8
        sta     menuItemCount           ; 61A9  7 groups + (EXIT)
        lda     #$FF                    ; 61AC  $FF
        sta     unusedFlag9207          ; 61AE  $9207 is written here and at $639B but never read anywhere - dead store
        ldx     selectedUnit            ; 61B1  the joining unit
        lda     unitDisplayFlagsTable,x ; 61B4  $FB54 flags
        and     #$20                    ; 61B7  keep only the side bit
        sta     menuLoopMode            ; 61B9  menuLoopMode 0/$20: the loop previews the group the current row stands for
        jsr     runMenuLoop             ; 61BC  run the selector without the unit-idle watch
        lda     menuSelectedItem        ; 61BF  the selected row = the group number (0-6), 7 = (EXIT)
        cmp     #$07                    ; 61C2  (EXIT)?
        bcs     L_61CE                  ; 61C4  yes: nothing to do
        jsr     addSelectedUnitToGroup  ; 61C6  queue $88 unit,group; C = 1 when the unit is too far from the group
        bcc     L_61CE                  ; 61C9  joined
        jmp     drawJoinGroupList       ; 61CB  TOO FAR! was shown: redraw the list and ask again

L_61CE:
        rts                             ; 61CE  back to the unit menu


; ----------------------------------------------------------------------
; runGroupOptionsMenu - Group branch of handleOwnUnitClick. It takes the group key from the clicked
; unit's flags, highlights every member, prints the GROUP + name header and waits: a stick deflection
; sends the whole group SETTING DESTINATION, a click opens the GROUP OPTIONS menu (only when the UNIT
; MENUS option $0BA3 bit 3 is on) offering CLOAKING, DIG or BLITZ, MEMBERSHIP, FORMATION, DIRECT UNIT
; and (EXIT). The chosen item either queues a group command ($83/$85/$94/$96/$A1/$A2, argument = the
; group key), opens the group editor or the formation sub-menu, or hands the unit to
; runUnitOptionsMenu.
; In:  selectedUnit $90F8, unit arrays $F898/$F8FC/$F960/$FB54, joyDirection zp_5D, gameTypeOptions
;      $0BA3 bit 3
; Out: selectedGroupKey/highlightedGroupKey = group key, highlightedUnit = $FF, uiLevel = 2,
;      menuLoopMode = 1, cmdParam0 = group key with one group command queued, cursorTargetMode zp_BB
;      set for the editor; ends with clearGroupHighlight and exitUnitMenu
; Called from: handleOwnUnitClick ($5E21)
; ----------------------------------------------------------------------
runGroupOptionsMenu:
        lda     #$00                    ; 61CF  0
        sta     rawGlyphMode            ; 61D1  ASCII translation on
        sta     textCentreWidth         ; 61D3  no automatic centring
        sta     textFillGlyph           ; 61D6  blank with glyph 0
        ldy     selectedUnit            ; 61D8  the clicked unit
        lda     unitDisplayFlagsTable,y ; 61DB  $FB54 flags
        and     #$3C                    ; 61DE  group key = group number (bits 2-4) plus the side bit 5
        sta     selectedGroupKey        ; 61E0  this is the group being commanded
        sta     highlightedGroupKey     ; 61E3  and the group the viewport highlights
        lda     #$01                    ; 61E6  1
        sta     menuLoopMode            ; 61E8  menuLoopMode 1 = animate the path marker, do not preview groups
        lda     #$FF                    ; 61EB  $FF
        sta     highlightedUnit         ; 61ED  no single-unit highlight - the whole group is marked instead
        lda     #$02                    ; 61F0  2
        sta     uiLevel                 ; 61F2  uiLevel 2 = group
        jsr     setCursorShapeSquare    ; 61F5  square cell cursor
        jsr     drawViewport            ; 61F8  repaint the viewport with the group highlighted
        lda     #$04                    ; 61FB  colour 4 = group level
        sta     textColour              ; 61FD  ink
        sta     textPadColour           ; 61FF  pad
        jsr     clearInfoPanel          ; 6201  clear the info panel
        lda     #$0F                    ; 6204  screen row 15
        sta     textCursorRow           ; 6206  heading row (printGroupTitle sets the column)
        jsr     printGroupTitleAndName  ; 6208  print 'GROUP' and the group's name

; ----------------------------------------------------------------------
; Wait for the player: a stick deflection means 'move the whole group', a click opens the GROUP
; OPTIONS menu. The path marker keeps animating meanwhile.
; ----------------------------------------------------------------------
waitForGroupClick:
        lda     selectedUnit            ; 620B  the clicked unit...
        sta     infoPanelUnit           ; 620E  ...keeps the info panel
        jsr     updateGameFrame         ; 6211  one frame of the battle
        jsr     animatePathMarkerToDestination; 6214  walk the path marker along the group's route
        lda     joyDirection            ; 6217  joystick nibble
        cmp     #$0F                    ; 6219  centred?
        beq     L_6226                  ; 621B  centred: look for a click
        jsr     waitForGroupRepositionsDone; 621D  deflected: first let any pending re-position orders be applied
        jsr     promptForDestination    ; 6220  then run SETTING / DESTINATION for the whole group (uiLevel 2 makes it command $81)
        jmp     groupMenuDone           ; 6223  done

L_6226:
        jsr     checkFirePressed        ; 6226  fresh fire-button press?
        beq     L_6233                  ; 6229  clicked: open the GROUP OPTIONS menu
        jsr     checkUiContinue         ; 622B  may this UI keep running?
        bne     waitForGroupClick       ; 622E  yes: keep waiting
L_6230:
        jmp     groupMenuDone           ; 6230  aborted

L_6233:
        lda     gameTypeOptions         ; 6233  game type and option bits
        and     #$08                    ; 6236  bit 3 = the UNIT MENUS option
        beq     L_6230                  ; 6238  option off: no group menu in this scenario
        lda     #$00                    ; 623A  0
        sta     menuSelectedItem        ; 623C  start on the first row

; ----------------------------------------------------------------------
; Build the GROUP OPTIONS list. Re-entered from $62B1 when the clicked unit's idle flag changed. Same
; first two items as the unit menu, then the group-only items.
; ----------------------------------------------------------------------
buildGroupOptionsMenu:
        lda     #$04                    ; 623F  colour 4
        sta     textColour              ; 6241  ink
        sta     textPadColour           ; 6243  pad
        jsr     clearInfoPanel          ; 6245  clear the info panel
        jsr     printGroupTitle         ; 6248  print 'GROUP' at column 4
        lda     #$13                    ; 624B  $CB13 = '   OPTIONS' + CR + CR
        ldy     #$CB                    ; 624D  high byte
        jsr     printString             ; 624F  print the second heading line
        ldy     selectedUnit            ; 6252  leftover from the unit-menu version: Y is reloaded at $625A (dead)
        ldx     #$00                    ; 6255  0
        stx     menuItemCount           ; 6257  empty item list
        ldy     selectedUnit            ; 625A  the clicked unit stands for the whole group
        ldx     #$02                    ; 625D  item id 2 = CLOAKING ON
        lda     unitPathColTable,y      ; 625F  $F898 bit 6 = 'cloaking ordered'
        and     #$40                    ; 6262  test it
        beq     L_6268                  ; 6264  not cloaked: CLOAKING ON
        ldx     #$03                    ; 6266  item id 3 = NO CLOAKING
L_6268:
        jsr     addMenuItem             ; 6268  append it
        ldy     selectedUnit            ; 626B  unit index
        lda     unitDisplayFlagsTable,y ; 626E  $FB54 flags
        ora     #$01                    ; 6271  keep the snapshot nonzero
        sta     menuIdleSnapshot+1      ; 6273  patch menuIdleSnapshot+1 ($602A) exactly as the unit menu does
        bpl     L_6284                  ; 6276  bit 7 clear = moving: offer the blitz items
        ldx     #$11                    ; 6278  item id 17 = DIG OUT
        lda     unitStunTable,y         ; 627A  $F960 bits 5-7 = dug in / digging
        and     #$E0                    ; 627D  isolate them
        bne     L_628E                  ; 627F  already digging: DIG OUT
        inx                             ; 6281  item id 18 = DIG IN
        bne     L_628E                  ; 6282  always taken
L_6284:
        ldx     #$0A                    ; 6284  item id 10 = SET BLITZ
        lda     unitPathRowTable,y      ; 6286  $F8FC bit 6 = 'blitz ordered'
        and     #$40                    ; 6289  test it
        beq     L_628E                  ; 628B  blitz off: SET BLITZ
        inx                             ; 628D  item id 11 = BLITZ OFF
L_628E:
        jsr     addMenuItem             ; 628E  append the dig/blitz item
        ldx     #$06                    ; 6291  item id 6 = MEMBERSHIP
        jsr     addMenuItem             ; 6293  append it
        ldx     #$00                    ; 6296  item id 0 = FORMATION
        jsr     addMenuItem             ; 6298  append it
        ldx     #$08                    ; 629B  item id 8 = DIRECT UNIT
        jsr     addMenuItem             ; 629D  append it
        ldx     #$0C                    ; 62A0  item id 12 = (EXIT)
        jsr     addMenuItem             ; 62A2  append it
        lda     #$0F                    ; 62A5  screen row 15
        sta     menuFirstRow            ; 62A7  the list starts on row 15
        jsr     runMenuLoopWatchUnit    ; 62AA  run the selector: A = item id, Y = row (or $80)
        cpy     #$80                    ; 62AD  $80 = the unit's idle flag changed
        bne     L_62B4                  ; 62AF  no: act on the item
        jmp     buildGroupOptionsMenu   ; 62B1  yes: rebuild (DIG and BLITZ swap places)


; ----------------------------------------------------------------------
; Dispatch on the chosen item id. All the command branches end at $62D7, which supplies the group key
; as the command's argument.
; ----------------------------------------------------------------------
L_62B4:
        sta     menuSelectedItem        ; 62B4  keep the chosen item id
        ldy     selectedUnit            ; 62B7  dead load, as in the unit menu
        lda     menuSelectedItem        ; 62BA  the chosen item id
        cmp     #$02                    ; 62BD  CLOAKING ON?
        bne     L_62C5                  ; 62BF  no
        ldy     #$83                    ; 62C1  command $83 = cloak on (2 bytes: group key)
        bne     L_62CB                  ; 62C3  always taken
L_62C5:
        cmp     #$03                    ; 62C5  NO CLOAKING?
        bne     L_62E4                  ; 62C7  no
        ldy     #$85                    ; 62C9  command $85 = cloak off (2 bytes: group key)
L_62CB:
        ldx     selectedUnit            ; 62CB  the clicked unit
        lda     unitPathColTable,x      ; 62CE  flip only its local cloak bit...
        eor     #$40                    ; 62D1  ...bit 6 of $F898...
        sta     unitPathColTable,x      ; 62D3  ...so the menu shows the new state at once (the handler does the whole group)
        tya                             ; 62D6  command byte back into A
queueGroupCommand:
        pha                             ; 62D7  keep the command byte
        lda     selectedGroupKey        ; 62D8  the group key...
        sta     cmdParam0               ; 62DB  ...is the single argument of every group command
        pla                             ; 62DD  command byte back
        jsr     queueCmd2               ; 62DE  queue 'command, group key'
        jmp     groupMenuDone           ; 62E1  done

L_62E4:
        cmp     #$06                    ; 62E4  MEMBERSHIP?
        bne     L_62F2                  ; 62E6  no
        lda     #$00                    ; 62E8  0
        sta     cursorTargetMode        ; 62EA  zp_BB >= 0 selects membership mode
L_62EC:
        jsr     runGroupEditor          ; 62EC  run the group editor
        jmp     groupMenuDone           ; 62EF  done

L_62F2:
        cmp     #$08                    ; 62F2  DIRECT UNIT?
        bne     L_62F9                  ; 62F4  no
        jmp     runUnitOptionsMenu      ; 62F6  hand the clicked unit to the single-unit menu (which ends in exitUnitMenu)

L_62F9:
        cmp     #$00                    ; 62F9  FORMATION? (item id 0)
        bne     L_630A                  ; 62FB  no
        jsr     runFormationOptionsMenu ; 62FD  SWEEP LEFT / SWEEP RIGHT / REVERSE / CUSTOM / (EXIT); Y = the chosen row
        cpy     #$03                    ; 6300  row 3 = CUSTOM
        bne     groupMenuDone           ; 6302  the three canned formations are sent by the sub-menu itself (command $A3)
        lda     #$80                    ; 6304  $80
        sta     cursorTargetMode        ; 6306  zp_BB bit 7 selects custom-formation mode
        bmi     L_62EC                  ; 6308  always taken: run the editor in formation mode
L_630A:
        cmp     #$0A                    ; 630A  SET BLITZ?
        bne     L_6312                  ; 630C  no
        ldy     #$94                    ; 630E  command $94 = blitz on (2 bytes: group key)
        bne     L_6318                  ; 6310  always taken
L_6312:
        cmp     #$0B                    ; 6312  BLITZ OFF?
        bne     L_6326                  ; 6314  no
        ldy     #$96                    ; 6316  command $96 = blitz off (2 bytes: group key)
L_6318:
        ldx     selectedUnit            ; 6318  the clicked unit
        lda     unitPathRowTable,x      ; 631B  flip its local blitz bit...
        eor     #$40                    ; 631E  ...bit 6 of $F8FC...
        sta     unitPathRowTable,x      ; 6320  ...for instant feedback
        tya                             ; 6323  command byte back into A
        bne     queueGroupCommand       ; 6324  always taken
L_6326:
        cmp     #$11                    ; 6326  DIG OUT?
        bne     L_632E                  ; 6328  no
        lda     #$A2                    ; 632A  command $A2 = dig out (2 bytes: group key)
        bne     queueGroupCommand       ; 632C  always taken
L_632E:
        cmp     #$12                    ; 632E  DIG IN?
        bne     groupMenuDone           ; 6330  anything else, including (EXIT): just close
        lda     #$A1                    ; 6332  command $A1 = dig in (2 bytes: group key)
        bne     queueGroupCommand       ; 6334  always taken
groupMenuDone:
        jsr     clearGroupHighlight     ; 6336  drop the group highlight and repaint the viewport
        jmp     exitUnitMenu            ; 6339  uiLevel 0, no info-panel unit, return to the battlefield loop


; ----------------------------------------------------------------------
; printGroupTitleAndName - Header of the group menu: prints GROUP (printGroupTitle) and, on the next
; row at column 4, the name of group (selectedGroupKey >> 2) & 7 from the 8-byte name table at $C92C,
; followed by a newline.
; In:  selectedGroupKey $9206
; Out: two lines printed, textCursorCol zp_3F = 4
; Called from: runGroupOptionsMenu ($6208)
; ----------------------------------------------------------------------
printGroupTitleAndName:
        jsr     printGroupTitle         ; 633C  print 'GROUP' + CR at column 4
        lda     #$04                    ; 633F  column 4
        sta     textCursorCol           ; 6341  indent the name under the title
        jsr     updateCursorPointers    ; 6343  recompute the bitmap/colour pointers for the new column
        lda     selectedGroupKey        ; 6346  group key
        lsr     a                       ; 6349  shift out the two low bits...
        lsr     a                       ; 634A  ...
        and     #$07                    ; 634B  ...leaving the group number 0-6 (the side bit is dropped)
        tax                             ; 634D  index for the name table
        lda     #$2C                    ; 634E  $C92C = the seven 8-byte group names
        ldy     #$C9                    ; 6350  high byte
        jsr     printTableEntry         ; 6352  print the name at $C92C + 8*group
        jmp     printNewline            ; 6355  and move to the next row


; ----------------------------------------------------------------------
; printGroupTitle - Moves the text cursor to column 4 of the info panel and prints the string GROUP
; ($CB1F, which ends with a CR).
; In:  the current text window
; Out: one line printed, textCursorCol zp_3F = 4
; Called from: runGroupOptionsMenu ($6248) and printGroupTitleAndName ($633C)
; ----------------------------------------------------------------------
printGroupTitle:
        lda     #$04                    ; 6358  column 4
        sta     textCursorCol           ; 635A  indent the title
        jsr     updateCursorPointers    ; 635C  recompute the bitmap/colour pointers
        lda     #$1F                    ; 635F  $CB1F = 'GROUP' (ends with CR)
        ldy     #$CB                    ; 6361  high byte
        jmp     printString             ; 6363  print it and return to the caller


; ----------------------------------------------------------------------
; clearGroupHighlight - Drops the viewport's group highlight and repaints the 7x5 tactical view.
; In:  -
; Out: highlightedGroupKey $9205 = $FF, viewport redrawn
; Called from: runGroupOptionsMenu ($6336) and runGroupEditor ($64D4)
; ----------------------------------------------------------------------
clearGroupHighlight:
        lda     #$FF                    ; 6366  $FF
        sta     highlightedGroupKey     ; 6368  no group highlighted any more
        jmp     drawViewport            ; 636B  repaint the 7x5 viewport without the highlight


; ----------------------------------------------------------------------
; printSettingHeader - Header of the group editor: clears the info panel in colour 4, moves to screen
; row 15 and prints SETTING centred, followed by MEMBERSHIP ($CA32) when cursorTargetMode zp_BB is
; positive or FORMATION ($C9D2) when its bit 7 is set.
; In:  cursorTargetMode zp_BB
; Out: textColour/textPadColour = 4, two centred lines printed
; Called from: runGroupEditor ($63B0, and again after every hover info panel at $63F8)
; ----------------------------------------------------------------------
printSettingHeader:
        lda     #$04                    ; 636E  colour 4 = group level
        sta     textColour              ; 6370  ink
        sta     textPadColour           ; 6372  pad
        jsr     clearInfoPanel          ; 6374  clear the info panel
        lda     #$0F                    ; 6377  screen row 15
        jsr     setCursorRow            ; 6379  position the cursor there
        jsr     printSettingWord        ; 637C  print 'SETTING' centred, then a newline
        lda     #$32                    ; 637F  $CA32 = 'MEMBERSHIP' (entry 6 of the 16-byte option strings)
        ldy     #$CA                    ; 6381  high byte
        bit     cursorTargetMode        ; 6383  zp_BB: bit 7 set = custom formation mode
        bpl     L_638B                  ; 6385  membership mode: print MEMBERSHIP

; ----------------------------------------------------------------------
; printFormationCentred - Prints the word FORMATION ($C9D2) centred in the current text window; also
; the tail of printSettingHeader in custom-formation mode.
; In:  the current text window
; Out: one centred line printed
; Called from: runFormationOptionsMenu ($6602) and by fall-through from printSettingHeader
; ----------------------------------------------------------------------
printFormationCentred:
        lda     #$D2                    ; 6387  $C9D2 = 'FORMATION' (entry 0 of the 16-byte option strings)
        ldy     #$C9                    ; 6389  high byte
L_638B:
        jmp     printStringCentred      ; 638B  print the word centred in the info panel


; ----------------------------------------------------------------------
; runGroupEditor - Interactive editor for the group in selectedGroupKey, in MEMBERSHIP mode (zp_BB >=
; 0) or custom FORMATION mode (zp_BB bit 7 set). It halts the group with command $9B (formation mode
; first waits for pending re-positions), then lets the player scroll the viewport with the joystick
; and shows hover information for an own unit after 30 idle frames. Clicking an own unit adds it
; (command $88, ADDED / TOO FAR!) or removes it (command $89, REMOVED) in membership mode and asks for
; a NEW POSITION (command $98) in formation mode; the command centre is always refused with sound 3.
; Clicking empty ground or an enemy prints FINISHED and ends the editor. Only the part up to $6467
; belongs to this chunk; the routine continues to $64D9.
; In:  selectedGroupKey $9206, cursorTargetMode zp_BB (mode), joyDirection zp_5D, joyRepeatTimer
;     zp_6C,
;      inputIdleFrames $90EE, comcenUnit $9236, unitDisplayFlagsTable $FB54 and the side-branch opcode
;      at groupEditorOwnSideBranch ($63E2)
; Out: selectedUnit $90F8 = the unit under the cursor (bit 7 set = empty cell or enemy), uiLevel = 2,
;      infoPanelUnit = $FF, $9207 = $FF (dead), highlightedGroupKey, joyRepeatMask/joyHoldCounter,
;      inputLockoutTimer, commands $9B/$88/$89/$98 queued, viewport scrolled and redrawn
; Called from: the GROUP OPTIONS items MEMBERSHIP and FORMATION/CUSTOM ($62EC)
; ----------------------------------------------------------------------
runGroupEditor:
        lda     #$02                    ; 638E  2
        sta     uiLevel                 ; 6390  uiLevel 2 = group
        jsr     setCursorShapeSquare    ; 6393  square cell cursor
        lda     #$FF                    ; 6396  $FF
        sta     infoPanelUnit           ; 6398  the info panel belongs to the editor, not to a unit
        sta     unusedFlag9207          ; 639B  dead store - $9207 is never read (see $61AE)
        lda     selectedGroupKey        ; 639E  the group being edited
        sta     highlightedGroupKey     ; 63A1  highlight all of its members
        jsr     drawViewport            ; 63A4  repaint the viewport
        lda     #$00                    ; 63A7  0
        sta     rawGlyphMode            ; 63A9  ASCII translation on
        sta     textCentreWidth         ; 63AB  no automatic centring
        sta     textFillGlyph           ; 63AE  blank with glyph 0
        jsr     printSettingHeader      ; 63B0  'SETTING' + MEMBERSHIP or FORMATION
        lda     cursorTargetMode        ; 63B3  zp_BB: bit 7 set = custom formation mode
        bpl     L_63C0                  ; 63B5  membership mode
        jsr     waitForGroupRepositionsDone; 63B7  formation mode: wait until earlier re-position orders have been applied
        jsr     sendHaltGroupCmd        ; 63BA  command $9B halts every member so the group stays put while it is edited
        jmp     groupEditorActOnUnit    ; 63BD  formation mode starts straight away with the unit the player clicked on

L_63C0:
        jsr     sendHaltGroupCmd        ; 63C0  membership mode: halt the group as well
        lda     #$78                    ; 63C3  120 frames
        jsr     waitFramesOrInput       ; 63C5  let the halt take effect; returns early on a click or a stick deflection

; ----------------------------------------------------------------------
; Main editor loop: one game frame per pass, hover information for the unit under the cursor, click
; handling and joystick scrolling of the viewport.
; ----------------------------------------------------------------------
groupEditorLoop:
        jsr     updateGameFrame         ; 63C8  one frame of the battle
        jsr     checkUiContinue         ; 63CB  may this UI keep running?
        bne     L_63D3                  ; 63CE  yes
        jmp     L_64D4                  ; 63D0  SPACE / screen change / game over: leave the editor

L_63D3:
        lda     joyDirection            ; 63D3  joystick nibble
        cmp     #$0F                    ; 63D5  centred?
        bne     groupEditorScroll       ; 63D7  deflected: scroll the viewport
        jsr     readCellUnderCursor     ; 63D9  map byte under the cursor = the centre cell of the 7x5 view
        eor     #$80                    ; 63DC  flip bit 7: a unit cell ($80|index) becomes positive, plain terrain becomes negative
        bmi     L_63E6                  ; 63DE  empty cell: nothing selectable
        cmp     #$32                    ; 63E0  unit index < 50 = side 0
groupEditorOwnSideBranch:
        bcc     L_63E6                  ; 63E2  opcode byte patched by patchOwnSideBranches ($7BCF): $90 BCC for side 0, $B0 BCS for side 1, so the branch is taken when the unit is ours
        ora     #$80                    ; 63E4  an enemy unit counts as 'nothing under the cursor'
L_63E6:
        sta     selectedUnit            ; 63E6  bit 7 set = empty cell or enemy unit
        lda     inputIdleFrames         ; 63E9  frames since the player last touched the controls
        cmp     #$1E                    ; 63EC  30 frames
        bcc     L_63FB                  ; 63EE  still moving: no hover info
        lda     selectedUnit            ; 63F0  the cell under the cursor
        bmi     L_63FB                  ; 63F3  only own units get an info panel
        jsr     showUnitInfoPanel       ; 63F5  show its status while the player hovers
        jsr     printSettingHeader      ; 63F8  then restore the editor's own heading
L_63FB:
        jsr     checkFirePressed        ; 63FB  fresh fire-button press?
        beq     groupEditorActOnUnit    ; 63FE  clicked: act on the cell
        jsr     checkUiContinue         ; 6400  may this UI keep running?
        bne     groupEditorScroll       ; 6403  yes
        jmp     L_64D4                  ; 6405  aborted: leave the editor


; ----------------------------------------------------------------------
; Scroll the viewport one cell per auto-repeat step. $6418-$6434 is an inline copy of
; setJoystickRepeatDelay ($0D49) - the same fourteen instructions, not a JSR.
; ----------------------------------------------------------------------
groupEditorScroll:
        lda     joyRepeatTimer          ; 6408  auto-repeat delay still running?
        bne     groupEditorLoop         ; 640A  yes: next frame
        ldy     joyDirection            ; 640C  joystick nibble as the table index
        lda     #$00                    ; 640E  0
        sta     inputLockoutTimer       ; 6410  cancel the input lockout: scrolling must respond at once
        lda     joystickDirectionTable,y; 6413  direction code 0-7 (N,S,E,W,NW,NE,SE,SW), $FF when centred or invalid
        bmi     groupEditorLoop         ; 6416  no usable direction
        ldy     #$10                    ; 6418  16 frames before the first repeat
        pha                             ; 641A  keep the direction code
        inc     joyHoldCounter          ; 641B  count how long the stick has been held
        lda     joyHoldCounter          ; 641E  ...
        and     joyRepeatMask           ; 6421  the IRQ clears this mask while the stick is centred, so a new press restarts the count at 0
        sta     joyHoldCounter          ; 6424  store the masked counter
        cmp     #$01                    ; 6427  first step after centring?
        bcc     L_642D                  ; 6429  yes: keep the 16-frame delay
        ldy     #$06                    ; 642B  held: repeat every 6 frames
L_642D:
        sty     joyRepeatTimer          ; 642D  arm the repeat timer
        lda     #$7F                    ; 642F  $7F
        sta     joyRepeatMask           ; 6431  let the hold counter grow again
        pla                             ; 6434  direction code back
        jsr     scrollViewportByDirection; 6435  scroll the viewport one cell in that direction
        jmp     groupEditorLoop         ; 6438  next frame


; ----------------------------------------------------------------------
; The player clicked: act on whatever the cursor stands on. Empty ground or an enemy finishes the
; editor, the command centre is refused, and any other own unit is added, removed or re-positioned by
; the code that follows this chunk.
; ----------------------------------------------------------------------
groupEditorActOnUnit:
        lda     #$04                    ; 643B  colour 4
        sta     textColour              ; 643D  ink
        sta     textPadColour           ; 643F  pad
        jsr     clearInfoPanel          ; 6441  clear the info panel for the result message
        lda     #$10                    ; 6444  screen row 16
        jsr     setCursorRow            ; 6446  position the cursor there
        ldy     selectedUnit            ; 6449  the cell under the cursor
        bpl     L_645D                  ; 644C  one of our own units: act on it
        lda     #$47                    ; 644E  $CB47 = 'FINISHED'
        ldy     #$CB                    ; 6450  high byte
        jsr     printStringCentred      ; 6452  print it centred
        lda     #$3C                    ; 6455  60 frames
        jsr     waitFramesOrInput       ; 6457  show it briefly
        jmp     L_64D4                  ; 645A  clicking empty ground or an enemy ends the editor

L_645D:
        cpy     comcenUnit              ; 645D  is it the player's command centre (unit 49 or 99)?
        bne     L_646A                  ; 6460  no: an ordinary unit
        lda     #$03                    ; 6462  sound 3 = rejected
        jsr     playSound               ; 6464  play it
        jmp     L_64BE                  ; 6467  the comcen can never be a group member: ignore the click and go round again


; ----------------------------------------------------------------------
; Click on one of our own units while the group editor is running.  Y = the unit index (selectedUnit,
; already known to be alive, ours and not the comcen).  Four cases:
;   MEMBERSHIP mode (cursorTargetMode >= 0): outsider -> ADDED / TOO FAR!, member -> REMOVED
;   FORMATION  mode (cursorTargetMode <  0): outsider -> NOT IN GROUP, member -> NEW POSITION
; ----------------------------------------------------------------------
L_646A:
        lda     unitDisplayFlagsTable,y ; 646A  local flag copy of the clicked unit: facing 0-1, group id 2-4, side 5, member 6
        and     #$3C                    ; 646D  keep the group key = group id (bits 2-4) plus the side bit ($20)
        cmp     selectedGroupKey        ; 646F  does it already carry the key of the group being edited?
        beq     L_6496                  ; 6472  yes - go on to test the real membership bit
L_6474:
        lda     cursorTargetMode        ; 6474  editor mode: >= 0 = MEMBERSHIP, < 0 = custom FORMATION
        bpl     L_6483                  ; 6476  MEMBERSHIP mode: try to enlist this outsider
        lda     #$03                    ; 6478  sound 3 = the error beep
        jsr     playSound               ; 647A  FORMATION mode may only re-position units that are already members
        lda     #$8D                    ; 647D  'NOT IN GROUP' ($CB8D), low byte
        ldy     #$CB                    ; 647F  ...high byte
        bne     L_64B8                  ; 6481  always taken (A = $8D) - go and print the refusal
L_6483:
        jsr     addSelectedUnitToGroup  ; 6483  membership add: C=1 when the unit is more than 8 cells from the group (TOO FAR! already shown)
        bcc     L_6490                  ; 6486  accepted -> report ADDED
        lda     #$3C                    ; 6488  60 frames
        jsr     runGameFrames           ; 648A  let the real-time battle run on so TOO FAR! stays readable
        jmp     L_64D1                  ; 648D  straight back to the cursor loop; the panel already holds the message

L_6490:
        lda     #$3B                    ; 6490  'ADDED' ($CB3B), low byte
        ldy     #$CB                    ; 6492  ...high byte
        bne     L_64B8                  ; 6494  always taken (A = $3B)
L_6496:
        lda     unitDisplayFlagsTable,y ; 6496  the key matches - now check the 'is in a group' bit itself
        and     #$40                    ; 6499  bit 6 of the local flags
        beq     L_6474                  ; 649B  key matches but the member bit is clear (stale key) - treat it as an outsider
        lda     cursorTargetMode        ; 649D  editor mode again
        bpl     L_64B1                  ; 649F  MEMBERSHIP mode: this member is being removed
        jsr     printSettingHeaderForLevel; 64A1  FORMATION mode: 'SETTING' header in the info panel, in the uiLevel colour
        lda     #$A6                    ; 64A4  'NEW POSITION' ($CBA6), low byte
        ldy     #$CB                    ; 64A6  ...high byte
        jsr     printStringCentred      ; 64A8  centred under SETTING
        jsr     orderGroupNewPosition   ; 64AB  run the free cell pick and queue command $98 unit,col,row
        jmp     L_64BE                  ; 64AE  skip the ADDED/REMOVED print - orderGroupNewPosition has drawn its own feedback

L_64B1:
        jsr     removeSelectedUnitFromGroup; 64B1  clear the local member bit and queue command $89 unit
        lda     #$40                    ; 64B4  'REMOVED' ($CB40), low byte
        ldy     #$CB                    ; 64B6  ...high byte
L_64B8:
        jsr     printStringCentred      ; 64B8  print ADDED / REMOVED / NOT IN GROUP centred in the info panel
        jsr     drawViewport            ; 64BB  redraw the 7x5 tactical view so the member highlight is current
L_64BE:
        jsr     checkUiContinue         ; 64BE  may the editor keep running?  0 = game over, screen change or SPACE
        beq     L_64D4                  ; 64C1  no - tear the editor down
        lda     #$3C                    ; 64C3  60 frames
        jsr     waitFramesOrInput       ; 64C5  hold the message, but return early on a fresh stick move or button press
        lda     selectedGroupKey        ; 64C8  the group being edited...
        sta     highlightedGroupKey     ; 64CB  ...is highlighted again (NEW POSITION had highlighted a single unit)
        jsr     drawViewport            ; 64CE  redraw the viewport with the group highlight back
L_64D1:
        jmp     groupEditorLoop         ; 64D1  back to the top of the group-editor cursor loop at $63C8

L_64D4:
        jsr     clearGroupHighlight     ; 64D4  highlightedGroupKey = $FF and redraw - no more group highlight
        jmp     exitUnitMenu            ; 64D7  uiLevel = 0, info-panel unit = $FF, return to the battlefield loop


; ----------------------------------------------------------------------
; sendHaltGroupCmd - Queues the 2-byte command $9B with the key of the currently selected group. Its
; executor ($549E) stops every member: destination = current position, idle bit set, cloak and blitz
; orders cleared.  The group editor halts the group first so the player edits settled units.
; In:  selectedGroupKey ($9206) = group id in bits 2-4 plus the side bit $20
; Out: cmdParam0 = the group key, 2 bytes appended to the outgoing command ring by queueCmd2
; Called from: runGroupEditor at $63BA (formation mode) and $63C0 (membership mode)
; ----------------------------------------------------------------------
sendHaltGroupCmd:
        lda     selectedGroupKey        ; 64DA  the group being edited
        sta     cmdParam0               ; 64DD  becomes the single argument byte of the command
        lda     #$9B                    ; 64DF  command $9B = halt every member of a group
        jmp     queueCmd2               ; 64E1  queue code + argument and return through queueCmd2


; ----------------------------------------------------------------------
; printSettingHeaderForLevel - Clears the info panel in the colour that belongs to the current UI
; level, moves to row 15 and falls into printSettingWord, which prints SETTING centred plus a newline.
; The caller then prints the object of the order (TARGET, DESTINATION, NEW POSITION).
; In:  uiLevel ($9209) 0-2, uiLevelTextColourTable at $5E14 ($09 browsing, $05 unit, $04 group)
; Out: textColour = textPadColour = that colour, info panel cleared, cursor on row 15
; Called from: the SET TARGET item at $5F56, the NEW POSITION branch at $64A1, setUnitDestination
; ----------------------------------------------------------------------
printSettingHeaderForLevel:
        ldy     uiLevel                 ; 64E4  0 = just browsing, 1 = one unit selected, 2 = a whole group
        lda     uiLevelTextColourTable,y; 64E7  $09 / $05 / $04 - the info-panel colour byte for that level
        sta     textColour              ; 64EA  colour of every character cell printed from here on
        sta     textPadColour           ; 64EC  ...and of the blanks that pad a centred string
        jsr     clearInfoPanel          ; 64EE  select and clear the info panel (columns 25-38, rows 12-21)
        lda     #$0F                    ; 64F1  row 15, three rows below the top of the panel
        jsr     setCursorRow            ; 64F3  move the cursor there without touching the column

; ----------------------------------------------------------------------
; printSettingWord - Prints the word SETTING ($CB4F) centred in the current text window and ends the
; line, leaving the cursor on the next row for the object word.
; In:  the text window and colour set up by the caller
; Out: SETTING printed, cursor on the following row
; Called from: printSettingHeader at $637C, and by falling out of printSettingHeaderForLevel
; ----------------------------------------------------------------------
printSettingWord:
        lda     #$4F                    ; 64F6  'SETTING' ($CB4F), low byte
        ldy     #$CB                    ; 64F8  ...high byte
        jsr     printStringCentred      ; 64FA  centred across the 14-column info panel
        jmp     printNewline            ; 64FD  end the line and return through printNewline


; ----------------------------------------------------------------------
; runSetDestinationScreen - The SETTING / DESTINATION screen.  Prints the header, selects the plain
; move-order cursor and runs the target cursor loop, which issues the move order for the selected unit
; or, at uiLevel 2, for every member of the selected group.  Entered as soon as the player pushes the
; stick after picking a unit or a group.
; In:  selectedUnit ($90F8), uiLevel ($9209), selectedGroupKey ($9206)
; Out: cursorTargetMode = 0, a move order queued by runTargetCursorLoop
; Called from: the unit branch at $5E62 and the group branch at $6220 of handleOwnUnitClick
; promptForDestination - the console flow for 'set this unit's destination': print SETTING and
; DESTINATION, put the cursor in plain move mode and drive runTargetCursorLoop until the player
; commits or aborts, which queues the move order. Not to be confused with setUnitDestination $34D5,
; which is the engine routine that actually writes a unit's destination and applies the comcen speed
; rule.
; ----------------------------------------------------------------------
promptForDestination:
        jsr     printSettingHeaderForLevel; 6500  'SETTING' in the colour of the current UI level
        jsr     printNextStringCentred  ; 6503  the string right after SETTING is 'DESTINATION' ($CB56) - print it centred too
        lda     #$00                    ; 6506  0 = plain move cursor: no range limit, the pick becomes a move order
        sta     cursorTargetMode        ; 6508  cursorTargetMode selects the behaviour of runTargetCursorLoop
        jsr     runTargetCursorLoop     ; 650A  drive the cursor until the player commits or aborts
        rts                             ; 650D  back to the caller (a JMP would have done)


; ----------------------------------------------------------------------
; countGroupMembers - Counts the live units that belong to the group whose key is in A.  Used by the
; WHICH GROUP: list to print the member count beside each group name.
; In:  A = group key (group number * 4 plus the side bit $20)
; Out: A = scratch18 = number of members, scratch19 = key | $40, Y = $FF
; Called from: the join-group list builder at $617D
; ----------------------------------------------------------------------
countGroupMembers:
        ora     #$40                    ; 650E  add bit 6, the 'is in a group' flag, to the pattern we look for
        sta     scratch19               ; 6510  keep the wanted flag pattern here for the compare inside the loop
        ldy     #$63                    ; 6512  walk all 100 unit slots, 99 down to 0
        lda     #$00                    ; 6514  member count starts at zero
        sta     scratch18               ; 6516  scratch18 accumulates the member count

; ----------------------------------------------------------------------
; Scan every unit slot; count the live ones whose local flags carry exactly this group key.
; ----------------------------------------------------------------------
L_6518:
        lda     unitTypeTable,y         ; 6518  unit type, bit 7 set = the unit is dead
        bmi     L_6528                  ; 651B  dead units are not members
        lda     unitDisplayFlagsTable,y ; 651D  local flag copy
        and     #$7C                    ; 6520  group id (bits 2-4), side (bit 5) and member bit (bit 6)
        cmp     scratch19               ; 6522  same group and actually a member?
        bne     L_6528                  ; 6524  no
        inc     scratch18               ; 6526  yes - one more member
L_6528:
        dey                             ; 6528  next unit slot
        bpl     L_6518                  ; 6529  until index $FF wraps the sign bit
        lda     scratch18               ; 652B  return the count in A as well
        rts                             ; 652D  the caller wants the count in A


; ----------------------------------------------------------------------
; runRepairWhichMenu - Draws and runs the 'REPAIR WHICH:' menu of the REPAIR console tab.  It opens a
; 7-column text window at column 3 spanning rows 9-19, prints the header, appends the four comcen
; systems (ENERGY, BATTLE, RADAR, DRONE) and (EXIT) as menu items, then loops: run the selector, and
; for anything but (EXIT) make one repair attempt on the chosen system.  selectedUnit = $FF is the
; sentinel that makes the menu highlight blink the matching system marker on the comcen picture.
; In:  the comcen state read by attemptSystemRepair; filmPlaybackMode ($0B9B) and gameOverFlags
;      ($923F) through isPlayerInputAllowed; menuSelectedItem ($91D5) after each selector pass
; Out: text window $39-$3E, menu state $91D4/$91D5/$91D6/$91CE, selectedUnit = $FF; may queue the
;      $9A energy-repair command and clear comcenSystemStatus[system]; window erased on exit
; Called from: repairTabAction at $1860, which has just erased the four system markers and set
;      showOkMarkers = 1 so undamaged systems are shown too
; ----------------------------------------------------------------------
runRepairWhichMenu:
        lda     #$00                    ; 652E  zero for the text modes and the menu counters below
        sta     rawGlyphMode            ; 6530  $43 = 0: strings are ASCII, control codes (CR) honoured
        sta     textCentreWidth         ; 6532  $90EF = 0: no centring field, text starts at the left edge
        sta     textFillGlyph           ; 6535  $3E = 0: glyph 0 is the blank used by the window clear
        sta     menuItemCount           ; 6537  the item list starts out empty
        sta     menuSelectedItem        ; 653A  highlight will start on the first item
        lda     #$09                    ; 653D  row 9, just under the top of the left map panel
        sta     textWindowTop           ; 653F  first row of the menu window
        clc                             ; 6541  clear the carry for the add that follows
        adc     #$0A                    ; 6542  the window is 10 rows tall
        sta     textWindowBottom        ; 6544  row 19 = row limit of the window
        lda     #$03                    ; 6546  column 3, inside the 22-column map panel
        sta     textWindowLeft          ; 6548  left edge of the menu window
        lda     #$07                    ; 654A  7 characters wide - enough for 'REPAIR' and ' ENERGY'
        sta     textWindowWidth         ; 654C  wrap point and default centring width
        lda     #$61                    ; 654E  video-matrix colour byte $61: %01 pixel pairs blue (6), %10 pairs white (1)
        sta     textColour              ; 6550  the clear below and the header are drawn in it
        jsr     bootDriveDataBit        ; 6552  paint the whole window blank and home the cursor to row 9  (also textEngineC000:clearTextWindow)
        jsr     printNewline            ; 6555  leave row 9 empty; the text starts on row 10
        lda     #$D6                    ; 6558  'REPAIR' CR 'WHICH:' CR CR ($CBD6), low byte
        ldy     #$CB                    ; 655A  ...high byte
        jsr     printString             ; 655C  two header rows plus a blank one
        lda     textCursorRow           ; 655F  the trailing CRs left the cursor on row 13
        sta     menuFirstRow            ; 6561  row 13 is the first row the highlight bar may sit on
        ldx     #$0D                    ; 6564  menu item id $0D = ' ENERGY' (ids 12-16 index the strings at $CAB2)
        stx     scratch1C               ; 6566  id of the item currently being added

; ----------------------------------------------------------------------
; Append the four comcen systems as menu items 13,14,15,16 = ENERGY, BATTLE, RADAR, DRONE. Each
; addMenuItem prints the name (its string ends in CR) so the rows fill downwards.
; ----------------------------------------------------------------------
L_6568:
        jsr     addMenuItem             ; 6568  record the id in menuItemIdList and print its name
        inc     scratch1C               ; 656B  next system
        ldx     scratch1C               ; 656D  addMenuItem takes the id in X
        cpx     #$11                    ; 656F  $11 = one past ' DRONE', so ids $0D-$10 are added
        bcc     L_6568                  ; 6571  keep going while there are systems left
        ldx     #$0C                    ; 6573  item id $0C = ' (EXIT)', always the last entry
        jsr     addMenuItem             ; 6575  menu now holds 5 items
        lda     #$60                    ; 6578  colour byte $60 for the rows the highlight will run over
        sta     textColour              ; 657A  the item rows and the highlight bar use this

; ----------------------------------------------------------------------
; Menu loop: pick a system, repair it, remove the highlight and start over until (EXIT).
; ----------------------------------------------------------------------
L_657C:
        lda     #$FF                    ; 657C  $FF = repair-menu mode...
        sta     selectedUnit            ; 657E  ...so the blinking highlight also blinks the comcen system marker
        jsr     runMenuLoop             ; 6581  wait for the player to choose (returns the index in Y and in menuSelectedItem)
        ldy     menuSelectedItem        ; 6584  0 ENERGY, 1 BATTLE, 2 RADAR, 3 DRONE, 4 (EXIT)
        cpy     #$04                    ; 6587  the (EXIT) row
        beq     L_6599                  ; 6589  leave the menu
        jsr     isPlayerInputAllowed    ; 658B  Z = 1 only when no film is playing and the game is not already over
        bne     L_6593                  ; 658E  repairs are locked out - just re-run the menu
        jsr     attemptSystemRepair     ; 6590  one repair roll on the system in menuSelectedItem (1/2 for energy, 1/4 otherwise)
L_6593:
        jsr     unhighlightMenuRow      ; 6593  un-highlight the row before the selector redraws it
        jmp     L_657C                  ; 6596  back into the menu

L_6599:
        lda     #$66                    ; 6599  colour byte $66: both nibbles blue, i.e. wipe the window out
        sta     textColour              ; 659B  the clear below paints the whole window in it
        jmp     bootDriveDataBit        ; 659D  erase the menu window and return to repairTabAction  (also textEngineC000:clearTextWindow)


; ----------------------------------------------------------------------
; runInGameOptionsMenu - Runs the MISC-tab options menu while a battle is in progress.  The caller has
; already printed the items (ABORT GAME, VOICE PAUSE when a modem game is running, (EXIT)) and set
; menuItemCount / menuFirstRow.  Every index other than 1 is handed back to the caller.  Index 1 is
; VOICE PAUSE: with a carrier up it tells the opponent with command $8C reason 5 and then waits until
; voicePauseReconnect (called from the $C000 overlay at $C768) clears bit 7 of menuSelectedItem;
; without a carrier it just hangs up and redials itself.  Afterwards the menu is restarted.
; In:  menuItemCount ($91D4) and menuFirstRow ($91D6) set by the caller, filmPlaybackMode ($0B9B),
;      isSoloTrainer ($0BA5), the comm module link flags at $E03C
; Out: Y = menuSelectedItem (0 = ABORT GAME, last = (EXIT)), selectedUnit = $FE, textColour = $60,
;      command $8C/5 possibly queued, menu highlight removed
; Called from: miscTabAction at $1A78; tail-jumps to itself at $65E0 after a voice pause
; ----------------------------------------------------------------------
runInGameOptionsMenu:
        lda     #$FE                    ; 65A0  $FE = plain menu: the highlight does not track a unit or a comcen marker
        sta     selectedUnit            ; 65A2  the menu selector reads this sentinel every frame
        lda     #$60                    ; 65A5  colour byte $60 for the menu rows (%01 blue, %10 black)
        sta     textColour              ; 65A7  colour of the item rows
        jsr     runMenuLoop             ; 65A9  run the selector; returns the chosen index in Y
        cpy     #$01                    ; 65AC  index 1 = VOICE PAUSE
        bcc     L_65E3                  ; 65AE  index 0 = ABORT GAME - let miscTabAction deal with it
        bne     L_65E3                  ; 65B0  index 2 = (EXIT) - likewise
        lda     filmPlaybackMode        ; 65B2  film record/playback flag
        bne     L_65E3                  ; 65B5  a film has no phone call to pause
        lda     isSoloTrainer           ; 65B7  bit 7 set = the solo trainer is the opponent
        bmi     L_65E3                  ; 65BA  the trainer has no phone call either
        bit     linkStatus              ; 65BC  comm module link flags; bit 6 = carrier present  (also trainerAiE000:linkStatus)
        bvs     L_65C7                  ; 65BF  carrier up - warn the opponent before hanging up
        jsr     voicePauseReconnect     ; 65C1  no carrier: hang up and re-open the link straight away
        jmp     L_65DD                  ; 65C4  join the common tail: unhighlight and restart the menu

L_65C7:
        lda     #$05                    ; 65C7  reason 5 of command $8C = 'hanging up for a voice call'
        sta     cmdParam0               ; 65C9  the single argument byte
        lda     #$8C                    ; 65CB  command $8C = session control
        jsr     queueCmd2               ; 65CD  queue the two bytes for the opponent
        lda     #$81                    ; 65D0  bit 7 = 'waiting for the call to come back'
        sta     menuSelectedItem        ; 65D2  menuSelectedItem doubles as the wait flag

; ----------------------------------------------------------------------
; Spin here while the players talk on the phone.  updateGameFrame keeps the battle, the message system
; and the link running; voicePauseReconnect clears bit 7 when the modems have connected again.
; ----------------------------------------------------------------------
L_65D5:
        jsr     updateGameFrame         ; 65D5  one game frame (engine, messages, comm poll)
        lda     menuSelectedItem        ; 65D8  voicePauseReconnect clears bit 7 when the link is back
        bmi     L_65D5                  ; 65DB  still waiting for the reconnect
L_65DD:
        jsr     unhighlightMenuRow      ; 65DD  take the highlight off the VOICE PAUSE row
        jmp     runInGameOptionsMenu    ; 65E0  and put the whole options menu up again

L_65E3:
        jsr     unhighlightMenuRow      ; 65E3  the caller redraws, so drop the highlight first
        rts                             ; 65E6  Y still holds the chosen index


; ----------------------------------------------------------------------
; waitForGroupRepositionsDone - Before a group screen edits a group, wait until the re-position orders
; already sent for that same group have been executed, so the editor works on settled positions.  If
; the player gives up meanwhile (checkUiContinue = 0) the routine pops its own return address so the
; RTS lands in the caller's caller and the whole group operation is abandoned.
; In:  selectedGroupKey ($9206), lastOrderedGroupKey ($929D), pendingGroupOrderCount ($929E)
; Out: nothing, but the return may unwind one stack level
; Called from: the GROUP screen at $621D and the group editor at $63B7
; ----------------------------------------------------------------------
waitForGroupRepositionsDone:
        lda     selectedGroupKey        ; 65E7  the group we are about to work on
        cmp     lastOrderedGroupKey     ; 65EA  the group the last batch of re-position orders was for
        bne     L_65FE                  ; 65ED  a different group - nothing of ours is in flight

; ----------------------------------------------------------------------
; Wait while orders are still outstanding, keeping the real-time battle running.
; ----------------------------------------------------------------------
L_65EF:
        lda     pendingGroupOrderCount  ; 65EF  re-position orders queued but not yet executed
        beq     L_65FE                  ; 65F2  all settled - carry on
        jsr     updateGameFrame         ; 65F4  run one frame of the battle and the link
        jsr     checkUiContinue         ; 65F7  0 = game over, screen change or SPACE
        bne     L_65EF                  ; 65FA  keep waiting
        pla                             ; 65FC  abandon: throw away the low byte of our return address...
        pla                             ; 65FD  ...and the high byte, so the RTS below returns to the caller's caller
L_65FE:
        rts                             ; 65FE  return - normally, or one level higher when the wait was abandoned


; ----------------------------------------------------------------------
; runFormationOptionsMenu - The FORMATION sub-menu of the group options.  Prints the FORMATION OPTIONS
; heading and the five items (SWEEP LEFT, SWEEP RIGHT, REVERSE, CUSTOM, (EXIT)) as one multi-line
; string, then runs the selector.  Items 0-2 are rotations and are sent straight to both machines as
; command $A3 leader,mode; 3 (CUSTOM) and 4 ((EXIT)) are returned to the caller.
; In:  selectedUnit ($90F8) = the unit the formation is rotated about, menuFirstRow ($91D6) = 15,
;      already set by the group menu at $62A7
; Out: Y = menuSelectedItem, cmdParam1/cmdParam2 = leader and mode, command $A3 queued, item count 5
; Called from: the group options menu at $62FD
; ----------------------------------------------------------------------
runFormationOptionsMenu:
        jsr     clearInfoPanel          ; 65FF  select and clear the 14-column info panel (columns 25-38, rows 12-21)
        jsr     printFormationCentred   ; 6602  'FORMATION' ($C9D2) centred on row 12
        lda     #$13                    ; 6605  '   OPTIONS' CR CR ($CB13), low byte
        ldy     #$CB                    ; 6607  ...high byte
        jsr     printString             ; 6609  heading plus a blank row, leaving the cursor on row 15
        lda     #$8C                    ; 660C  the five item lines at $CA8C, separated by CR
        ldy     #$CA                    ; 660E  ...high byte
        jsr     printString             ; 6610  SWEEP LEFT / SWEEP RIGHT / REVERSE / CUSTOM / (EXIT) on rows 15-19
        lda     #$00                    ; 6613  start at the top of the list
        sta     menuSelectedItem        ; 6615  start the highlight on SWEEP LEFT
        lda     #$05                    ; 6618  five rows to choose from
        sta     menuItemCount           ; 661A  five selectable rows
        jsr     runMenuLoop             ; 661D  wait for the choice
        ldy     menuSelectedItem        ; 6620  0 SWEEP LEFT, 1 SWEEP RIGHT, 2 REVERSE, 3 CUSTOM, 4 (EXIT)
        cpy     #$03                    ; 6623  indices 3 (CUSTOM) and 4 ((EXIT)) are not rotations
        bcs     L_6636                  ; 6625  CUSTOM and (EXIT) are handled by the caller
        sty     cmdParam2               ; 6627  third command byte = rotation mode 0-2
        lda     selectedUnit            ; 6629  the group leader / clicked unit
        sta     cmdParam1               ; 662C  second command byte
        lda     #$A3                    ; 662E  command $A3 = rotate the leader's formation
        jsr     queueCmd3               ; 6630  queue code + two arguments
        ldy     menuSelectedItem        ; 6633  hand the choice back in Y
L_6636:
        rts                             ; 6636  Y = the chosen index


; ----------------------------------------------------------------------
; soundPlayerTick - The per-frame SID sound driver.  It is skipped while startSound is still
; rebuilding the voice tables, writes the master volume and then advances the three voices.  X holds
; voice*2 throughout the driver, because every per-voice value lives at a fixed offset plus voice*2
; inside the 48-byte block at sndVoiceState.
; In:  sndInitLock ($67C5), sndVolume ($67C2), the voice state at $6792-$67C1, the script pointers
;      in zero page $D8-$DD
; Out: SID registers $D400-$D418, updated voice state, sndVoiceSoundId cleared for finished voices
; Called from: the raster IRQ at $128C (which first dequeues a request with $C8E9 and, if there is
;      one, calls startSound at $1289)
; ----------------------------------------------------------------------
soundPlayerTick:
        lda     sndInitLock             ; 6637  non-zero while startSound is half way through filling the voice tables
        beq     L_663D                  ; 663A  safe to run
        rts                             ; 663C  skip this tick rather than play a half-built voice

L_663D:
        lda     sndVolume               ; 663D  $0C while anything is sounding, 0 when the driver has been silenced
        sta     SID_VOLUME              ; 6640  $D418 bits 0-3 = master volume (bits 4-6 are the filter mode, left at 0)
        beq     L_66BE                  ; 6643  volume 0 - nothing can be heard, so do no work
        ldx     #$00                    ; 6645  X = voice*2: SID voice 1
        jsr     updateSoundVoice        ; 6647  advance SID voice 1
        ldx     #$02                    ; 664A  SID voice 2
        jsr     updateSoundVoice        ; 664C  advance SID voice 2
        ldx     #$04                    ; 664F  SID voice 3 - fall through into updateSoundVoice, whose RTS returns for us

; ----------------------------------------------------------------------
; updateSoundVoice - Advances one SID voice by one frame.  An idle voice (duration 0) is left alone.
; Otherwise the 16-bit frequency and pulse-width slides are added to the current values, the note
; duration is counted down and, when it expires, the voice's script is interpreted up to its next 'set
; duration' command; a script that ends without setting one releases the voice and frees its
; sndVoiceSoundId slot.  Finally the whole voice image is written to the SID registers.
; In:  X = voice*2 (0, 2 or 4), the voice state rows at $6792-$67C1
; Out: SID voice registers at $D400 + voice*7, updated state, X preserved
; Called from: soundPlayerTick at $6647 and $664C, and by falling through at $664F
; ----------------------------------------------------------------------
updateSoundVoice:
        lda     sndVoiceState,x         ; 6651  duration counter of this voice ($6792 + voice*2)
        beq     L_66BE                  ; 6654  0 = the voice is idle, nothing to do
        clc                             ; 6656  clear the carry for the 16-bit frequency add
        lda     sndVoiceFreqStepLo,x    ; 6657  per-tick frequency slide, low byte
        adc     sndVoiceFreqLo,x        ; 665A  current oscillator frequency, low byte
        sta     sndVoiceFreqLo,x        ; 665D  new oscillator frequency, low byte
        lda     sndVoiceFreqStepHi,x    ; 6660  slide high byte, with the carry from the low half
        adc     sndVoiceFreqHi,x        ; 6663  current frequency high byte, plus the carry from the low half
        sta     sndVoiceFreqHi,x        ; 6666  new oscillator frequency, high byte
        clc                             ; 6669  clear the carry for the 16-bit pulse-width add
        lda     sndVoicePulseStepLo,x   ; 666A  per-tick pulse-width sweep, low byte
        adc     sndVoicePulseLo,x       ; 666D  current pulse width, low byte
        sta     sndVoicePulseLo,x       ; 6670  new pulse width, low byte
        lda     sndVoicePulseStepHi,x   ; 6673  sweep high byte plus carry
        adc     sndVoicePulseHi,x       ; 6676  current pulse width high byte, plus carry
        sta     sndVoicePulseHi,x       ; 6679  new pulse width, high byte
        dec     sndVoiceState,x         ; 667C  one tick off the remaining note duration
        bne     L_6691                  ; 667F  note still running - just refresh the SID registers
        jsr     runSoundScript          ; 6681  duration expired: interpret the script up to its next duration command
        lda     sndVoiceState,x         ; 6684  did the script set a new duration?
        bne     L_6691                  ; 6687  yes - the voice carries on
        txa                             ; 6689  no, the sound is over: turn voice*2...
        lsr     a                       ; 668A  ...back into a plain voice number 0-2
        tay                             ; 668B  Y = voice number 0-2
        lda     #$00                    ; 668C  0 = no sound playing on this voice
        sta     sndVoiceSoundId,y       ; 668E  mark the voice free so requestSound may hand it to another sound

; ----------------------------------------------------------------------
; Write the voice image out to the SID.  Y = 0, 7 or $0E, the register offset of this voice.
; ----------------------------------------------------------------------
L_6691:
        ldy     sidVoiceRegOffsetTable,x; 6691  $00 / $07 / $0E - distance of this voice's registers from $D400
        lda     sndVoiceFreqLo,x        ; 6694  frequency low byte of this voice
        sta     SID_V1_FREQ_LO,y        ; 6697  $D400 + voice*7 = frequency low
        lda     sndVoiceFreqHi,x        ; 669A  frequency high byte
        sta     SID_V1_FREQ_HI,y        ; 669D  $D401 + voice*7 = frequency high
        lda     sndVoicePulseLo,x       ; 66A0  pulse width low byte
        sta     SID_V1_PW_LO,y          ; 66A3  $D402 + voice*7 = pulse width low
        lda     sndVoicePulseHi,x       ; 66A6  pulse width high byte
        sta     SID_V1_PW_HI,y          ; 66A9  $D403 + voice*7 = pulse width high (only bits 0-3 of it matter)
        lda     sndVoiceAttackDecay,x   ; 66AC  attack/decay byte
        sta     SID_V1_AD,y             ; 66AF  $D405 + voice*7 = attack (high nibble) and decay (low nibble)
        lda     sndVoiceSustainRelease,x; 66B2  sustain/release byte
        sta     SID_V1_SR,y             ; 66B5  $D406 + voice*7 = sustain level and release rate
        lda     sndVoiceCtrlByte,x      ; 66B8  waveform + gate byte, e.g. $41 pulse, $21 sawtooth, $81 noise, gate off = bit 0 clear
        sta     SID_V1_CTRL,y           ; 66BB  $D404 + voice*7 - written last so the gate change lands after the new envelope
L_66BE:
        rts                             ; 66BE  shared exit of soundPlayerTick and updateSoundVoice


; ----------------------------------------------------------------------
; Script byte $80-$FF: 'jitter the pitch'.  Only bit 7 matters; the value of the byte is ignored. The
; current map/scenario random byte is added to the frequency low byte, which is what gives the shot
; and drone scripts their rasp.  Note there is no CLC first, so the carry left by the BIT/ fetch
; sequence can add one extra - harmless for a noise effect.  Reached only by the BMI at $66CD.
; ----------------------------------------------------------------------
L_66BF:
        lda     scenarioSeed0           ; 66BF  byte 0 of the 24-bit scenario seed, used here as a cheap random value
        adc     sndVoiceFreqLo,x        ; 66C1  add it to the oscillator frequency low byte
        sta     sndVoiceFreqLo,x        ; 66C4  and fall straight back into the script interpreter

; ----------------------------------------------------------------------
; runSoundScript - Interprets one voice's script bytecode until it sets a note duration (or ends).
; Byte formats:
;   $80-$FF  add the scenario random byte to the frequency low byte (handler at $66BF)
;   $40-$7F  loop/jump: bits 0-5 select a counter slot ($06 = loop counter A, $07 = counter B),
;            followed by a 16-bit target address, low byte first.  A counter of 0 jumps always;
;            otherwise it is decremented and the jump is taken while it is still non-zero.
;   $00-$0A  store the following byte (two bytes for slots from $12 up) into the state slot named by
;            sndCommandSlotTable: 0 duration (and stop here), 1 waveform/gate, 2 loop counter A,
;            3 loop counter B, 4/5 spare, 6 frequency, 7 pulse width, 8 AD/SR, 9 frequency slide,
;            10 pulse-width slide.  A duration of 0 ends the sound.
; In:  X = voice*2, the script pointer in zero page ($D8 + X), sndScriptByte
; Out: the voice state rows updated, the script pointer advanced, sndScratch clobbered
; Called from: updateSoundVoice at $6681, and re-entered from its own $66EB / $6710 / $6719
; ----------------------------------------------------------------------
runSoundScript:
        jsr     fetchSoundScriptByte    ; 66C7  next script byte, also stored in sndScriptByte for the test below
        bit     sndScriptByte           ; 66CA  N = bit 7, V = bit 6, Z = 1 only when the byte is 0 (the 'set duration' command)
        bmi     L_66BF                  ; 66CD  $80-$FF: jitter the frequency
        bvs     L_66EE                  ; 66CF  $40-$7F: loop or jump
        php                             ; 66D1  remember Z: command 0 must stop the interpreter after it has run
        tay                             ; 66D2  Y = command number 0-10
        txa                             ; 66D3  X = voice*2
        clc                             ; 66D4  clear the carry before adding the slot offset
        adc     sndCommandSlotTable,y   ; 66D5  plus the offset of that command's slot inside the 48-byte state block
        tay                             ; 66D8  Y now indexes the exact byte to write
        jsr     fetchSoundScriptByte    ; 66D9  operand byte
        sta     sndVoiceState,y         ; 66DC  store it in the slot
        cpy     #$12                    ; 66DF  slots from $12 on (frequency, pulse, AD/SR, the two slides) are 16-bit
        bcc     L_66EA                  ; 66E1  one-byte slot - done
        iny                             ; 66E3  second byte of the pair, at slot+1
        jsr     fetchSoundScriptByte    ; 66E4  high byte of a 16-bit parameter
        sta     sndVoiceState,y         ; 66E7  stored one byte further on, in slot+1
L_66EA:
        plp                             ; 66EA  recover 'was the command 0?'
        bne     runSoundScript          ; 66EB  any other command - keep interpreting
        rts                             ; 66ED  command 0 set the note duration; stop until it runs out


; ----------------------------------------------------------------------
; Loop/jump command $40-$7F.  The two script bytes after it are the target address, low byte first;
; they are pushed and only stored into the pointer if the jump is taken.
; ----------------------------------------------------------------------
L_66EE:
        and     #$3F                    ; 66EE  bits 0-5 = which counter slot ($06 = loop A, $07 = loop B)
        sta     sndScratch              ; 66F0  keep the slot offset
        jsr     fetchSoundScriptByte    ; 66F3  target address low byte
        pha                             ; 66F6  push it
        jsr     fetchSoundScriptByte    ; 66F7  target address high byte
        pha                             ; 66FA  push it as well (so it comes off the stack first)
        clc                             ; 66FB  clear the carry before indexing the counter
        txa                             ; 66FC  voice*2
        adc     sndScratch              ; 66FD  plus the counter slot
        tay                             ; 6700  Y = this voice's copy of that counter
        lda     sndVoiceState,y         ; 6701  this voice's copy of the selected loop counter
        beq     L_6713                  ; 6704  a counter of 0 means 'jump unconditionally'
        sec                             ; 6706  set the carry for the subtract
        sbc     #$01                    ; 6707  count one repetition off
        sta     sndVoiceState,y         ; 6709  write the decremented repeat count back
        bne     L_6713                  ; 670C  repetitions left - take the jump
        pla                             ; 670E  loop finished: drop the target high byte...
        pla                             ; 670F  ...and the low byte
        jmp     runSoundScript          ; 6710  carry on with the byte after the jump

L_6713:
        pla                             ; 6713  top of stack is the high byte pushed at $66FA
        sta     soundScriptPtrHi,x      ; 6714  script pointer high byte for this voice ($D9 + voice*2)
        pla                             ; 6716  then the low byte
        sta     soundScriptPtr,x        ; 6717  script pointer low byte ($D8 + voice*2)
        jmp     runSoundScript          ; 6719  continue interpreting at the jump target


; ----------------------------------------------------------------------
; fetchSoundScriptByte - Reads the next byte of a voice's script through the zero-page pointer at
; $D8 + voice*2, keeps a copy in sndScriptByte for the BIT test in runSoundScript, and advances the
; pointer.
; In:  X = voice*2, the 16-bit pointer at zero page $D8/$D9 + X
; Out: A = the byte, sndScriptByte = the byte, pointer incremented
; Called from: runSoundScript at $66C7, $66D9, $66E4, $66F3 and $66F7
; ----------------------------------------------------------------------
fetchSoundScriptByte:
        lda     (soundScriptPtr,x)      ; 671C  (zp,X) with X = voice*2 picks this voice's script pointer
        sta     sndScriptByte           ; 671E  kept so runSoundScript can BIT it without disturbing A
        inc     soundScriptPtr,x        ; 6721  advance the pointer low byte
        bne     L_6727                  ; 6723  no page crossing
        inc     soundScriptPtrHi,x      ; 6725  carry into the high byte
L_6727:
        rts                             ; 6727  A holds the byte, the pointer now points past it


; ----------------------------------------------------------------------
; startSound - Starts sound A (0-28) on the voices its table row names.  Each row of soundPointerTable
; holds three script addresses, one per voice; a zero entry means this sound does not use that voice,
; which is then left playing whatever it had.  For every voice it does use, the script pointer is
; loaded, the voice's eight state slots are zeroed, the sound id is recorded and the duration is set
; to 2 so the script is first interpreted on the next-but-one driver tick.
; In:  A = sound id 0-28, soundPointerTable at $6831
; Out: sndVolume, sndCurrentSoundId, sndScratch, sndInitLock, sndStartVoiceIndex, sndVoiceSoundId,
;      the zero-page script pointers $D8-$DD and the voice state; X and Y clobbered
; Called from: the raster IRQ at $1289 with the id dequeued by $C8E9
; ----------------------------------------------------------------------
startSound:
        ldy     #$03                    ; 6728  3 = the number of voices, used as the loop counter...
        sty     sndInitLock             ; 672A  ...and, while it is non-zero, as the 'driver busy' lock read by soundPlayerTick
        ldy     #$0C                    ; 672D  volume 12 of 15
        sty     sndVolume               ; 672F  the driver writes this to $D418 every tick
        sta     sndCurrentSoundId       ; 6732  remember the id; it is copied into every voice slot this sound takes
        asl     a                       ; 6735  id*2
        sta     sndScratch              ; 6736  keep id*2 so it can be added to id*4 below
        asl     a                       ; 6739  id*4
        clc                             ; 673A  clear the carry for the id*4 + id*2 sum
        adc     sndScratch              ; 673B  id*4 + id*2 = id*6, the row stride of soundPointerTable
        tax                             ; 673E  X = byte offset of this sound's row
        ldy     #$00                    ; 673F  Y = voice*2, starting at voice 0
        sty     sndStartVoiceIndex      ; 6741  plain voice number 0-2, kept in parallel with Y

; ----------------------------------------------------------------------
; Once per voice: take the script address from the table row, or skip the voice if it is 0.
; ----------------------------------------------------------------------
L_6744:
        lda     soundPointerTableHi,x   ; 6744  high byte of this voice's script address (soundPointerTable+1, indexed by X)
        beq     L_6775                  ; 6747  0 = this sound does not use this voice; leave it playing
        sta     a:soundScriptPtrHi,y    ; 6749  absolute,Y because there is no STA zero page,Y - writes $D9 + voice*2
        lda     soundPointerTable,x     ; 674C  low byte of the script address
        sta     a:soundScriptPtr,y      ; 674F  writes $D8 + voice*2
        tya                             ; 6752  voice*2 for this voice
        pha                             ; 6753  save voice*2 across the clear loop, which walks Y through the whole block

; ----------------------------------------------------------------------
; Clear this voice's two bytes in each of the eight 6-byte rows of the state block, i.e. duration,
; waveform, both loop counters, the spares, frequency, pulse, AD/SR and the two slides.  Y starts at
; voice*2 and steps by 6 until it passes the end of the 48-byte block.
; ----------------------------------------------------------------------
L_6754:
        lda     #$00                    ; 6754  the value written into every slot
        sta     sndVoiceState,y         ; 6756  first byte of the row for this voice
        sta     sndVoiceCtrlByte,y      ; 6759  second byte of the row for this voice
        tya                             ; 675C  current offset inside the state block
        clc                             ; 675D  clear the carry for the +6 row step
        adc     #$06                    ; 675E  next 6-byte row
        tay                             ; 6760  Y = the same voice's slot in the next row
        cmp     #$30                    ; 6761  $30 = 48 bytes = eight rows: stop once Y has walked past the block
        bcc     L_6754                  ; 6763  keep clearing while Y is still inside the 48-byte block
        ldy     sndStartVoiceIndex      ; 6765  voice number 0-2
        lda     sndCurrentSoundId       ; 6768  the sound now playing
        sta     sndVoiceSoundId,y       ; 676B  record it so requestSound can weigh priorities against it
        pla                             ; 676E  get voice*2 back
        tay                             ; 676F  voice*2 back in Y
        lda     #$02                    ; 6770  duration 2 ticks...
        sta     sndVoiceState,y         ; 6772  ...so the script is first run on the second driver tick, not this one
L_6775:
        inx                             ; 6775  next 16-bit entry of the table row
        inx                             ; 6776  table entries are 16-bit, so step X by two
        iny                             ; 6777  next voice*2
        iny                             ; 6778  and Y by two, to the next voice's slot
        inc     sndStartVoiceIndex      ; 6779  next voice number
        dec     sndInitLock             ; 677C  3, 2, 1, 0 - and reaching 0 also releases soundPlayerTick again
        bne     L_6744                  ; 677F  three voices per sound
        rts                             ; 6781  every voice this sound uses is now armed


; sidVoiceRegOffsetTable - 5 bytes, indexed by voice*2 (0, 2, 4); the two odd bytes are padding. Value
; = distance of that voice's SID registers from $D400: $00, $07, $0E.  Read at $6691.
sidVoiceRegOffsetTable:
        .byte   $00                     ; 6782  voice 0 -> $D400, (pad), voice 1 -> $D407, (pad), voice 2 -> $D40E
        .byte   $00,$07,$00,$0E         ; 6783  ....

; sndCommandSlotTable - 11 bytes, indexed by script command 0-10.  Value = the offset of that
; command's state slot from sndVoiceState ($6792); the driver adds voice*2 to it.  Slots from $12 up
; hold 16-bit values and take two operand bytes.  Read at $66D5.
sndCommandSlotTable:
        .byte   $00                     ; 6787  0 duration, 1 waveform/gate, 2 loop A, 3 loop B, 4/5 spare, 6 frequency, 7 pulse width
        .byte   $01,$06,$07,$0C,$0D,$12,$18; 6788  .......
        .byte   $1E                     ; 678F  8 AD/SR, 9 frequency slide, 10 pulse-width slide
        .byte   $24,$2A                 ; 6790  $*

; sndVoiceState - 48 bytes of work RAM: eight 6-byte rows, each row holding one parameter for all
; three voices at row + voice*2.  The driver indexes everything from here.
;   $6792 row 0: +0 note duration in ticks, +1 waveform/gate byte
;   $6798 row 1: +0 loop counter A, +1 loop counter B
;   $679E row 2: +0/+1 two spare slots (script commands 4 and 5, never used by the shipped scripts)
;   $67A4 row 3: 16-bit oscillator frequency
;   $67AA row 4: 16-bit pulse width
;   $67B0 row 5: +0 attack/decay, +1 sustain/release
;   $67B6 row 6: 16-bit frequency slide added every tick
;   $67BC row 7: 16-bit pulse-width sweep added every tick
; startSound zeroes one voice's two bytes in every row; the whole block is RAM, the $00s below are
; only the state the loader happens to leave behind.
sndVoiceState:
        .byte   $00                     ; 6792  note duration of voice 0 (voices 1 and 2 at $6794 and $6796)

; Waveform/gate byte written to $D404 + voice*7: $11 triangle, $21 sawtooth, $41 pulse,
; $81 noise, each with the gate in bit 0; clearing bit 0 ($00/$20/$80) starts the release.
sndVoiceCtrlByte:
        .byte   $00                     ; 6793  waveform/gate byte of voice 0 ($6795 and $6797 for voices 1 and 2)
        .byte   $00,$00,$00,$00,$00,$00,$00,$00; 6794  ........
        .byte   $00,$00,$00,$00,$00,$00,$00,$00; 679C  ........

; 16-bit oscillator frequency, voice 0 at $67A4/$67A5, voice 1 at $67A6/$67A7, voice 2 at
; $67A8/$67A9.  Written to $D400/$D401 + voice*7 every tick.
sndVoiceFreqLo:
        .byte   $00                     ; 67A4  oscillator frequency of voice 0, low byte
sndVoiceFreqHi:
        .byte   $00                     ; 67A5  oscillator frequency of voice 0, high byte (then voices 1 and 2)
        .byte   $00,$00,$00,$00         ; 67A6  ....

; 16-bit pulse width -> $D402/$D403 + voice*7 (only the low 12 bits are used by the SID).
sndVoicePulseLo:
        .byte   $00                     ; 67AA  pulse width of voice 0, low byte
sndVoicePulseHi:
        .byte   $00                     ; 67AB  pulse width of voice 0, high byte (then voices 1 and 2)
        .byte   $00,$00,$00,$00         ; 67AC  ....

; Envelope: attack/decay -> $D405 + voice*7, sustain/release -> $D406 + voice*7.
sndVoiceAttackDecay:
        .byte   $00                     ; 67B0  attack/decay of voice 0
sndVoiceSustainRelease:
        .byte   $00                     ; 67B1  sustain/release of voice 0 (then voices 1 and 2)
        .byte   $00,$00,$00,$00         ; 67B2  ....

; 16-bit signed frequency slide added to the frequency once per tick (the pitch bends).
sndVoiceFreqStepLo:
        .byte   $00                     ; 67B6  per-tick frequency slide of voice 0, low byte
sndVoiceFreqStepHi:
        .byte   $00                     ; 67B7  per-tick frequency slide of voice 0, high byte (then voices 1 and 2)
        .byte   $00,$00,$00,$00         ; 67B8  ....

; 16-bit signed pulse-width sweep added to the pulse width once per tick.
sndVoicePulseStepLo:
        .byte   $00                     ; 67BC  per-tick pulse-width sweep of voice 0, low byte
sndVoicePulseStepHi:
        .byte   $00                     ; 67BD  per-tick pulse-width sweep of voice 0, high byte (then voices 1 and 2)
        .byte   $00,$00,$00,$00         ; 67BE  ....

; Sound driver work RAM, six separate bytes:
;   $67C2 sndVolume          master volume written to $D418 every tick ($0C playing, 0 silent)
;   $67C3 sndScriptByte      the script byte just fetched, kept for the BIT test at $66CA
;   $67C4 sndScratch         id*2 in startSound, loop slot number in runSoundScript
;   $67C5 sndInitLock        non-zero while startSound rebuilds the tables; also its voice counter
;   $67C6 sndCurrentSoundId  the id startSound is installing
;   $67C7 sndStartVoiceIndex the plain voice number 0-2 startSound is working on
sndVolume:
        .byte   $00                     ; 67C2  master volume written to $D418 every tick
sndScriptByte:
        .byte   $00                     ; 67C3  the script byte last fetched
sndScratch:
        .byte   $00                     ; 67C4  scratch: id*2 in startSound, loop slot in runSoundScript
sndInitLock:
        .byte   $00                     ; 67C5  non-zero = startSound is busy, soundPlayerTick must not run
sndCurrentSoundId:
        .byte   $00                     ; 67C6  the sound id startSound is installing
sndStartVoiceIndex:
        .byte   $00                     ; 67C7  voice number 0-2 startSound is working on

; sndVoiceSoundId - the sound id currently playing on each of the three voices, 0 = free.  The driver
; clears an entry when that voice's script ends; requestSound in the $C000 overlay ($C87D priority
; classes) reads it to decide whether a new sound may take a busy voice.  $67CA (voice 2) is read on
; its own at $3F07 and $C909/$C913 to see whether the comcen engine sound is running.
sndVoiceSoundId:
        .byte   $00                     ; 67C8  voice 0 and voice 1
        .byte   $00                     ; 67C9  .

; Voice 2 - the drone/comcen engine voice; tested by name elsewhere.
sndVoice2SoundId:
        .byte   $00                     ; 67CA  sound id playing on voice 2 (0 = free)

; ----------------------------------------------------------------------
; unpackRleTo0200 - Run-length decoder that always unpacks into the two pages $0200-$03FF.  The first
; byte of the source block is the escape marker; in the stream 'marker, count, value' emits value
; count times and any other byte is a literal.  The source pointer is kept as the self-modified
; operand of fetchRleByte.
; In:  A/Y = address of the packed block
; Out: $0200-$03FF filled, rleMarkerByte ($9246) = the marker, rleUnpackFlag ($9247) = 0,
;      glyphPtr/glyphPtrHi, scratch1A and scratch1C clobbered, fetchRleByte's operand left past the
;      end of the block
; Called from: overlay B at $7596 (block at $FD70) and $7FF0 (block at $FE24), and the $C000 overlay
;      at $CD10 for the five unit pictures at $CD52/$CDD3/$CE3B/$CECD/$CF3A; each caller then copies
;      the unpacked page somewhere else
; ----------------------------------------------------------------------
unpackRleTo0200:
        sta     L_67D6+1                ; 67CB  self-modify: low byte of the LDX operand at $67D6, which reads the marker
        sty     L_67D6+2                ; 67CE  and its high byte
        ldx     #$00                    ; 67D1  X = 0 for the flag store below
        stx     rleUnpackFlag           ; 67D3  $9247 = 0 (the overlay-B bitmap unpacker keeps a state flag here)

; ----------------------------------------------------------------------
; The operand of this LDX was patched two instructions ago.  The $FFFF the disassembly shows is simply
; what the loader left in the file - the instruction is never executed unpatched.
; ----------------------------------------------------------------------
L_67D6:
        ldx     irqVectorHi             ; 67D6  first byte of the block = the escape marker (operand patched at $67CB/$67CE)
        stx     rleMarkerByte           ; 67D9  keep the marker; the overlay-B unpacker at $865E compares against the same byte
        tax                             ; 67DC  A still holds the source low byte
        inx                             ; 67DD  step past the marker byte
        bne     L_67E1                  ; 67DE  no page crossing
        iny                             ; 67E0  carry into the source high byte
L_67E1:
        stx     fetchRleByte+1          ; 67E1  point fetchRleByte at the first packed byte (self-modified operand $681B)
        sty     fetchRleByte+2          ; 67E4  and its high byte ($681C)
        lda     #$00                    ; 67E7  destination low byte
        sta     glyphPtr                ; 67E9  destination pointer = $0200
        lda     #$02                    ; 67EB  destination page $02
        sta     glyphPtrHi              ; 67ED  the unpacked data always lands at $0200
        lda     #$02                    ; 67EF  two pages of output
        sta     scratch1A               ; 67F1  two pages to fill: $0200-$03FF
        ldy     #$00                    ; 67F3  offset inside the current page
        ldx     #$01                    ; 67F5  run counter: one byte still owed before the next stream byte is read

; ----------------------------------------------------------------------
; Emit one byte per pass.  X counts down the bytes still owed by the current run; when it reaches 0
; the next stream byte is fetched, which is either a literal (run of 1) or the escape marker followed
; by a count and a value.
; ----------------------------------------------------------------------
L_67F7:
        dex                             ; 67F7  one byte of the current run consumed
        bne     L_680E                  ; 67F8  still repeating - store the same value again
        jsr     fetchRleByte            ; 67FA  next byte of the packed stream
        ldx     #$01                    ; 67FD  assume a literal: a run of exactly one
        cmp     rleMarkerByte           ; 67FF  is it the escape marker?
        bne     L_680E                  ; 6802  no - store it as it stands
        jsr     fetchRleByte            ; 6804  escape: the repeat count comes next
        sta     scratch1C               ; 6807  remember how many copies to emit
        jsr     fetchRleByte            ; 6809  and then the byte to repeat
        ldx     scratch1C               ; 680C  X = how many copies to emit
L_680E:
        sta     (glyphPtr),y            ; 680E  store into $0200 + page*256 + Y
        iny                             ; 6810  next byte of the current page
        bne     L_67F7                  ; 6811  until the page is full
        inc     glyphPtrHi              ; 6813  next destination page
        dec     scratch1A               ; 6815  one page finished
        bne     L_67F7                  ; 6817  two pages done?
        rts                             ; 6819  $0200-$03FF now hold the unpacked block


; ----------------------------------------------------------------------
; fetchRleByte - Returns the next byte of the packed stream and advances the pointer.  The pointer is
; the absolute operand of the LDA below ($681B/$681C), patched by whoever starts an unpack.
; In:  the self-modified operand at $681B/$681C
; Out: A = X = the byte, the operand incremented by one
; Called from: unpackRleTo0200 at $67FA/$6804/$6809, and from overlay B's bitmap unpacker at
;      $8659/$8663/$8668, which sets $681B itself at $8644 and shares rleMarkerByte
; ----------------------------------------------------------------------
fetchRleByte:
        lda     irqVectorHi             ; 681A  read through the self-modified pointer; the $FFFF shown is only the loader's filler
        tax                             ; 681D  keep a copy - the routine hands the byte back in X as well
        lda     #$01                    ; 681E  the pointer is advanced by one byte
        clc                             ; 6820  clear the carry for the 16-bit increment
        adc     fetchRleByte+1          ; 6821  advance the stream pointer low byte
        sta     fetchRleByte+1          ; 6824  the operand of the LDA above is the pointer itself
        lda     #$00                    ; 6827  only the carry has to reach the high byte
        adc     fetchRleByte+2          ; 6829  and its high byte
        sta     fetchRleByte+2          ; 682C  high byte of the advanced stream pointer
        txa                             ; 682F  return the byte in A
        rts                             ; 6830  byte returned in A (and in X)


; soundPointerTable - 29 rows of 3 words, 6 bytes per row, indexed by sound id * 6.  Word n is the
; script address for SID voice n, or $0000 when the sound leaves that voice alone.  startSound reads
; the high byte through soundPointerTableHi (= this label + 1) and the low byte through this label.
; Sound 0 points all three voices at sndScriptSilence, so 'play sound 0' stops everything; ids $1A,
; $1B and $1C do the same for one voice each.  The names below come from the call sites of each id:
; $01 button click, $02 menu/typing click, $03 error beep, $04 alert chime, $05 incoming chat
; character, $06 phone prompt, $07 drone alert, $08 comcen hit, $09 unit destroyed, $0A no known
; caller, $0B boomer shot, $0C missile blast, $0D win fanfare, $0E loss fanfare, $0F drone launch,
; $10 drone turn, $11 drone strike, $12 repair failed, $13 repair succeeded, $14 comcen engine loop,
; $15 phone trouble, $16 own comcen moving, $17 damage/radar sweep, $18 attempting repair, $19 game
; over / warning chord.
soundPointerTable:
soundPointerTableHi      = soundPointerTable+1
        .addr   sndScriptSilence        ; 6831  sound $00 = stop all three voices
        .addr   sndScriptSilence        ; 6833
        .addr   sndScriptSilence        ; 6835
        .addr   sndScriptButtonClick    ; 6837
        .addr   CPU_DDR                 ; 6839
        .addr   CPU_DDR                 ; 683B
        .addr   sndScriptMenuClick      ; 683D
        .addr   CPU_DDR                 ; 683F
        .addr   CPU_DDR                 ; 6841
        .addr   sndScriptErrorBeep      ; 6843
        .addr   CPU_DDR                 ; 6845
        .addr   CPU_DDR                 ; 6847
        .addr   sndScriptAlertChime     ; 6849
        .addr   CPU_DDR                 ; 684B
        .addr   CPU_DDR                 ; 684D
        .addr   sndScriptChatKeyClick   ; 684F
        .addr   CPU_DDR                 ; 6851
        .addr   CPU_DDR                 ; 6853
        .addr   sndScriptPhonePromptV0  ; 6855
        .addr   sndScriptPhonePromptV1  ; 6857
        .addr   sndScriptSilence        ; 6859
        .addr   sndScriptDroneAlert     ; 685B
        .addr   CPU_DDR                 ; 685D
        .addr   CPU_DDR                 ; 685F
        .addr   CPU_DDR                 ; 6861
        .addr   sndScriptComcenHit      ; 6863
        .addr   CPU_DDR                 ; 6865
        .addr   CPU_DDR                 ; 6867
        .addr   sndScriptUnitDestroyed  ; 6869
        .addr   CPU_DDR                 ; 686B
        .addr   CPU_DDR                 ; 686D
        .addr   sndScriptUnusedShot     ; 686F
        .addr   CPU_DDR                 ; 6871
        .addr   CPU_DDR                 ; 6873
        .addr   sndScriptBoomerShot     ; 6875
        .addr   CPU_DDR                 ; 6877
        .addr   sndScriptMissileBlast   ; 6879
        .addr   CPU_DDR                 ; 687B
        .addr   CPU_DDR                 ; 687D
        .addr   sndScriptWinFanfareV0   ; 687F
        .addr   sndScriptWinFanfareV1   ; 6881
        .addr   sndScriptWinFanfareV2   ; 6883
        .addr   sndScriptLossFanfareV0  ; 6885
        .addr   sndScriptLossFanfareV1  ; 6887
        .addr   sndScriptLossFanfareV2  ; 6889
        .addr   CPU_DDR                 ; 688B
        .addr   CPU_DDR                 ; 688D
        .addr   sndScriptDroneLaunch    ; 688F
        .addr   CPU_DDR                 ; 6891
        .addr   CPU_DDR                 ; 6893
        .addr   sndScriptDroneTurn      ; 6895
        .addr   CPU_DDR                 ; 6897
        .addr   CPU_DDR                 ; 6899
        .addr   sndScriptDroneStrike    ; 689B
        .addr   CPU_DDR                 ; 689D
        .addr   CPU_DDR                 ; 689F
        .addr   sndScriptRepairFail     ; 68A1
        .addr   CPU_DDR                 ; 68A3
        .addr   CPU_DDR                 ; 68A5
        .addr   sndScriptRepairOk       ; 68A7
        .addr   CPU_DDR                 ; 68A9
        .addr   CPU_DDR                 ; 68AB
        .addr   sndScriptComcenEngine   ; 68AD
        .addr   sndScriptPhoneTroubleV0 ; 68AF
        .addr   sndScriptPhoneTroubleV1 ; 68B1
        .addr   sndScriptSilence        ; 68B3
        .addr   CPU_DDR                 ; 68B5
        .addr   CPU_DDR                 ; 68B7
        .addr   sndScriptOwnComcenMove  ; 68B9
        .addr   CPU_DDR                 ; 68BB
        .addr   CPU_DDR                 ; 68BD
        .addr   sndScriptDamageSweep    ; 68BF
        .addr   CPU_DDR                 ; 68C1
        .addr   CPU_DDR                 ; 68C3
        .addr   sndScriptRepairAttempt  ; 68C5
        .addr   sndScriptGameOverV0     ; 68C7
        .addr   sndScriptGameOverV1     ; 68C9
        .addr   sndScriptGameOverV2     ; 68CB
        .addr   sndScriptSilence        ; 68CD
        .addr   CPU_DDR                 ; 68CF
        .addr   CPU_DDR                 ; 68D1
        .addr   CPU_DDR                 ; 68D3
        .addr   sndScriptSilence        ; 68D5
        .addr   CPU_DDR                 ; 68D7
        .addr   CPU_DDR                 ; 68D9
        .addr   CPU_DDR                 ; 68DB
        .addr   sndScriptSilence        ; 68DD

; Sound script data - the 854 bytes $68DF-$6C34 are sound-driver bytecode interpreted by
; runSoundScript.  The scripts are packed back to back; the labels through to $6C1B are exactly the
; entry points soundPointerTable names, plus the shared end-of-sound tail at $68E3.  Several scripts
; jump into the middle of another one ($6ACE, $6B09, $6B24, $6C1E) to share a tail or a loop body,
; which is why the pieces below are not independent.
; 
; sndScriptSilence: waveform 0 (gate off) then a 60-tick rest.  Used by sound $00 on all three voices
; to stop everything, as the unused third voice of sounds $06 and $15, and by the single-voice stop
; sounds $1A, $1B and $1C.
sndScriptSilence:
        .byte   $01,$00,$00,$3C         ; 68DF  ...<

; Waveform 0, duration 0.  A duration of 0 makes updateSoundVoice free the voice, so this is the
; common 'end of sound' tail every '46 E3 68' jump in the scripts points at.
sndScriptEnd:
        .byte   $01,$00,$00,$00         ; 68E3  ....

; Sound $15 (PHONE TROUBLE) voice 0: one tone, then an unconditional jump into the voice 1 script at
; $68F6 so both voices run the same alternating body.
sndScriptPhoneTroubleV0:
        .byte   $06,$5D,$0F,$08,$00,$30,$46,$F6; 68E7  .]...0F.
        .byte   $68                     ; 68EF  h

; Sound $15 voice 1: two-tone alarm looping for ever at $68F9 (only stopped by sound $00).
sndScriptPhoneTroubleV1:
        .byte   $06,$54,$14,$08,$00,$10,$07,$00; 68F0  .T......
        .byte   $01,$01,$41,$00,$19,$01,$00,$00; 68F8  ..A.....
        .byte   $19,$46,$F9,$68         ; 6900  .F.h

; Sound $01, voice 0: the fire-button click played by checkFirePressed.
sndScriptButtonClick:
        .byte   $08,$00,$80,$06,$00,$62,$07,$00; 6904  .....b..
        .byte   $08,$01,$41,$00,$01,$46,$E3,$68; 690C  ..A..F.h

; Sound $03, voice 0: the error beep (TOO FAR!, rejected key, disk failure).
sndScriptErrorBeep:
        .byte   $08,$08,$00,$06,$00,$29,$07,$00; 6914  .....)..
        .byte   $08,$01,$41,$00,$0F,$46,$E3,$68; 691C  ..A..F.h

; Sound $02, voice 0: the short click when a menu selection moves or a chat key is taken.
sndScriptMenuClick:
        .byte   $06,$00,$18,$07,$00,$08,$01,$41; 6924  .......A
        .byte   $00,$01,$46,$E3,$68     ; 692C  ..F.h

; Sound $04, voice 0: the chime that goes with the alert messages (SPOTTED..., STUNNED...).
sndScriptAlertChime:
        .byte   $08,$00,$60,$06,$C6,$70,$07,$00; 6931  ..`..p..
        .byte   $08,$01,$41,$00,$0F,$46,$E3,$68; 6939  ..A..F.h

; Sound $05, voice 0: the click for each character received from the opponent's keyboard.
sndScriptChatKeyClick:
        .byte   $08,$00,$50,$06,$64,$32,$02,$03; 6941  ..P.d2..
        .byte   $01,$21,$00,$04,$01,$00,$00,$02; 6949  .!......
        .byte   $46,$49,$69,$00,$00     ; 6951  FIi..

; Sound $0C, voice 0: the comcen missile explosion (a falling sawtooth, freq slide $0095).
sndScriptMissileBlast:
        .byte   $08,$00,$80,$01,$21,$06,$60,$16; 6956  ....!.`.
        .byte   $09,$95,$00,$00,$1E,$01,$00,$00; 695E  ........
        .byte   $1E,$00,$00             ; 6966  ...

; Sound $07, voice 0: two-note warning that the opponent has launched a drone.
sndScriptDroneAlert:
        .byte   $01,$21,$08,$00,$60,$02,$03,$06; 6969  .!..`...
        .byte   $9C,$1A,$00,$14,$06,$4A,$12,$00; 6971  .....J..
        .byte   $14,$46,$70,$69,$46,$E3,$68; 6979  .FpiF.h

; Sound $06 voice 0: the two-voice attention chord behind the 'PICK UP PHONE' / 'HANG UP PHONE'
; messages (message ids $17 and $18 in messageSoundTable).
sndScriptPhonePromptV0:
        .byte   $08,$00,$20,$07,$00,$08,$02,$02; 6980  .. .....
        .byte   $06,$82,$54,$01,$41,$00,$04,$01; 6988  ..T.A...
        .byte   $00,$00,$03,$46,$88,$69,$06,$45; 6990  ...F.i.E
        .byte   $4B,$01,$41,$00,$04,$01,$00,$00; 6998  K.A.....
        .byte   $03,$02,$02,$06,$BF,$5D,$01,$41; 69A0  .....].A
        .byte   $00,$04,$01,$00,$00,$03,$46,$A3; 69A8  ......F.
        .byte   $69,$06,$82,$54,$01,$41,$00,$04; 69B0  i..T.A..
        .byte   $01,$00,$00,$03,$06,$BF,$5D,$01; 69B8  ......].
        .byte   $41,$00,$04,$01,$00,$00,$03,$00; 69C0  A.......
        .byte   $00                     ; 69C8  .

; Sound $06 voice 1: the second part of the same chord.
sndScriptPhonePromptV1:
        .byte   $08,$00,$20,$07,$00,$08,$02,$02; 69C9  .. .....
        .byte   $06,$C1,$2C,$01,$41,$00,$04,$01; 69D1  ..,.A...
        .byte   $00,$00,$03,$46,$D1,$69,$06,$BD; 69D9  ...F.i..
        .byte   $36,$01,$41,$00,$04,$01,$00,$00; 69E1  6.A.....
        .byte   $03,$06,$BF,$31,$01,$41,$00,$04; 69E9  ...1.A..
        .byte   $01,$00,$00,$03,$06,$C1,$2C,$01; 69F1  ......,.
        .byte   $41,$00,$04,$01,$00,$00,$03,$06; 69F9  A.......
        .byte   $BB,$3B,$01,$41,$00,$04,$01,$00; 6A01  .;.A....
        .byte   $00,$03,$06,$C1,$2C,$01,$41,$00; 6A09  ....,.A.
        .byte   $04,$01,$00,$00,$03,$00,$00; 6A11  .......

; Sound $08, voice 1: noise burst played when a comcen is hit.
sndScriptComcenHit:
        .byte   $01,$81,$08,$00,$FB,$02,$0E,$06; 6A18  ........
        .byte   $5A,$02,$00,$03,$01,$80,$06,$90; 6A20  Z.......
        .byte   $01,$00,$03,$46,$1F,$6A,$00,$00; 6A28  ...F.j..

; Sound $09, voice 1: the noise-into-tone whoosh when a unit is destroyed.
sndScriptUnitDestroyed:
        .byte   $01,$81,$08,$06,$9B,$06,$BC,$02; 6A30  ........
        .byte   $00,$04,$01,$80,$09,$12,$00,$02; 6A38  ........
        .byte   $08,$06,$E8,$03,$00,$08,$46,$41; 6A40  ......FA
        .byte   $6A,$46,$E3,$68         ; 6A48  jF.h

; Sound $0A, voice 1.  No call site for id $0A was found anywhere in the game, so this script looks
; like a leftover shot effect; it is the only script that uses the bit-7 'jitter' command.
sndScriptUnusedShot:
        .byte   $01,$21,$08,$00,$38,$06,$5C,$12; 6A4C  .!..8.\.
        .byte   $80,$02,$04,$09,$5C,$12,$00,$01; 6A54  ....\...
        .byte   $09,$A4,$ED,$00,$01,$46,$57,$6A; 6A5C  .....FWj
        .byte   $09,$5C,$12,$00,$01,$01,$20,$09; 6A64  .\.... .
        .byte   $D4,$FE,$00,$14,$46,$E3,$68; 6A6C  ....F.h

; Sound $0B, voice 1: the boomer's shot (noise with a downward slide).
sndScriptBoomerShot:
        .byte   $01,$81,$08,$07,$00,$06,$84,$03; 6A73  ........
        .byte   $09,$D8,$FF,$00,$12,$46,$E3,$68; 6A7B  .....F.h

; Sound $11, voice 2: the drone detonation / strike flash.
sndScriptDroneStrike:
        .byte   $01,$41,$08,$00,$50,$07,$00,$08; 6A83  .A..P...
        .byte   $06,$40,$9C,$09,$38,$FF,$00,$2D; 6A8B  .@..8..-
        .byte   $01,$00,$00,$02,$09,$00,$00; 6A93  .......

; Sound $17, voice 2: the tone under the expanding damage/radar frames ($3D2E, $79F5).
sndScriptDamageSweep:
        .byte   $01,$81,$08,$09,$00,$06,$00,$02; 6A9A  ........
        .byte   $00,$1E,$46,$E3,$68     ; 6AA2  ..F.h

; Sound $0D voice 0: the winning chord (WE WON ON POINTS / BY A KNOCKOUT).  Voices 0 and 1 set their
; own pitch and then jump into the shared tail at $6ACE inside the voice 2 script.
sndScriptWinFanfareV0:
        .byte   $01,$21,$08,$00,$47,$06,$47,$06; 6AA7  .!..G.G.
        .byte   $09,$A6,$00,$46,$CE,$6A ; 6AAF  ...F.j

; Sound $0D voice 1: same body, different pitch; also jumps to the tail at $6ACE.
sndScriptWinFanfareV1:
        .byte   $01,$21,$08,$00,$47,$06,$47,$06; 6AB5  .!..G.G.
        .byte   $09,$5D,$00,$46,$CE,$6A ; 6ABD  .].F.j

; Sound $0D voice 2: the third note and, from $6ACE on, the shared decay all three voices run.
sndScriptWinFanfareV2:
        .byte   $01,$21,$08,$00,$47,$06,$47,$06; 6AC3  .!..G.G.
        .byte   $09,$16,$00,$00,$78,$09,$00,$00; 6ACB  ....x...
        .byte   $00,$5A,$01,$20,$00,$14,$46,$E3; 6AD3  .Z. ..F.
        .byte   $68                     ; 6ADB  h

; Sound $0E voice 0: the losing chord (WE LOST ON POINTS / WE WERE KNOCKED OUT).  Voices 0 and 1 jump
; into the shared tail at $6B09 inside the voice 2 script.
sndScriptLossFanfareV0:
        .byte   $01,$21,$08,$00,$4D,$06,$87,$21; 6ADC  .!..M..!
        .byte   $00,$2D,$09,$C2,$FF,$46,$09,$6B; 6AE4  .-...F.k

; Sound $0E voice 1: same body one interval down; also jumps to $6B09.
sndScriptLossFanfareV1:
        .byte   $01,$21,$08,$00,$4D,$06,$6B,$2F; 6AEC  .!..M.k/
        .byte   $00,$2D,$09,$A5,$FF,$46,$09,$6B; 6AF4  .-...F.k

; Sound $0E voice 2: the third note plus the shared falling tail from $6B09.
sndScriptLossFanfareV2:
        .byte   $01,$21,$08,$00,$4D,$06,$3C,$32; 6AFC  .!..M.<2
        .byte   $00,$2D,$09,$9F,$FF,$00,$78,$09; 6B04  .-....x.
        .byte   $00,$00,$00,$14,$01,$20,$00,$C8; 6B0C  ..... ..
        .byte   $46,$E3,$68             ; 6B14  F.h

; Sound $0F, voice 2: the drone launch, which then settles into the engine loop at $6B24.
sndScriptDroneLaunch:
        .byte   $01,$81,$08,$0A,$20,$06,$E8,$03; 6B17  .... ...
        .byte   $09,$0A,$00,$00,$F0,$01,$81,$08; 6B1F  ........
        .byte   $00,$20,$06,$48,$0D,$09,$00,$00; 6B27  . .H....
        .byte   $00,$01,$07,$96,$00,$01,$41,$00; 6B2F  ......A.
        .byte   $01,$46,$24,$6B         ; 6B37  .F$k

; Sound $10, voice 2: the pitch wobble as the drone turns one step, ending by jumping back into the
; engine loop of sndScriptDroneLaunch at $6B24.
sndScriptDroneTurn:
        .byte   $06,$48,$0D,$08,$99,$20,$02,$07; 6B3B  .H... ..
        .byte   $09,$0E,$00,$01,$81,$00,$01,$07; 6B43  ........
        .byte   $96,$00,$01,$41,$00,$01,$46,$43; 6B4B  ...A..FC
        .byte   $6B,$02,$07,$01,$81,$09,$F2,$FF; 6B53  k.......
        .byte   $00,$01,$01,$41,$00,$01,$46,$56; 6B5B  ...A..FV
        .byte   $6B,$46,$24,$6B         ; 6B63  kF$k

; Sound $18, voice 2: the repeating warble under 'ATTEMPTING REPAIR...'.
sndScriptRepairAttempt:
        .byte   $06,$08,$07,$08,$00,$20,$07,$00; 6B67  ..... ..
        .byte   $08,$01,$41,$09,$C8,$00,$00,$06; 6B6F  ..A.....
        .byte   $09,$50,$FB,$00,$01,$09,$38,$FF; 6B77  .P....8.
        .byte   $00,$06,$09,$00,$05,$00,$01,$46; 6B7F  .......F
        .byte   $72,$6B                 ; 6B87  rk

; Sound $13, voice 2: the rising sweep that reports a successful repair.
sndScriptRepairOk:
        .byte   $06,$A4,$06,$01,$21,$08,$00,$20; 6B89  ....!.. 
        .byte   $02,$1E,$09,$C8,$00,$00,$02,$09; 6B91  ........
        .byte   $6A,$FF,$00,$01,$46,$93,$6B,$46; 6B99  j...F.kF
        .byte   $E3,$68                 ; 6BA1  .h

; Sound $12, voice 2: the two-counter stutter that reports a failed repair.
sndScriptRepairFail:
        .byte   $06,$E0,$15,$01,$21,$08,$00,$20; 6BA3  ....!.. 
        .byte   $02,$03,$03,$0A,$09,$06,$FF,$00; 6BAB  ........
        .byte   $01,$09,$96,$00,$00,$01,$47,$AF; 6BB3  ......G.
        .byte   $6B,$09,$A8,$FD,$00,$01,$46,$AD; 6BBB  k.....F.
        .byte   $6B,$46,$E3,$68         ; 6BC3  kF.h

; Sound $16, voice 2: played while the player's own comcen is moving in a live game.
sndScriptOwnComcenMove:
        .byte   $06,$E8,$03,$08,$00,$40,$09,$04; 6BC7  .....@..
        .byte   $00,$03,$02,$02,$0F,$01,$81,$00; 6BCF  ........
        .byte   $01,$01,$21,$00,$01,$46,$D4,$6B; 6BD7  ..!..F.k
        .byte   $09,$FC,$FF,$47,$D2,$6B,$09,$00; 6BDF  ...G.k..
        .byte   $00                     ; 6BE7  .

; Sound $14, voice 2: the comcen engine loop, kept running while the comcen is moving and stopped
; again with sound $1C.
sndScriptComcenEngine:
        .byte   $06,$E8,$03,$08,$00,$10,$01,$81; 6BE8  ........
        .byte   $00,$01,$01,$21,$00,$01,$46,$EE; 6BF0  ...!..F.
        .byte   $6B,$00,$00             ; 6BF8  k..

; Sound $19 voice 0: the alarm chord used for GAME OVER, TIMEOUT, RESUMED and the LAST QTR WARNING.
sndScriptGameOverV0:
        .byte   $08,$00,$70,$07,$00,$08,$01,$41; 6BFB  ..p....A
        .byte   $02,$16,$06,$06,$B3,$00,$01,$06; 6C03  ........
        .byte   $8B,$96,$00,$01,$46,$05,$6C,$46; 6C0B  ....F.lF
        .byte   $E3,$68                 ; 6C13  .h

; Sound $19 voice 1: sets its own pitch and jumps into the middle of the voice 2 script at $6C1E, so
; voices 1 and 2 share one body.
sndScriptGameOverV1:
        .byte   $06,$FA,$A8,$46,$1E,$6C ; 6C15  ...F.l

; Sound $19 voice 2: the body both it and voice 1 run from $6C1E.
sndScriptGameOverV2:
        .byte   $06,$00,$AE,$01,$41,$07,$00,$08; 6C1B  ....A...
        .byte   $02,$16,$08,$00,$70,$00,$01,$08; 6C23  ....p...
        .byte   $00,$30,$00,$01,$46,$25,$6C,$46; 6C2B  .0..F%lF
        .byte   $E3,$68                 ; 6C33  .h

; unitGlyphPointerTable - 20 words, one per unit picture, indexed by unitType*4 + facing.  The
; viewport tile builder computes the index at $289A-$28A7 as (unitFlagsTable[unit] & 3) +
; unitGlyphBaseTable[type] - $2C, where unitGlyphBaseTable ($0799) holds $2C,$30,$34,$38,$3C for
; GRUNT, RIDER, BOOMER, SPY and COMCEN, so the subtraction turns it into type*4 + facing.  It then
; copies the 32 bytes at the pointer into the cell buffer at $28B5.
unitGlyphPointerTable:
        .addr   unitGlyphGruntUp        ; 6C35  GRUNT facing 0
        .addr   unitGlyphGruntDown      ; 6C37
        .addr   unitGlyphGruntLeft      ; 6C39
        .addr   unitGlyphGruntRight     ; 6C3B
        .addr   unitGlyphRiderUp        ; 6C3D
        .addr   unitGlyphRiderDown      ; 6C3F
        .addr   unitGlyphRiderLeft      ; 6C41
        .addr   unitGlyphRiderRight     ; 6C43
        .addr   unitGlyphBoomerUp       ; 6C45
        .addr   unitGlyphBoomerDown     ; 6C47
        .addr   unitGlyphBoomerLeft     ; 6C49
        .addr   unitGlyphBoomerRight    ; 6C4B
        .addr   unitGlyphSpyUp          ; 6C4D
        .addr   unitGlyphSpyDown        ; 6C4F
        .addr   unitGlyphSpyLeft        ; 6C51
        .addr   unitGlyphSpyRight       ; 6C53
        .addr   unitGlyphComcenUp       ; 6C55
        .addr   unitGlyphComcenDown     ; 6C57
        .addr   unitGlyphComcenLeft     ; 6C59
        .addr   unitGlyphComcenRight    ; 6C5B

; Unit glyph shapes - the 640 bytes $6C5D-$6EDC are 20 unit pictures of 32 bytes, one for each unit
; type and facing, in the order unitGlyphPointerTable indexes them (type*4 + facing). A picture is one
; map cell of the 7x5 tactical view: 2x2 character cells = 16x16 pixels, stored as top-left,
; top-right, bottom-left and bottom-right, 8 bytes each, which is why the bit art below shows each
; picture as four separate 8x8 blocks rather than one square. The bitmap is multicolour, so a pixel is
; a pair of bits and every pixel drawn here is %11, whose colour comes from colour RAM - that is how
; one shape serves both sides in their own colour. Facings 0 and 1 are a vertical mirror pair and 2
; and 3 a horizontal pair; the survey reads them as up, down, left, right.
; 
; GRUNT, facing 0 (up) - glyph index 0.  32 bytes: TL, TR, BL, BR.
unitGlyphGruntUp:
        .byte   $00                     ; 6C5D  ........
        .byte   $00                     ; 6C5E  ........
        .byte   $00                     ; 6C5F  ........
        .byte   $03                     ; 6C60  ......##
        .byte   $03                     ; 6C61  ......##
        .byte   $03                     ; 6C62  ......##
        .byte   $0F                     ; 6C63  ....####
        .byte   $0F                     ; 6C64  ....####

        .byte   $00                     ; 6C65  ........
        .byte   $00                     ; 6C66  ........
        .byte   $00                     ; 6C67  ........
        .byte   $00                     ; 6C68  ........
        .byte   $00                     ; 6C69  ........
        .byte   $00                     ; 6C6A  ........
        .byte   $C0                     ; 6C6B  ##......
        .byte   $C0                     ; 6C6C  ##......

        .byte   $33                     ; 6C6D  ..##..##
        .byte   $3F                     ; 6C6E  ..######
        .byte   $3F                     ; 6C6F  ..######
        .byte   $3F                     ; 6C70  ..######
        .byte   $0F                     ; 6C71  ....####
        .byte   $00                     ; 6C72  ........
        .byte   $00                     ; 6C73  ........
        .byte   $00                     ; 6C74  ........

        .byte   $30                     ; 6C75  ..##....
        .byte   $F0                     ; 6C76  ####....
        .byte   $F0                     ; 6C77  ####....
        .byte   $F0                     ; 6C78  ####....
        .byte   $C0                     ; 6C79  ##......
        .byte   $00                     ; 6C7A  ........
        .byte   $00                     ; 6C7B  ........
        .byte   $00                     ; 6C7C  ........


; GRUNT, facing 1 (down) - glyph index 1.  32 bytes: TL, TR, BL, BR.
unitGlyphGruntDown:
        .byte   $00                     ; 6C7D  ........
        .byte   $00                     ; 6C7E  ........
        .byte   $00                     ; 6C7F  ........
        .byte   $0F                     ; 6C80  ....####
        .byte   $3F                     ; 6C81  ..######
        .byte   $3F                     ; 6C82  ..######
        .byte   $3F                     ; 6C83  ..######
        .byte   $33                     ; 6C84  ..##..##

        .byte   $00                     ; 6C85  ........
        .byte   $00                     ; 6C86  ........
        .byte   $00                     ; 6C87  ........
        .byte   $C0                     ; 6C88  ##......
        .byte   $F0                     ; 6C89  ####....
        .byte   $F0                     ; 6C8A  ####....
        .byte   $F0                     ; 6C8B  ####....
        .byte   $30                     ; 6C8C  ..##....

        .byte   $0F                     ; 6C8D  ....####
        .byte   $0F                     ; 6C8E  ....####
        .byte   $03                     ; 6C8F  ......##
        .byte   $03                     ; 6C90  ......##
        .byte   $03                     ; 6C91  ......##
        .byte   $00                     ; 6C92  ........
        .byte   $00                     ; 6C93  ........
        .byte   $00                     ; 6C94  ........

        .byte   $C0                     ; 6C95  ##......
        .byte   $C0                     ; 6C96  ##......
        .byte   $00                     ; 6C97  ........
        .byte   $00                     ; 6C98  ........
        .byte   $00                     ; 6C99  ........
        .byte   $00                     ; 6C9A  ........
        .byte   $00                     ; 6C9B  ........
        .byte   $00                     ; 6C9C  ........


; GRUNT, facing 2 (left) - glyph index 2.  32 bytes: TL, TR, BL, BR.
unitGlyphGruntLeft:
        .byte   $00                     ; 6C9D  ........
        .byte   $00                     ; 6C9E  ........
        .byte   $00                     ; 6C9F  ........
        .byte   $0F                     ; 6CA0  ....####
        .byte   $0F                     ; 6CA1  ....####
        .byte   $3F                     ; 6CA2  ..######
        .byte   $3C                     ; 6CA3  ..####..
        .byte   $3F                     ; 6CA4  ..######

        .byte   $00                     ; 6CA5  ........
        .byte   $00                     ; 6CA6  ........
        .byte   $00                     ; 6CA7  ........
        .byte   $00                     ; 6CA8  ........
        .byte   $00                     ; 6CA9  ........
        .byte   $C0                     ; 6CAA  ##......
        .byte   $C0                     ; 6CAB  ##......
        .byte   $FC                     ; 6CAC  ######..

        .byte   $3F                     ; 6CAD  ..######
        .byte   $3C                     ; 6CAE  ..####..
        .byte   $3F                     ; 6CAF  ..######
        .byte   $0F                     ; 6CB0  ....####
        .byte   $0F                     ; 6CB1  ....####
        .byte   $00                     ; 6CB2  ........
        .byte   $00                     ; 6CB3  ........
        .byte   $00                     ; 6CB4  ........

        .byte   $FC                     ; 6CB5  ######..
        .byte   $C0                     ; 6CB6  ##......
        .byte   $C0                     ; 6CB7  ##......
        .byte   $00                     ; 6CB8  ........
        .byte   $00                     ; 6CB9  ........
        .byte   $00                     ; 6CBA  ........
        .byte   $00                     ; 6CBB  ........
        .byte   $00                     ; 6CBC  ........


; GRUNT, facing 3 (right) - glyph index 3.  32 bytes: TL, TR, BL, BR.
unitGlyphGruntRight:
        .byte   $00                     ; 6CBD  ........
        .byte   $00                     ; 6CBE  ........
        .byte   $00                     ; 6CBF  ........
        .byte   $00                     ; 6CC0  ........
        .byte   $00                     ; 6CC1  ........
        .byte   $03                     ; 6CC2  ......##
        .byte   $03                     ; 6CC3  ......##
        .byte   $3F                     ; 6CC4  ..######

        .byte   $00                     ; 6CC5  ........
        .byte   $00                     ; 6CC6  ........
        .byte   $00                     ; 6CC7  ........
        .byte   $F0                     ; 6CC8  ####....
        .byte   $F0                     ; 6CC9  ####....
        .byte   $FC                     ; 6CCA  ######..
        .byte   $3C                     ; 6CCB  ..####..
        .byte   $FC                     ; 6CCC  ######..

        .byte   $3F                     ; 6CCD  ..######
        .byte   $03                     ; 6CCE  ......##
        .byte   $03                     ; 6CCF  ......##
        .byte   $00                     ; 6CD0  ........
        .byte   $00                     ; 6CD1  ........
        .byte   $00                     ; 6CD2  ........
        .byte   $00                     ; 6CD3  ........
        .byte   $00                     ; 6CD4  ........

        .byte   $FC                     ; 6CD5  ######..
        .byte   $3C                     ; 6CD6  ..####..
        .byte   $FC                     ; 6CD7  ######..
        .byte   $F0                     ; 6CD8  ####....
        .byte   $F0                     ; 6CD9  ####....
        .byte   $00                     ; 6CDA  ........
        .byte   $00                     ; 6CDB  ........
        .byte   $00                     ; 6CDC  ........


; RIDER, facing 0 (up) - glyph index 4.  32 bytes: TL, TR, BL, BR.
unitGlyphRiderUp:
        .byte   $00                     ; 6CDD  ........
        .byte   $00                     ; 6CDE  ........
        .byte   $00                     ; 6CDF  ........
        .byte   $00                     ; 6CE0  ........
        .byte   $03                     ; 6CE1  ......##
        .byte   $33                     ; 6CE2  ..##..##
        .byte   $33                     ; 6CE3  ..##..##
        .byte   $0F                     ; 6CE4  ....####

        .byte   $00                     ; 6CE5  ........
        .byte   $00                     ; 6CE6  ........
        .byte   $00                     ; 6CE7  ........
        .byte   $00                     ; 6CE8  ........
        .byte   $00                     ; 6CE9  ........
        .byte   $30                     ; 6CEA  ..##....
        .byte   $30                     ; 6CEB  ..##....
        .byte   $C0                     ; 6CEC  ##......

        .byte   $0F                     ; 6CED  ....####
        .byte   $FF                     ; 6CEE  ########
        .byte   $F0                     ; 6CEF  ####....
        .byte   $C0                     ; 6CF0  ##......
        .byte   $00                     ; 6CF1  ........
        .byte   $00                     ; 6CF2  ........
        .byte   $00                     ; 6CF3  ........
        .byte   $00                     ; 6CF4  ........

        .byte   $C0                     ; 6CF5  ##......
        .byte   $FC                     ; 6CF6  ######..
        .byte   $3C                     ; 6CF7  ..####..
        .byte   $0C                     ; 6CF8  ....##..
        .byte   $00                     ; 6CF9  ........
        .byte   $00                     ; 6CFA  ........
        .byte   $00                     ; 6CFB  ........
        .byte   $00                     ; 6CFC  ........


; RIDER, facing 1 (down) - glyph index 5.  32 bytes: TL, TR, BL, BR.
unitGlyphRiderDown:
        .byte   $00                     ; 6CFD  ........
        .byte   $00                     ; 6CFE  ........
        .byte   $00                     ; 6CFF  ........
        .byte   $C0                     ; 6D00  ##......
        .byte   $F0                     ; 6D01  ####....
        .byte   $FF                     ; 6D02  ########
        .byte   $0F                     ; 6D03  ....####
        .byte   $0F                     ; 6D04  ....####

        .byte   $00                     ; 6D05  ........
        .byte   $00                     ; 6D06  ........
        .byte   $00                     ; 6D07  ........
        .byte   $0C                     ; 6D08  ....##..
        .byte   $3C                     ; 6D09  ..####..
        .byte   $FC                     ; 6D0A  ######..
        .byte   $C0                     ; 6D0B  ##......
        .byte   $C0                     ; 6D0C  ##......

        .byte   $33                     ; 6D0D  ..##..##
        .byte   $33                     ; 6D0E  ..##..##
        .byte   $03                     ; 6D0F  ......##
        .byte   $00                     ; 6D10  ........
        .byte   $00                     ; 6D11  ........
        .byte   $00                     ; 6D12  ........
        .byte   $00                     ; 6D13  ........
        .byte   $00                     ; 6D14  ........

        .byte   $30                     ; 6D15  ..##....
        .byte   $30                     ; 6D16  ..##....
        .byte   $00                     ; 6D17  ........
        .byte   $00                     ; 6D18  ........
        .byte   $00                     ; 6D19  ........
        .byte   $00                     ; 6D1A  ........
        .byte   $00                     ; 6D1B  ........
        .byte   $00                     ; 6D1C  ........


; RIDER, facing 2 (left) - glyph index 6.  32 bytes: TL, TR, BL, BR.
unitGlyphRiderLeft:
        .byte   $00                     ; 6D1D  ........
        .byte   $00                     ; 6D1E  ........
        .byte   $3C                     ; 6D1F  ..####..
        .byte   $3C                     ; 6D20  ..####..
        .byte   $0C                     ; 6D21  ....##..
        .byte   $0F                     ; 6D22  ....####
        .byte   $0F                     ; 6D23  ....####
        .byte   $0F                     ; 6D24  ....####

        .byte   $00                     ; 6D25  ........
        .byte   $00                     ; 6D26  ........
        .byte   $00                     ; 6D27  ........
        .byte   $00                     ; 6D28  ........
        .byte   $C0                     ; 6D29  ##......
        .byte   $C0                     ; 6D2A  ##......
        .byte   $00                     ; 6D2B  ........
        .byte   $F0                     ; 6D2C  ####....

        .byte   $0F                     ; 6D2D  ....####
        .byte   $0F                     ; 6D2E  ....####
        .byte   $0F                     ; 6D2F  ....####
        .byte   $0C                     ; 6D30  ....##..
        .byte   $3C                     ; 6D31  ..####..
        .byte   $3C                     ; 6D32  ..####..
        .byte   $00                     ; 6D33  ........
        .byte   $00                     ; 6D34  ........

        .byte   $F0                     ; 6D35  ####....
        .byte   $00                     ; 6D36  ........
        .byte   $C0                     ; 6D37  ##......
        .byte   $C0                     ; 6D38  ##......
        .byte   $00                     ; 6D39  ........
        .byte   $00                     ; 6D3A  ........
        .byte   $00                     ; 6D3B  ........
        .byte   $00                     ; 6D3C  ........


; RIDER, facing 3 (right) - glyph index 7.  32 bytes: TL, TR, BL, BR.
unitGlyphRiderRight:
        .byte   $00                     ; 6D3D  ........
        .byte   $00                     ; 6D3E  ........
        .byte   $00                     ; 6D3F  ........
        .byte   $00                     ; 6D40  ........
        .byte   $0C                     ; 6D41  ....##..
        .byte   $0F                     ; 6D42  ....####
        .byte   $03                     ; 6D43  ......##
        .byte   $3F                     ; 6D44  ..######

        .byte   $00                     ; 6D45  ........
        .byte   $00                     ; 6D46  ........
        .byte   $F0                     ; 6D47  ####....
        .byte   $F0                     ; 6D48  ####....
        .byte   $C0                     ; 6D49  ##......
        .byte   $C0                     ; 6D4A  ##......
        .byte   $C0                     ; 6D4B  ##......
        .byte   $C0                     ; 6D4C  ##......

        .byte   $3F                     ; 6D4D  ..######
        .byte   $03                     ; 6D4E  ......##
        .byte   $0F                     ; 6D4F  ....####
        .byte   $0C                     ; 6D50  ....##..
        .byte   $00                     ; 6D51  ........
        .byte   $00                     ; 6D52  ........
        .byte   $00                     ; 6D53  ........
        .byte   $00                     ; 6D54  ........

        .byte   $C0                     ; 6D55  ##......
        .byte   $C0                     ; 6D56  ##......
        .byte   $C0                     ; 6D57  ##......
        .byte   $C0                     ; 6D58  ##......
        .byte   $F0                     ; 6D59  ####....
        .byte   $F0                     ; 6D5A  ####....
        .byte   $00                     ; 6D5B  ........
        .byte   $00                     ; 6D5C  ........


; BOOMER, facing 0 (up) - glyph index 8.  32 bytes: TL, TR, BL, BR.
unitGlyphBoomerUp:
        .byte   $00                     ; 6D5D  ........
        .byte   $0F                     ; 6D5E  ....####
        .byte   $03                     ; 6D5F  ......##
        .byte   $03                     ; 6D60  ......##
        .byte   $03                     ; 6D61  ......##
        .byte   $03                     ; 6D62  ......##
        .byte   $03                     ; 6D63  ......##
        .byte   $C3                     ; 6D64  ##....##

        .byte   $00                     ; 6D65  ........
        .byte   $C0                     ; 6D66  ##......
        .byte   $00                     ; 6D67  ........
        .byte   $00                     ; 6D68  ........
        .byte   $00                     ; 6D69  ........
        .byte   $00                     ; 6D6A  ........
        .byte   $00                     ; 6D6B  ........
        .byte   $0C                     ; 6D6C  ....##..

        .byte   $FF                     ; 6D6D  ########
        .byte   $FF                     ; 6D6E  ########
        .byte   $FF                     ; 6D6F  ########
        .byte   $FF                     ; 6D70  ########
        .byte   $FF                     ; 6D71  ########
        .byte   $C0                     ; 6D72  ##......
        .byte   $00                     ; 6D73  ........
        .byte   $00                     ; 6D74  ........

        .byte   $FC                     ; 6D75  ######..
        .byte   $FC                     ; 6D76  ######..
        .byte   $FC                     ; 6D77  ######..
        .byte   $FC                     ; 6D78  ######..
        .byte   $FC                     ; 6D79  ######..
        .byte   $0C                     ; 6D7A  ....##..
        .byte   $00                     ; 6D7B  ........
        .byte   $00                     ; 6D7C  ........


; BOOMER, facing 1 (down) - glyph index 9.  32 bytes: TL, TR, BL, BR.
unitGlyphBoomerDown:
        .byte   $00                     ; 6D7D  ........
        .byte   $00                     ; 6D7E  ........
        .byte   $C0                     ; 6D7F  ##......
        .byte   $FF                     ; 6D80  ########
        .byte   $FF                     ; 6D81  ########
        .byte   $FF                     ; 6D82  ########
        .byte   $FF                     ; 6D83  ########
        .byte   $FF                     ; 6D84  ########

        .byte   $00                     ; 6D85  ........
        .byte   $00                     ; 6D86  ........
        .byte   $0C                     ; 6D87  ....##..
        .byte   $FC                     ; 6D88  ######..
        .byte   $FC                     ; 6D89  ######..
        .byte   $FC                     ; 6D8A  ######..
        .byte   $FC                     ; 6D8B  ######..
        .byte   $FC                     ; 6D8C  ######..

        .byte   $C3                     ; 6D8D  ##....##
        .byte   $03                     ; 6D8E  ......##
        .byte   $03                     ; 6D8F  ......##
        .byte   $03                     ; 6D90  ......##
        .byte   $03                     ; 6D91  ......##
        .byte   $03                     ; 6D92  ......##
        .byte   $0F                     ; 6D93  ....####
        .byte   $00                     ; 6D94  ........

        .byte   $0C                     ; 6D95  ....##..
        .byte   $00                     ; 6D96  ........
        .byte   $00                     ; 6D97  ........
        .byte   $00                     ; 6D98  ........
        .byte   $00                     ; 6D99  ........
        .byte   $00                     ; 6D9A  ........
        .byte   $C0                     ; 6D9B  ##......
        .byte   $00                     ; 6D9C  ........


; BOOMER, facing 2 (left) - glyph index 10.  32 bytes: TL, TR, BL, BR.
unitGlyphBoomerLeft:
        .byte   $00                     ; 6D9D  ........
        .byte   $00                     ; 6D9E  ........
        .byte   $00                     ; 6D9F  ........
        .byte   $FF                     ; 6DA0  ########
        .byte   $3F                     ; 6DA1  ..######
        .byte   $3F                     ; 6DA2  ..######
        .byte   $3F                     ; 6DA3  ..######
        .byte   $3F                     ; 6DA4  ..######

        .byte   $00                     ; 6DA5  ........
        .byte   $00                     ; 6DA6  ........
        .byte   $00                     ; 6DA7  ........
        .byte   $C0                     ; 6DA8  ##......
        .byte   $00                     ; 6DA9  ........
        .byte   $00                     ; 6DAA  ........
        .byte   $03                     ; 6DAB  ......##
        .byte   $03                     ; 6DAC  ......##

        .byte   $3F                     ; 6DAD  ..######
        .byte   $3F                     ; 6DAE  ..######
        .byte   $3F                     ; 6DAF  ..######
        .byte   $3F                     ; 6DB0  ..######
        .byte   $3F                     ; 6DB1  ..######
        .byte   $FF                     ; 6DB2  ########
        .byte   $00                     ; 6DB3  ........
        .byte   $00                     ; 6DB4  ........

        .byte   $FF                     ; 6DB5  ########
        .byte   $03                     ; 6DB6  ......##
        .byte   $03                     ; 6DB7  ......##
        .byte   $00                     ; 6DB8  ........
        .byte   $00                     ; 6DB9  ........
        .byte   $C0                     ; 6DBA  ##......
        .byte   $00                     ; 6DBB  ........
        .byte   $00                     ; 6DBC  ........


; BOOMER, facing 3 (right) - glyph index 11.  32 bytes: TL, TR, BL, BR.
unitGlyphBoomerRight:
        .byte   $00                     ; 6DBD  ........
        .byte   $00                     ; 6DBE  ........
        .byte   $00                     ; 6DBF  ........
        .byte   $03                     ; 6DC0  ......##
        .byte   $00                     ; 6DC1  ........
        .byte   $00                     ; 6DC2  ........
        .byte   $C0                     ; 6DC3  ##......
        .byte   $C0                     ; 6DC4  ##......

        .byte   $00                     ; 6DC5  ........
        .byte   $00                     ; 6DC6  ........
        .byte   $00                     ; 6DC7  ........
        .byte   $FF                     ; 6DC8  ########
        .byte   $FC                     ; 6DC9  ######..
        .byte   $FC                     ; 6DCA  ######..
        .byte   $FC                     ; 6DCB  ######..
        .byte   $FC                     ; 6DCC  ######..

        .byte   $FF                     ; 6DCD  ########
        .byte   $C0                     ; 6DCE  ##......
        .byte   $C0                     ; 6DCF  ##......
        .byte   $00                     ; 6DD0  ........
        .byte   $00                     ; 6DD1  ........
        .byte   $03                     ; 6DD2  ......##
        .byte   $00                     ; 6DD3  ........
        .byte   $00                     ; 6DD4  ........

        .byte   $FC                     ; 6DD5  ######..
        .byte   $FC                     ; 6DD6  ######..
        .byte   $FC                     ; 6DD7  ######..
        .byte   $FC                     ; 6DD8  ######..
        .byte   $FC                     ; 6DD9  ######..
        .byte   $FF                     ; 6DDA  ########
        .byte   $00                     ; 6DDB  ........
        .byte   $00                     ; 6DDC  ........


; SPY, facing 0 (up) - glyph index 12.  32 bytes: TL, TR, BL, BR.
unitGlyphSpyUp:
        .byte   $00                     ; 6DDD  ........
        .byte   $00                     ; 6DDE  ........
        .byte   $00                     ; 6DDF  ........
        .byte   $00                     ; 6DE0  ........
        .byte   $30                     ; 6DE1  ..##....
        .byte   $30                     ; 6DE2  ..##....
        .byte   $3C                     ; 6DE3  ..####..
        .byte   $0C                     ; 6DE4  ....##..

        .byte   $00                     ; 6DE5  ........
        .byte   $00                     ; 6DE6  ........
        .byte   $00                     ; 6DE7  ........
        .byte   $00                     ; 6DE8  ........
        .byte   $30                     ; 6DE9  ..##....
        .byte   $30                     ; 6DEA  ..##....
        .byte   $F0                     ; 6DEB  ####....
        .byte   $C0                     ; 6DEC  ##......

        .byte   $0F                     ; 6DED  ....####
        .byte   $03                     ; 6DEE  ......##
        .byte   $03                     ; 6DEF  ......##
        .byte   $03                     ; 6DF0  ......##
        .byte   $00                     ; 6DF1  ........
        .byte   $00                     ; 6DF2  ........
        .byte   $00                     ; 6DF3  ........
        .byte   $00                     ; 6DF4  ........

        .byte   $C0                     ; 6DF5  ##......
        .byte   $00                     ; 6DF6  ........
        .byte   $00                     ; 6DF7  ........
        .byte   $00                     ; 6DF8  ........
        .byte   $00                     ; 6DF9  ........
        .byte   $00                     ; 6DFA  ........
        .byte   $00                     ; 6DFB  ........
        .byte   $00                     ; 6DFC  ........


; SPY, facing 1 (down) - glyph index 13.  32 bytes: TL, TR, BL, BR.
unitGlyphSpyDown:
        .byte   $00                     ; 6DFD  ........
        .byte   $00                     ; 6DFE  ........
        .byte   $00                     ; 6DFF  ........
        .byte   $03                     ; 6E00  ......##
        .byte   $03                     ; 6E01  ......##
        .byte   $03                     ; 6E02  ......##
        .byte   $0F                     ; 6E03  ....####
        .byte   $0C                     ; 6E04  ....##..

        .byte   $00                     ; 6E05  ........
        .byte   $00                     ; 6E06  ........
        .byte   $00                     ; 6E07  ........
        .byte   $00                     ; 6E08  ........
        .byte   $00                     ; 6E09  ........
        .byte   $00                     ; 6E0A  ........
        .byte   $C0                     ; 6E0B  ##......
        .byte   $C0                     ; 6E0C  ##......

        .byte   $3C                     ; 6E0D  ..####..
        .byte   $30                     ; 6E0E  ..##....
        .byte   $30                     ; 6E0F  ..##....
        .byte   $00                     ; 6E10  ........
        .byte   $00                     ; 6E11  ........
        .byte   $00                     ; 6E12  ........
        .byte   $00                     ; 6E13  ........
        .byte   $00                     ; 6E14  ........

        .byte   $F0                     ; 6E15  ####....
        .byte   $30                     ; 6E16  ..##....
        .byte   $30                     ; 6E17  ..##....
        .byte   $00                     ; 6E18  ........
        .byte   $00                     ; 6E19  ........
        .byte   $00                     ; 6E1A  ........
        .byte   $00                     ; 6E1B  ........
        .byte   $00                     ; 6E1C  ........


; SPY, facing 2 (left) - glyph index 14.  32 bytes: TL, TR, BL, BR.
unitGlyphSpyLeft:
        .byte   $00                     ; 6E1D  ........
        .byte   $00                     ; 6E1E  ........
        .byte   $00                     ; 6E1F  ........
        .byte   $00                     ; 6E20  ........
        .byte   $00                     ; 6E21  ........
        .byte   $03                     ; 6E22  ......##
        .byte   $03                     ; 6E23  ......##
        .byte   $3C                     ; 6E24  ..####..

        .byte   $00                     ; 6E25  ........
        .byte   $00                     ; 6E26  ........
        .byte   $00                     ; 6E27  ........
        .byte   $F0                     ; 6E28  ####....
        .byte   $F0                     ; 6E29  ####....
        .byte   $00                     ; 6E2A  ........
        .byte   $00                     ; 6E2B  ........
        .byte   $00                     ; 6E2C  ........

        .byte   $3C                     ; 6E2D  ..####..
        .byte   $03                     ; 6E2E  ......##
        .byte   $03                     ; 6E2F  ......##
        .byte   $00                     ; 6E30  ........
        .byte   $00                     ; 6E31  ........
        .byte   $00                     ; 6E32  ........
        .byte   $00                     ; 6E33  ........
        .byte   $00                     ; 6E34  ........

        .byte   $00                     ; 6E35  ........
        .byte   $00                     ; 6E36  ........
        .byte   $00                     ; 6E37  ........
        .byte   $F0                     ; 6E38  ####....
        .byte   $F0                     ; 6E39  ####....
        .byte   $00                     ; 6E3A  ........
        .byte   $00                     ; 6E3B  ........
        .byte   $00                     ; 6E3C  ........


; SPY, facing 3 (right) - glyph index 15.  32 bytes: TL, TR, BL, BR.
unitGlyphSpyRight:
        .byte   $00                     ; 6E3D  ........
        .byte   $00                     ; 6E3E  ........
        .byte   $00                     ; 6E3F  ........
        .byte   $3C                     ; 6E40  ..####..
        .byte   $3C                     ; 6E41  ..####..
        .byte   $03                     ; 6E42  ......##
        .byte   $03                     ; 6E43  ......##
        .byte   $00                     ; 6E44  ........

        .byte   $00                     ; 6E45  ........
        .byte   $00                     ; 6E46  ........
        .byte   $00                     ; 6E47  ........
        .byte   $00                     ; 6E48  ........
        .byte   $00                     ; 6E49  ........
        .byte   $00                     ; 6E4A  ........
        .byte   $00                     ; 6E4B  ........
        .byte   $F0                     ; 6E4C  ####....

        .byte   $00                     ; 6E4D  ........
        .byte   $03                     ; 6E4E  ......##
        .byte   $03                     ; 6E4F  ......##
        .byte   $3C                     ; 6E50  ..####..
        .byte   $3C                     ; 6E51  ..####..
        .byte   $00                     ; 6E52  ........
        .byte   $00                     ; 6E53  ........
        .byte   $00                     ; 6E54  ........

        .byte   $F0                     ; 6E55  ####....
        .byte   $00                     ; 6E56  ........
        .byte   $00                     ; 6E57  ........
        .byte   $00                     ; 6E58  ........
        .byte   $00                     ; 6E59  ........
        .byte   $00                     ; 6E5A  ........
        .byte   $00                     ; 6E5B  ........
        .byte   $00                     ; 6E5C  ........


; COMCEN, facing 0 (up) - glyph index 16.  32 bytes: TL, TR, BL, BR.
unitGlyphComcenUp:
        .byte   $00                     ; 6E5D  ........
        .byte   $0F                     ; 6E5E  ....####
        .byte   $0F                     ; 6E5F  ....####
        .byte   $3F                     ; 6E60  ..######
        .byte   $3F                     ; 6E61  ..######
        .byte   $FF                     ; 6E62  ########
        .byte   $F0                     ; 6E63  ####....
        .byte   $FF                     ; 6E64  ########

        .byte   $00                     ; 6E65  ........
        .byte   $C0                     ; 6E66  ##......
        .byte   $C0                     ; 6E67  ##......
        .byte   $F0                     ; 6E68  ####....
        .byte   $F0                     ; 6E69  ####....
        .byte   $FC                     ; 6E6A  ######..
        .byte   $3C                     ; 6E6B  ..####..
        .byte   $FC                     ; 6E6C  ######..

        .byte   $FF                     ; 6E6D  ########
        .byte   $FF                     ; 6E6E  ########
        .byte   $FF                     ; 6E6F  ########
        .byte   $FF                     ; 6E70  ########
        .byte   $FF                     ; 6E71  ########
        .byte   $FF                     ; 6E72  ########
        .byte   $FF                     ; 6E73  ########
        .byte   $00                     ; 6E74  ........

        .byte   $FC                     ; 6E75  ######..
        .byte   $FC                     ; 6E76  ######..
        .byte   $FC                     ; 6E77  ######..
        .byte   $FC                     ; 6E78  ######..
        .byte   $FC                     ; 6E79  ######..
        .byte   $FC                     ; 6E7A  ######..
        .byte   $FC                     ; 6E7B  ######..
        .byte   $00                     ; 6E7C  ........


; COMCEN, facing 1 (down) - glyph index 17.  32 bytes: TL, TR, BL, BR.
unitGlyphComcenDown:
        .byte   $00                     ; 6E7D  ........
        .byte   $00                     ; 6E7E  ........
        .byte   $FF                     ; 6E7F  ########
        .byte   $FF                     ; 6E80  ########
        .byte   $FF                     ; 6E81  ########
        .byte   $FF                     ; 6E82  ########
        .byte   $FF                     ; 6E83  ########
        .byte   $FF                     ; 6E84  ########

        .byte   $00                     ; 6E85  ........
        .byte   $00                     ; 6E86  ........
        .byte   $FC                     ; 6E87  ######..
        .byte   $FC                     ; 6E88  ######..
        .byte   $FC                     ; 6E89  ######..
        .byte   $FC                     ; 6E8A  ######..
        .byte   $FC                     ; 6E8B  ######..
        .byte   $FC                     ; 6E8C  ######..

        .byte   $FF                     ; 6E8D  ########
        .byte   $FF                     ; 6E8E  ########
        .byte   $F0                     ; 6E8F  ####....
        .byte   $FF                     ; 6E90  ########
        .byte   $3F                     ; 6E91  ..######
        .byte   $3F                     ; 6E92  ..######
        .byte   $0F                     ; 6E93  ....####
        .byte   $0F                     ; 6E94  ....####

        .byte   $FC                     ; 6E95  ######..
        .byte   $FC                     ; 6E96  ######..
        .byte   $3C                     ; 6E97  ..####..
        .byte   $FC                     ; 6E98  ######..
        .byte   $F0                     ; 6E99  ####....
        .byte   $F0                     ; 6E9A  ####....
        .byte   $C0                     ; 6E9B  ##......
        .byte   $C0                     ; 6E9C  ##......


; COMCEN, facing 2 (left) - glyph index 18.  32 bytes: TL, TR, BL, BR.
unitGlyphComcenLeft:
        .byte   $00                     ; 6E9D  ........
        .byte   $00                     ; 6E9E  ........
        .byte   $FF                     ; 6E9F  ########
        .byte   $FF                     ; 6EA0  ########
        .byte   $FF                     ; 6EA1  ########
        .byte   $FF                     ; 6EA2  ########
        .byte   $FF                     ; 6EA3  ########
        .byte   $FF                     ; 6EA4  ########

        .byte   $00                     ; 6EA5  ........
        .byte   $00                     ; 6EA6  ........
        .byte   $C0                     ; 6EA7  ##......
        .byte   $F0                     ; 6EA8  ####....
        .byte   $FC                     ; 6EA9  ######..
        .byte   $FF                     ; 6EAA  ########
        .byte   $3F                     ; 6EAB  ..######
        .byte   $3F                     ; 6EAC  ..######

        .byte   $FF                     ; 6EAD  ########
        .byte   $FF                     ; 6EAE  ########
        .byte   $FF                     ; 6EAF  ########
        .byte   $FF                     ; 6EB0  ########
        .byte   $FF                     ; 6EB1  ########
        .byte   $FF                     ; 6EB2  ########
        .byte   $00                     ; 6EB3  ........
        .byte   $00                     ; 6EB4  ........

        .byte   $3F                     ; 6EB5  ..######
        .byte   $3F                     ; 6EB6  ..######
        .byte   $3F                     ; 6EB7  ..######
        .byte   $FC                     ; 6EB8  ######..
        .byte   $F0                     ; 6EB9  ####....
        .byte   $C0                     ; 6EBA  ##......
        .byte   $00                     ; 6EBB  ........
        .byte   $00                     ; 6EBC  ........


; COMCEN, facing 3 (right) - glyph index 19.  32 bytes: TL, TR, BL, BR.
unitGlyphComcenRight:
        .byte   $00                     ; 6EBD  ........
        .byte   $00                     ; 6EBE  ........
        .byte   $03                     ; 6EBF  ......##
        .byte   $0F                     ; 6EC0  ....####
        .byte   $3F                     ; 6EC1  ..######
        .byte   $FF                     ; 6EC2  ########
        .byte   $FC                     ; 6EC3  ######..
        .byte   $FC                     ; 6EC4  ######..

        .byte   $00                     ; 6EC5  ........
        .byte   $00                     ; 6EC6  ........
        .byte   $FF                     ; 6EC7  ########
        .byte   $FF                     ; 6EC8  ########
        .byte   $FF                     ; 6EC9  ########
        .byte   $FF                     ; 6ECA  ########
        .byte   $FF                     ; 6ECB  ########
        .byte   $FF                     ; 6ECC  ########

        .byte   $FC                     ; 6ECD  ######..
        .byte   $FC                     ; 6ECE  ######..
        .byte   $FC                     ; 6ECF  ######..
        .byte   $3F                     ; 6ED0  ..######
        .byte   $0F                     ; 6ED1  ....####
        .byte   $03                     ; 6ED2  ......##
        .byte   $00                     ; 6ED3  ........
        .byte   $00                     ; 6ED4  ........

        .byte   $FF                     ; 6ED5  ########
        .byte   $FF                     ; 6ED6  ########
        .byte   $FF                     ; 6ED7  ########
        .byte   $FF                     ; 6ED8  ########
        .byte   $FF                     ; 6ED9  ########
        .byte   $FF                     ; 6EDA  ########
        .byte   $00                     ; 6EDB  ........
        .byte   $00                     ; 6EDC  ........


; padding6EDD - 35 zero bytes filling out the last sector of the main program to $6EFF.  Nothing reads
; them; the first byte the game does use above this is the overlay window at $6F00.
padding6EDD:
        .byte   $00,$00,$00,$00,$00,$00,$00,$00; 6EDD  ........  unused filler to the end of the $0800-$6EFF image
        .byte   $00,$00,$00,$00,$00,$00,$00,$00; 6EE5  ........
        .byte   $00,$00,$00,$00,$00,$00,$00,$00; 6EED  ........
        .byte   $00,$00,$00,$00,$00,$00,$00,$00; 6EF5  ........
        .byte   $00,$00,$00             ; 6EFD  ...
