modemwars/disassembly/game/trainerAiE000.s
2026-08-23 02:09:40 -05:00

3876 lines
255 KiB
ArmAsm

; ============================================================================
; $E000-$EFFF - solo trainer: the computer opponent (track 35 sectors 0-15)
; ============================================================================
; Loaded by $0F1A when the solo trainer was chosen ($0BA5 bit 7 set). It presents the same jump table as
; the modem driver but fakes the link: instead of sending and receiving over a wire it plans the moves of
; side 1 and injects them straight into the received-packet buffer. $EE00-$EFFF is swapped for the drone
; AI (track 29 sectors 4-5) while its drone is airborne.
.setcpu "6502"
.include "c64.inc"
.include "zeropage.inc"
; ---- references to code/data outside this file ----
checksumXorConstant := $0400
play4Grp12 := $0464
D_077E := $077E
loadSectors := $0804
setLoadDest := $085C
filmPlaybackMode := $0B9B
gameTypeOptions := $0BA3
gameEndReason := $0BA8
D_2009 := $2009
readMapCell := $25FA
terrainSightBonusTable := $35BB
squaresTable := $3860
distanceSquared := $3870
D_3E99 := $3E99
mirrorMapCoordinate := $3FA3
D_434C := $434C
D_4CE0 := $4CE0
getCommandLength := $4D9F
D_60E0 := $60E0
L_754C := $754C
D_774C := $774C
mapCellToRadarGrid := $7A13
msgWeWonByAKnockout := $87CE
msgGetOpponentOnPhoneAnd := $8800
D_8831 := $8831
D_8832 := $8832
D_8864 := $8864
D_8895 := $8895
D_8896 := $8896
msgPickUpPhoneThenPressSpace := $8897
D_8898 := $8898
D_8899 := $8899
D_88A0 := $88A0
D_88D2 := $88D2
D_88DE := $88DE
D_88EA := $88EA
D_88F6 := $88F6
D_8902 := $8902
D_890E := $890E
D_891A := $891A
D_8926 := $8926
D_8932 := $8932
D_893E := $893E
D_894A := $894A
D_8952 := $8952
D_8953 := $8953
D_8954 := $8954
D_8955 := $8955
D_8956 := $8956
D_8957 := $8957
D_8958 := $8958
D_895A := $895A
D_895B := $895B
D_895D := $895D
msgOpponentUsingNonStandardGame := $895E
D_895F := $895F
D_8960 := $8960
D_8961 := $8961
D_8962 := $8962
D_8963 := $8963
D_8964 := $8964
D_8965 := $8965
D_8966 := $8966
D_8967 := $8967
D_8968 := $8968
D_8969 := $8969
D_896A := $896A
D_896B := $896B
D_896C := $896C
D_896D := $896D
D_896E := $896E
D_896F := $896F
D_8970 := $8970
D_8971 := $8971
D_8972 := $8972
D_8973 := $8973
D_8974 := $8974
D_8975 := $8975
D_8976 := $8976
D_8977 := $8977
D_8979 := $8979
D_897A := $897A
D_897B := $897B
D_897C := $897C
D_8D03 := $8D03
vicBorderShadow := $9003
lastStepRowDelta := $90F4
lastStepColDelta := $90F5
currentUnit := $90FA
gameClock := $91CB
fireTargetUnit := $91F0
droneHeading := $920B
droneControlFlags := $920D
comcenUnit := $9236
dronesLeft := $92AE
opponentDronesLeft := $92AF
opponentMissilesLeft := $92B1
currentCommandLength := $92EE
droneViewOriginCol := $92F6
droneViewOriginRow := $92F7
D_A989 := $A989
D_B908 := $B908
unitColTable := $F640
side0ComcenCol := $F671
side1UnitColTable := $F672
side1ComcenCol := $F6A3
unitRowTable := $F6A4
side0ComcenRow := $F6D5
side1UnitRowTable := $F6D6
side1ComcenRow := $F707
side1UnitFlagsTable := $F73A
unitTypeTable := $F76C
side1UnitTypeTable := $F79E
unitDestColTable := $F7D0
side1UnitDestColTable := $F802
side1ComcenDestCol := $F833
unitDestRowTable := $F834
side1UnitDestRowTable := $F866
side1ComcenDestRow := $F897
side1ComcenStun := $F9C3
side0ComcenEnergy := $F9F5
side1ComcenEnergy := $FA27
unitTerrainUnderTable := $FAF0
side1ComcenDisplayFlags := $FBB7
initLineStepper := $FBB8
stepLine := $FBFA
divideSigned16by8 := $FCEC
nextGameRandom := $FD5D
; Contents
; --------
; $E000 commRequestEntry Jump-table slot 0 of the opponent module (JMP handleCommRequest).
; $E003 commSetLinkStateEntry Jump-table slot 1 (JMP setCommLinkState): brings the pretend link up or down.
; $E006 commGetReceivedByteEntry Jump-table slot 2 (JMP reportNoReceivedByte).
; $E009 commSendByteEntry Jump-table slot 3 (JMP commNoOpClc).
; $E00C commFrameTickEntry Jump-table slot 4 (JMP commNoOpClc).
; $E012 commKeyboardScanVector A JMP whose two operand bytes $E013/$E014 are the module's keyboard-scan
; vector.
; $E015 commReadKeyEntry Jump-table slot 5 (JMP commKeyboardScanVector).
; $E043 handleCommRequest Body of commRequestEntry.
; $E060 setCommLinkState Body of commSetLinkStateEntry.
; $E075 reportNoReceivedByte SEC + RTS: the 'no character available' answer of commGetReceivedByteEntry in
; the trainer build.
; $E077 commNoOpClc CLC + RTS: the shared no-op used as the body of commSendByteEntry,
; commFrameTickEntry and the unpatched keyboard vector.
; $E079 runTrainerRoundHook Once-per-round hook, called from finishRound ($4654) right after the game clock
; has been decremented and only in a solo game ($0BA5 bit 7 set, film playback excluded).
; $E082 buildTrainerReplyPacket Entered with X=1 from sendOutgoingPacket, i.e.
; $E0E8 trainerUnitStepHook Per-unit hook called at the end of processUnitStep ($4526) for currentUnit,
; i.e.
; $E128 setSubOverlayLoadDest Points the fast loader at $EE00 (X = destination low byte, Y = high byte) and
; tail-jumps into setLoadDest.
; $E12F pickTrainerRallyPoint Chooses the AI's current objective: a cell scattered at random around the human
; comcen (unit 49), stored as the rally column/row that every attacking group heads for.
; $E14A randomiseCoordinate Returns A = base coordinate plus a random offset, clamped to 0..39.
; $E170 planTrainerRound Top-level planner of the solo trainer, run once per game round.
; $E21F pushTrainerComcenForward Attack-mode tail of planTrainerRound (a branch target, not a JSR entry).
; $E266 retreatTrainerComcen Defend-mode tail of planTrainerRound (a branch target).
; $E2BD scanTrainerUnitsForOrders Walks the AI's 50 unit slots from ordinal 49 (unit 99, the comcen) down to 0,
; stopping as soon as four commands have been queued this round.
; $E2F1 decideTrainerGroupAction Decides what the AI group owning unit ordinal Y should do.
; $E339 aimGroupAtOwnComcen Internal tail (JMP target from retreatTrainerComcen $E2B3, fall-through from
; $E336): points the line target at the AI comcen's cell, then falls into the shared code at $E343 that copies the group
; centre into the search origin and the line target into the wanted destination, and jumps to issueTrainerGroupOrders.
; $E3D0 aimGroupAtHomeLine Internal branch target used when the group still has members that have not
; reached the player's end zones and has no contact with his comcen: the target column stays where the group already is
; and the target row becomes 29 plus the group's own vertical extents, i.e.
; $E42A findTrainerAdvanceCell Threat-aware path search.
; $E49E issueTrainerGroupOrders Issues the move orders for the group in $8955.
; $E531 computeTrainerGroupStats Gathers the statistics of AI group Y.
; $E5FF clampGroupTargetToMap Clips the wanted group centre in zp_22/zp_24 so that the whole formation box
; (reach left/right in $890E/$8926 and up/down in $891A/$8932) still fits inside the 40x40 map, stores the clipped cell
; as the group's target and returns the movement delta relative to the group's average ordered destination in zp_2A
; (columns, also kept in $8958) and zp_2B (rows).
; $E66C offsetPointInDirection Computes the cell that lies Y steps in compass direction X from the reference
; point (zp_27 column, zp_28 row), using dirToColDelta/dirToRowDelta and offsetCoordinateClamped.
; $E69C isGroupDestinationTooRisky influence-map test of one candidate destination for the AI group in
; currentAiGroup ($8955).
; $E773 buildUnitThreatTables rebuilds the two 100-entry influence-map tables that isGroupDestinationTooRisky
; reads.
; $E7B9 multiplyAbyY 8-bit multiply by repeated addition: A = A * Y modulo 256, and A = 0 when Y =
; 0.
; $E7D3 buildDirectionFanTowardTarget finds the compass heading from the line start (lineCurCol,lineCurRow) to the
; line end
; $E7E3 buildDirectionFan fills the eight-byte scratch buffer $8896-$889D with the move directions the
; path search should try,
; $E80E getStepDirection sets a Bresenham walker up between (lineCurCol,lineCurRow) and
; (lineEndCol,lineEndRow), takes exactly one
; $E82A cellDistance integer distance between two map cells.
; $E83C updateFocusGroupCentre keeps focusGroupId ($895D) and the focus centre ($895E column / $895F row)
; current.
; $E882 computeGroupCentroid averages the position and the ordered destination of every living member of one
; AI group.
; $E919 orderAiUnitToCell the trainer's central 'issue a move order' helper.
; $E937 queueMirroredMoveOrder second half of orderAiUnitToCell.
; $E95D isPathToPlayerComcenClear walks a Bresenham line from the current line position (which the caller has set
; to the group
; $E997 loadGroupCentreIntoLinePos copies the current group's centre (groupCentreCol $88EA / groupCentreRow $88F6)
; into the line
; $E9A6 scoreTargetBlock scores the 2x2 block of map cells whose top-left corner is (X,Y) as a
; bombardment target.
; $E9F6 addUnitTargetValue helper of scoreTargetBlock.
; $EA1B offsetCoordinateClamped returns clamp(A + X*Y, 0..39): the base coordinate A displaced by Y steps of
; the signed step X, held
; $EA38 mirrorBlockCornerForOpponent converts the top-left corner of a 2x2 object (the drone) into the coordinates
; that the engine's
; $EA46 queueAiCommand appends the command staged in $8896..$8899 to the AI's 50-byte order queue at
; $88A0.
; $EA7B injectAiCommandsIntoPacket the heart of the solo trainer.
; $EACF updateAiUnitTracking per-unit hook, run from trainerUnitStepHook after the engine has stepped each
; unit.
; $EB67 runAiComcenActions spends whatever is left of the AI comcen's three command slots for this round.
; $EBA5 maybeQueueComcenEnergyRepair the trainer's version of the human console's energy repair.
; $EBDD queueComcenCommand shared tail of the comcen actions: stores A as the command byte and jumps into
; queueAiCommand, leaving
; $EBE4 haltAiComcen orders the AI comcen to move to the cell it is already standing on.
; $EBEF assessThreatAroundAiComcen builds a directional pressure map around the AI comcen and picks the direction
; to run.
; $EC82 rankThreatDirections Tail of assessThreatAroundAiComcen ($EBEF): the scan loop above has just turned
; every one of the human player's combat units 0-45 into a saturating 'pressure' weight in one of the eight compass
; buckets $8896-$889D.
; $EC91 smoothThreatRing Circular 1-2-1 blur of the eight-direction threat ring.
; $ECD4 findQuietestDirectionArc For every leading direction y = 7..0 it adds the blurred pressures of y, y-1
; and y-2 (wrapping round the ring) and keeps the smallest total, remembering the arc's middle direction y-1.
; $ED0B runAiMissileTurn The computer opponent's one 'weapon' decision per lock-step exchange.
; $EDD5 fireMissileAtSpottedUnit Second half of the missile turn, reached whenever no anti-drone shot was taken.
; $EE00 targetEnemyComcenWithMissile Fall-through tail of fireMissileAtSpottedUnit: when the AI has no remembered
; attacker, or that attacker is dead, it shoots at the human's COMCEN (unit $31 = 49) instead, but only while that
; comcen is currently visible.
; $EE2D queueMissileStrikeCommand Appends the 3-byte command $90 col,row (MISSILE STRIKE, handler
; cmdMissileStrike $53C0: 14 points of damage, 7 to a comcen) to the packet the trainer pretends to have received from
; the opponent.
; $EE55 randomlySignValue Returns A or -A with equal probability, using bit 0 of the lock-step game RNG.
; $EE6A isUnitOnAiRadar Loads unit X's cell into zp_22/zp_24 and asks whether it lies inside the 21x17
; radar grid centred on the computer opponent's own COMCEN.
; $EE85 launchAiDrone Sends the computer opponent's drone up when one is due.
; $EEE0 chooseAiDroneTarget Picks what the computer opponent should send its next drone at and leaves the
; unit index in $8976.
; $EF59 pickDroneTargetAlongThreatAxis Tail of chooseAiDroneTarget for the case where the human comcen is no use as
; a drone target.
.org $E000
; ----------------------------------------------------------------------
; commRequestEntry - Jump-table slot 0 of the opponent module (JMP handleCommRequest). The game calls
; it every raster IRQ with X=0 ($1129), with X=1 from sendOutgoingPacket ($56AE) to hand over the
; outgoing packet, with X=2 to acknowledge a completed exchange ($4EFF/$526F) and with X=3 to cancel a
; pending send. In this build (track 35 = SOLO TRAINER) there is no link at all: X=1 runs one step of
; the computer opponent and leaves its orders in the receive buffer.
; In: X = request code 0..3
; Out: see handleCommRequest ($E043)
; Called from: mainProgram0800 $1129 (raster IRQ, X=0), $4EFF and $526F (X=2), $56AE (X=1),
; $570F/$5727/$573B/$5767/$5776 (menu-level exchange)
; ----------------------------------------------------------------------
commRequestEntry:
jmp handleCommRequest ; E000 slot 0: per-frame poll / packet request, X selects the function
; ----------------------------------------------------------------------
; commSetLinkStateEntry - Jump-table slot 1 (JMP setCommLinkState): brings the pretend link up or
; down.
; In: X = 0 open, >= 1 close
; Out: isLinkActive / linkStatus / linkStatusSample ($E03B/$E03C/$E03D), and $E01F when closing
; Called from: initCommModule $0F46 (X=1), hangUpModemSetState $1B56 and $1B70 (X=3)
; ----------------------------------------------------------------------
commSetLinkStateEntry:
jmp setCommLinkState ; E003 slot 1: open (X=0) or close (X>0) the link flags
; ----------------------------------------------------------------------
; commGetReceivedByteEntry - Jump-table slot 2 (JMP reportNoReceivedByte). In the modem build this
; pops the UART receive ring; the trainer has no wire, so it always answers 'nothing received' and the
; opponent chat line stays empty in a solo game.
; In: none
; Out: C=1 always (no byte)
; Called from: receiveChatCharacter $1399
; ----------------------------------------------------------------------
commGetReceivedByteEntry:
jmp reportNoReceivedByte ; E006 slot 2: fetch a received chat byte - the trainer never has one
; ----------------------------------------------------------------------
; commSendByteEntry - Jump-table slot 3 (JMP commNoOpClc). The chat character handed over is simply
; dropped and C=0 ('accepted') is returned.
; In: A = byte to send (ignored)
; Out: C=0
; Called from: handleChatSend $1396 (JMP, not JSR)
; ----------------------------------------------------------------------
commSendByteEntry:
jmp commNoOpClc ; E009 slot 3: send a chat byte - the trainer throws it away
; ----------------------------------------------------------------------
; commFrameTickEntry - Jump-table slot 4 (JMP commNoOpClc). In the modem build this services the
; software UART once per frame; with no serial line there is nothing to do.
; In: none
; Out: C=0
; Called from: rasterIrqHandler $112C
; ----------------------------------------------------------------------
commFrameTickEntry:
jmp commNoOpClc ; E00C slot 4: per-IRQ service tick - nothing to service without a UART
; ----------------------------------------------------------------------
; Two one-instruction stubs the module can point a vector at. $E00F is a plain RTS and $E010 an RTI;
; $E010 is the initial value of nmiChainVector ($E031/$E032), so an NMI arriving before the game
; patches
; that vector simply returns.
; ----------------------------------------------------------------------
commRtsRtiStubs:
rts ; E00F $60 = RTS: harmless body for any vector pointed here
.byte $40 ; E010 $40 = RTI: the module's default NMI handler, target of the initial $E031/$E032 = $E010
isOriginateMode:
.byte $00 ; E011 answer/originate flag: waitForAnswerOriginateKey ($0CDE) stores 0 for 'A' and 1 for 'O'. Modem-only; nothing in the trainer build reads it
; ----------------------------------------------------------------------
; commKeyboardScanVector - A JMP whose two operand bytes $E013/$E014 are the module's keyboard-scan
; vector. initCommModule patches them to the game's scanKeyboard ($0DB7) with the two stores at
; $0F4B/$0F50; until then the vector points at commNoOpClc, so no key is ever reported.
; In: none
; Out: whatever scanKeyboard returns (A = key code, X/Y = shift / Commodore-key flags)
; Called from: commReadKeyEntry $E015 (JMP). The XREF also lists a call from $E2EC, but that address
; is mid-instruction here - it belongs to the modem build that shares this address range.
; ----------------------------------------------------------------------
commKeyboardScanVector:
jmp commNoOpClc ; E012 operand bytes $E013/$E014 are the keyboard vector, patched to scanKeyboard $0DB7 by $0F4B/$0F50
; ----------------------------------------------------------------------
; commReadKeyEntry - Jump-table slot 5 (JMP commKeyboardScanVector). This is the only route by which
; the game reads the keyboard, so a comm module that owns the CPU can steal keys before the game sees
; them.
; In: none
; Out: key code in A, shift / Commodore-key flags in X and Y
; Called from: pollKeyboardEvent $0E56 (every frame)
; ----------------------------------------------------------------------
commReadKeyEntry:
jmp commKeyboardScanVector ; E015 slot 5: read one key through the patchable vector above
; Dead space. The modem build (track 34) puts a sixth jump-table entry at $E018; the trainer build
; leaves five $00 bytes here. Nothing in this file or in the main program calls $E018, and executing
; it would BRK.
unusedJumpSlotE018:
.byte $00 ; E018 five unused $00 bytes where the modem build has its sixth jump-table slot
.byte $00,$00,$00,$00 ; E019 ....
; $E01D-$E042 is the fixed interface block that both comm-module builds present to the game at exactly
; the
; same addresses: the packet-exchange flags, the received and outgoing packet lengths and their two
; 7-byte buffers, the build id, the NMI chain vector, the CIA2 ICR shadow, the disk-access suspend
; handshake and the three link flags. In the trainer build most of it is only ever written by
; handleCommRequest and setCommLinkState; $E034-$E038 and $E03E-$E042 are modem-build variables that
; stay zero for ever.
commInterfaceBlock:
.byte $00 ; E01D exchange flags: bit 7 = opponent packet received, bit 6 = exchange complete, bits 0/5 = send requested / in progress. handleCommRequest pins it at $C0
rxPacketLength:
.byte $00 ; E01E length 0-7 of the 'received' packet the trainer fabricates in $E020
txPacketLength:
.byte $00 ; E01F length of the outgoing packet the game built in $E027; the trainer never reads the bytes
rxPacketBuffer:
.byte $00 ; E020 received-packet buffer $E020-$E026, byte 0 = the command byte the engine will execute
rxPacketArg1:
.byte $00 ; E021 received packet byte 1 (first command argument)
rxPacketArg2:
.byte $00 ; E022 received packet byte 2
rxPacketArg3:
.byte $00 ; E023 received packet byte 3
rxPacketArg4:
.byte $00 ; E024 received packet bytes 4-6, the tail of the 7-byte buffer
.byte $00,$00 ; E025 ..
txPacketBuffer:
.byte $00 ; E027 outgoing-packet buffer $E027-$E02D, filled by sendOutgoingPacket ($568C); discarded here
txPacketArg1:
.byte $00 ; E028 outgoing packet byte 1
txPacketArg2:
.byte $00 ; E029 outgoing packet byte 2
txPacketArg3:
.byte $00 ; E02A outgoing packet bytes 3-6
.byte $00,$00,$00 ; E02B ...
hostOutCount:
.byte $00 ; E02E chat transmit-queue depth; handleChatSend ($1357/$1376) only opens typing while it is 0, so in a solo game the editor opens and the text goes nowhere
peerCommBuildId:
.byte $00 ; E02F build id reported by the peer; never filled in without a link
commBuildId:
.byte $FF ; E030 comm build id: $FF = build 1 = the solo trainer (track 35). The game compares it with linkTypeFlag $0BA5 at $0AEA/$0B2C/$1A9F/$1AFA/$1B3D to see whether the right module is resident
nmiChainVector:
.byte $10 ; E031 NMI chain vector low byte, initially $10 -> $E010 (the RTI above); the game patches it to $1298 at $0BF3
nmiChainVectorHi:
.byte $E0 ; E032 NMI chain vector high byte ($E0 here, patched to $12)
ciaIcrShadow:
.byte $7F ; E033 CIA2 ICR mask shadow; $7F means 'clear every CIA2 interrupt source'. readFilmByte ($5889/$58A8/$58CD) writes this back to $DD0D after banking the film RAM in - the trainer wants no CIA2 NMI at all
L_E034:
.byte $00 ; E034 $E034-$E037: modem-build link variables (command phase, retry state); never touched here
.byte $00,$00,$00 ; E035 ...
D_E038:
.byte $00 ; E038 modem-build link variable; unused in the trainer build
nmiSuspendRequest:
.byte $00 ; E039 disk-access suspend request: suspendCommModule stores $C0 here at $0F67, resumeCommModule 0 at $0F78
nmiSuspendAck:
.byte $00 ; E03A suspend acknowledge; handleCommRequest copies $E039 into it so the wait at $0F6A/$0F7B always succeeds at once
isLinkActive:
.byte $00 ; E03B link-active flag, $FF after setCommLinkState(X=0); read all over the main program ($0F60, $1331, $1A40, $4E46, ...)
linkStatus:
.byte $00 ; E03C carrier / link status; kept at $FF so the bit-6 test in pollMenuSession ($56B1) and the bit-7 test in hangUpModemSetState ($1B4B) always pass
linkStatusSample:
.byte $00 ; E03D $E03D = link-ready sample read at $1BAB; $E03E is a modem-build byte and stays zero
.byte $00 ; E03E .
D_E03F:
.byte $00 ; E03F modem-build reply timer; unused here
D_E040:
.byte $00 ; E040 modem-build connection phase; unused here
D_E041:
.byte $00 ; E041 modem-build 'skip init string' flag; unused here
D_E042:
.byte $00 ; E042 modem-build UART restart request; unused here
; ----------------------------------------------------------------------
; handleCommRequest - Body of commRequestEntry. X=1 jumps to buildTrainerReplyPacket, which is where
; the computer opponent actually runs. X=3 forgets the outgoing packet. Every other code (X=0
; per-frame poll, X=2 acknowledge) simply declares the exchange finished by storing $C0 in the
; exchange flags and mirroring the suspend request into the suspend acknowledge.
; In: X = request code 0..3
; Out: $E01D = $C0 and $E03A = $E039, or $E01F = 0 for X=3, or a whole AI turn for X=1
; Called from: commRequestEntry $E000
; ----------------------------------------------------------------------
handleCommRequest:
cpx #$01 ; E043 request 1 = 'transmit the outgoing packet'?
D_E045:
bne L_E04A ; E045 no - test the remaining request codes
D_E047:
jmp buildTrainerReplyPacket ; E047 yes: instead of transmitting, run one step of the computer opponent
L_E04A:
cpx #$03 ; E04A request 3 = 'cancel the pending send'?
D_E04C:
bne L_E054 ; E04C no - fall through to the poll / acknowledge case
D_E04E:
lda #$00 ; E04E cancel: drop whatever was queued
sta txPacketLength ; E050 outgoing packet length = 0
rts ; E053 done
; ----------------------------------------------------------------------
; Poll (X=0) and acknowledge (X=2) share this tail. Because the flags are unconditionally set to 'own
; packet sent AND opponent packet received', processCommandExchange ($4DB7) never waits for a peer and
; the suspend/resume handshake around every disk access completes on the first poll.
; ----------------------------------------------------------------------
L_E054:
lda #$C0 ; E054 $C0 = bit 7 'opponent packet received' + bit 6 'exchange complete'
D_E056:
sta commInterfaceBlock ; E056 report the exchange as finished the instant it is asked about
D_E059:
lda nmiSuspendRequest ; E059 take the disk-access suspend request...
sta nmiSuspendAck ; E05C ...and answer it immediately, so $0F5F/$0F71 never block
D_E05F:
rts ; E05F done
; ----------------------------------------------------------------------
; setCommLinkState - Body of commSetLinkStateEntry. X=0 stores $FF into the three link flags
; $E03D/$E03C/$E03B, X>=1 stores 0 in all three and also clears the outgoing packet length. Since the
; carrier byte $E03C is $FF whenever the link is 'open', every carrier test in the main program sees a
; healthy connection.
; In: X = 0 open, >= 1 close
; Out: $E03B, $E03C, $E03D and (on close) $E01F
; Called from: commSetLinkStateEntry $E003
; ----------------------------------------------------------------------
setCommLinkState:
lda #$FF ; E060 $FF = link up
cpx #$01 ; E062 X=0 means open, anything else means close
bcc L_E06B ; E064 open - keep $FF
lda #$00 ; E066 close - the three flags become 0
sta txPacketLength ; E068 and the outgoing packet is discarded
L_E06B:
sta linkStatusSample ; E06B link-ready sample
sta linkStatus ; E06E carrier status - $FF makes every carrier test in the game succeed
L_E071:
sta isLinkActive ; E071 link-active flag the main program polls everywhere
rts ; E074 done
; ----------------------------------------------------------------------
; reportNoReceivedByte - SEC + RTS: the 'no character available' answer of commGetReceivedByteEntry in
; the trainer build.
; In: none
; Out: C=1
; Called from: commGetReceivedByteEntry $E006 (JMP)
; ----------------------------------------------------------------------
reportNoReceivedByte:
sec ; E075 C=1 = 'no character available'
rts ; E076 the trainer never delivers opponent chat text
; ----------------------------------------------------------------------
; commNoOpClc - CLC + RTS: the shared no-op used as the body of commSendByteEntry, commFrameTickEntry
; and the unpatched keyboard vector. $E078 is the bare RTS on its own.
; In: none
; Out: C=0
; Called from: commSendByteEntry $E009, commFrameTickEntry $E00C, commKeyboardScanVector $E012 (all
; JMP)
; ----------------------------------------------------------------------
commNoOpClc:
clc ; E077 C=0 = 'accepted' / 'nothing to report'
commNoOpRts:
rts ; E078 shared bare RTS
; ----------------------------------------------------------------------
; runTrainerRoundHook - Once-per-round hook, called from finishRound ($4654) right after the game
; clock has been decremented and only in a solo game ($0BA5 bit 7 set, film playback excluded). Unless
; the drone-flight sub-overlay is currently sitting at $EE00 it runs planTrainerRound, the computer
; opponent's 'think' routine.
; In: $8977 drone timer (bit 7 = drone sub-overlay resident)
; Out: see planTrainerRound ($E170)
; Called from: finishRound $4654
; ----------------------------------------------------------------------
runTrainerRoundHook:
lda D_8977 ; E079 drone timer; bit 7 marks the drone-flight overlay as resident at $EE00
bmi L_E081 ; E07C the drone-flight AI occupies $EE00-$EFFF, so planTrainerRound must not run (it would call chooseAiDroneTarget at $EEE0)
D_E07E:
jsr planTrainerRound ; E07E run the once-per-round planner
L_E081:
rts ; E081 back to finishRound
; ----------------------------------------------------------------------
; buildTrainerReplyPacket - Entered with X=1 from sendOutgoingPacket, i.e. once per lock-step
; exchange, immediately after the human's packet was handed over. It clears the received-packet length
; and gives up at once during the setup phase, during film playback, after the game has ended or while
; the game is paused. Otherwise it manages the swappable $EE00-$EFFF quarter of the module: while a
; drone is airborne it loads track 29 sectors 4-5 there (the drone-flight AI) and calls $EE00 every
; exchange; when the drone is gone it reloads track 35 sectors 14-15 (its own missile/target tail). In
; the normal case it ticks the launch countdown down and calls injectAiCommandsIntoPacket, which moves
; whole queued AI commands into the received-packet buffer $E020.
; In: gamePhase zp_B1, filmPlaybackMode $0B9B, gameEndReason $0BA8, pauseState zp_BE,
; droneControlFlags $920D, drone timer $8977
; Out: $E01E/$E020.. = the fabricated 'received' packet, $8976 drone target, $8977 timer, and
; $EE00-$EFFF possibly reloaded from disk
; Called from: handleCommRequest $E047 (JMP, request code 1)
; Guards first: the AI only produces orders while a real battle is running.
; ----------------------------------------------------------------------
buildTrainerReplyPacket:
lda #$00 ; E082 start with an empty incoming packet
D_E084:
sta rxPacketLength ; E084 received packet length = 0
lda gamePhase ; E087 gamePhase is 0 only while the battle proper runs
bne L_E0E7 ; E089 side selection / unit setup / menus - stay silent
lda filmPlaybackMode ; E08B film playback flag
D_E08E:
bmi L_E0E7 ; E08E bit 7 = replaying a film; the recorded stream supplies the orders
D_E090:
lda gameEndReason ; E090 non-zero once the game has ended
bne L_E0E7 ; E093 game over - nothing left to order
lda pauseState ; E095 pause / disconnect state
bne L_E0E7 ; E097 paused - freeze the opponent as well
; ----------------------------------------------------------------------
; $EE00-$EFFF is a third overlay layer inside this module. $8977 bit 7 says the drone-flight AI
; (track 29 sectors 4-5) is resident there; otherwise this module's own missile/target tail (track 35
; sectors 14-15) is. The two loads below swap between them as the AI's drone takes off and lands.
; ----------------------------------------------------------------------
lda D_8977 ; E099 drone timer: bit 7 = the drone-flight overlay is resident at $EE00
bpl L_E0C1 ; E09C our own tail is resident - take the normal path
lda droneControlFlags ; E09E drone control flags
D_E0A1:
and #$01 ; E0A1 bit 0 = a drone-move command is in force, i.e. a drone is airborne
D_E0A3:
beq L_E0AB ; E0A3 drone gone - swap our own tail back in
L_E0A5:
jsr targetEnemyComcenWithMissile; E0A5 still flying: call the drone-flight AI now loaded at $EE00 (track 29 s4-5, NOT the code assembled at $EE00 in this file)
D_E0A8:
jmp L_E0E7 ; E0A8 done for this exchange
L_E0AB:
jsr setSubOverlayLoadDest ; E0AB point the fast loader at $EE00
ldy #$23 ; E0AE Y = track $23 = 35, this module's own track
lda #$02 ; E0B0 A = 2 sectors
ldx #$0E ; E0B2 X = first sector 14: the native $EE00-$EFFF tail
jsr loadSectors ; E0B4 reload it over the drone AI
lda #$FF ; E0B7 $FF = no drone target chosen
sta D_8976 ; E0B9 let chooseAiDroneTarget pick a fresh one
lda #$03 ; E0BC 3 exchanges of cool-down before the next launch
sta D_8977 ; E0BE storing 3 also clears bit 7, marking the drone overlay as gone
L_E0C1:
lda droneControlFlags ; E0C1 drone control flags
and #$01 ; E0C4 bit 0 = a drone is airborne
beq L_E0DC ; E0C6 no drone - just run the normal turn
lda #$FF ; E0C8 $FF marks the drone overlay resident and blocks planning
sta D_8977 ; E0CA store it in the drone timer
jsr setSubOverlayLoadDest ; E0CD point the fast loader at $EE00
ldy #$1D ; E0D0 Y = track $1D = 29
ldx #$04 ; E0D2 X = first sector 4
lda #$02 ; E0D4 A = 2 sectors: the drone-flight AI
jsr loadSectors ; E0D6 load it over our own tail
jmp L_E0A5 ; E0D9 and fly the drone straight away
; ----------------------------------------------------------------------
; Normal exchange: tick the drone launch countdown, then move as many complete AI commands as fit out
; of the queue at $88A0 into the received-packet buffer $E020.
; ----------------------------------------------------------------------
L_E0DC:
lda D_8977 ; E0DC launch countdown
beq L_E0E4 ; E0DF already 0 - launchAiDrone may fire this exchange
dec D_8977 ; E0E1 otherwise tick it down by one exchange
L_E0E4:
jsr injectAiCommandsIntoPacket; E0E4 copy queued AI orders into the fabricated received packet
L_E0E7:
rts ; E0E7 return to sendOutgoingPacket
; ----------------------------------------------------------------------
; trainerUnitStepHook - Per-unit hook called at the end of processUnitStep ($4526) for currentUnit,
; i.e. once for every living unit of both sides every round. For one of the AI's own units (index >=
; 50) that has now scored both end zones (bits 3 and 4 of the type byte) it sets bit 7 of that unit's
; 'has penetrated' flag so the planner stops counting it as still at home; for the AI comcen (unit 99)
; it also samples the 'was hit' bit of its display flags into the current and the one-round-sticky
; comcen-hit flags. Everything else is passed to updateAiUnitTracking, the AI's observer.
; In: currentUnit $90FA, unitTypeTable $F76C, comcen display flags $FBB7, drone timer $8977
; Out: bit 7 of $8864+ordinal, $897B comcen hit this round, $897C comcen hit this or last round;
; otherwise calls $EACF
; Called from: processUnitStep $4526
; ----------------------------------------------------------------------
trainerUnitStepHook:
ldy currentUnit ; E0E8 the unit the engine has just stepped
lda unitTypeTable,y ; E0EB type byte: bit 7 = dead, bits 3/4 = end zones already scored, bits 0-2 = type
bmi L_E127 ; E0EE dead slot - nothing to observe
cpy #$32 ; E0F0 $32 = 50: units 0-49 are the human player's
bcc L_E11F ; E0F2 human unit - hand it to the observer
and #$18 ; E0F4 keep only the two zone-scored bits
L_E0F6:
cmp #$18 ; E0F6 both the near zone (bit 3, columns 10-19) and the far zone (bit 4, columns 0-9) scored?
bne L_E11F ; E0F8 not deep in the player's half yet
lda D_8832,y ; E0FA $8832+unit is $8864+ordinal: this AI unit's 'has penetrated' flag
ora #$80 ; E0FD mark it penetrated...
sta D_8832,y ; E0FF ...so computeTrainerGroupStats stops counting it as still at home
cpy #$63 ; E102 $63 = 99 = the AI's own comcen
bne L_E11F ; E104 any other AI unit - hand it to the observer
lda D_897B ; E106 this round's 'comcen was hit' flag...
sta D_897C ; E109 ...becomes the one-round-old sticky flag
ldx #$00 ; E10C assume 'not hit' this round
lda side1ComcenDisplayFlags ; E10E display flags of unit 99 ($FB54 array)
L_E111:
and #$02 ; E111 bit 1 is set by recordUnitHit ($3C1A) whenever the unit takes damage
beq L_E119 ; E113 not hit
dex ; E115 X = $FF = hit
stx D_897C ; E116 a fresh hit also re-arms the sticky flag
L_E119:
stx D_897B ; E119 record whether the comcen was hit this round
L_E11C:
jmp L_E127 ; E11C done with the comcen
L_E11F:
lda D_8977 ; E11F drone timer, bit 7 = the drone-flight overlay is resident, i.e. the AI's drone is airborne
bmi L_E127 ; E122 the AI suspends its unit observation for as long as its drone is in the air
jsr updateAiUnitTracking ; E124 track what this unit has seen and what it is shooting at
L_E127:
rts ; E127 back to processUnitStep
; ----------------------------------------------------------------------
; setSubOverlayLoadDest - Points the fast loader at $EE00 (X = destination low byte, Y = high byte)
; and tail-jumps into setLoadDest. Used before each of the two sub-overlay loads in
; buildTrainerReplyPacket.
; In: none
; Out: fast-loader destination = $EE00
; Called from: $E0AB and $E0CD
; ----------------------------------------------------------------------
setSubOverlayLoadDest:
ldx #$00 ; E128 destination low byte = $00
ldy #$EE ; E12A destination high byte = $EE
jmp setLoadDest ; E12C hand $EE00 to the fast loader's setLoadDest
; ----------------------------------------------------------------------
; pickTrainerRallyPoint - Chooses the AI's current objective: a cell scattered at random around the
; human comcen (unit 49), stored as the rally column/row that every attacking group heads for.
; Refreshed every six rounds, and immediately whenever the human comcen becomes visible.
; In: side0ComcenCol $F671, side0ComcenRow $F6D5, the game RNG
; Out: $8969 rally column, $896A rally row
; Called from: planTrainerRound $E188; trainerSetupEC00 initTrainerAi $EC47 (once at game start)
; ----------------------------------------------------------------------
pickTrainerRallyPoint:
lda side0ComcenCol ; E12F column of the human comcen (unit 49)
jsr randomiseCoordinate ; E132 scatter it by a random offset
sta D_8969 ; E135 rally column the AI's groups head for
lda side0ComcenRow ; E138 row of the human comcen
jsr randomiseCoordinate ; E13B scatter that as well
sta D_896A ; E13E rally row
rts ; E141 done
; randomCoordinateOffsetTable - eight signed cell offsets, picked with nextGameRandom AND 7 by
; randomiseCoordinate and then scaled by 1, 2 or 4 according to how well the AI knows where the human
; comcen is.
randomCoordinateOffsetTable:
.byte $01 ; E142 offsets +1,-1,+1,+1,-1,-1,+2 (entries 0-6)
.byte $FF,$01,$01,$FF,$FF,$02 ; E143 ......
L_E149:
.byte $FE ; E149 entry 7: -2 cells
; ----------------------------------------------------------------------
; randomiseCoordinate - Returns A = base coordinate plus a random offset, clamped to 0..39. The base
; is written into the ADC immediate at $E164 and the offset comes from randomCoordinateOffsetTable
; indexed by nextGameRandom AND 7. The offset is used raw while the human comcen is actually visible
; (bit 7 of its energy byte), doubled once it has ever been seen ($896C bit 7) and quadrupled
; otherwise - so the AI aims accurately at a comcen it can see and wanders when it cannot. $E14D is an
; alternate entry that reuses the base already patched in.
; In: A = base coordinate 0-39, side0ComcenEnergy $F9F5, $896C 'comcen has been seen', the game RNG
; Out: A = coordinate 0..39; self-modifies the operand byte at $E164 (L_E163+1)
; Called from: pickTrainerRallyPoint $E132 and $E13B
; ----------------------------------------------------------------------
randomiseCoordinate:
sta L_E163+1 ; E14A patch the base coordinate into the ADC immediate at $E164
randomiseCoordinateSameBase:
jsr nextGameRandom ; E14D alternate entry that reuses the base already patched in (no caller in this build)
and #$07 ; E150 keep 3 bits: offset table index 0-7
tay ; E152 use it as the table index
lda randomCoordinateOffsetTable,y; E153 signed cell offset -2..+2
bit side0ComcenEnergy ; E156 energy byte of the human comcen; bit 7 = 'currently spotted'
bmi L_E162 ; E159 visible right now - aim with the raw offset
asl a ; E15B not visible: double the scatter
bit D_896C ; E15C sticky 'the comcen has been seen at some point' flag
bmi L_E162 ; E15F seen before - x2 scatter is enough
asl a ; E161 never seen at all - x4, so the AI just roams
L_E162:
clc ; E162 prepare the add
L_E163:
adc #$FF ; E163 operand byte patched at $E14A with the base coordinate
bpl L_E169 ; E165 result still positive - keep it
lda #$00 ; E167 clamp to the left/top edge of the map
L_E169:
cmp #$28 ; E169 $28 = 40 columns/rows on the battlefield
L_E16B:
bcc L_E16F ; E16B inside the map
lda #$27 ; E16D clamp to the last cell, 39
L_E16F:
rts ; E16F A = coordinate 0-39
; ----------------------------------------------------------------------
; planTrainerRound - Top-level planner of the solo trainer, run once per game round. It resets the
; per-round order counter, refreshes the rally point, works out this round's action budget from the AI
; comcen's energy and stun state, and then either spends the round on comcen repair (budget below 20)
; or evaluates the threat around its own comcen and decides between attack mode ($8963 = 0, plus a
; drone decision) and defend mode ($8963 = $FF). It then re-plans every AI unit whose replan clock has
; come due and finishes by walking the comcen forward behind the assault group or retreating it.
; In: gameEndReason $0BA8, comcen records $F9F5/$FA27/$F9C3, gameTypeOptions $0BA3, gameClock $91CB,
; the RNG and the whole $8800-$897C AI work block
; Out: $8965 budget, $8968 order count, $896B rally timer, $896C comcen-seen flag, $8960-$8963 threat
; result, commands appended to the queue at $88A0
; Called from: runTrainerRoundHook $E07E
; ----------------------------------------------------------------------
planTrainerRound:
lda gameEndReason ; E170 non-zero once the game has ended
beq L_E176 ; E173 still playing
rts ; E175 game over - no plan
L_E176:
sta D_8968 ; E176 A is 0 here: clear the count of commands queued this round
; ----------------------------------------------------------------------
; The rally point (a cell near the human comcen) is refreshed every six rounds, or at once on the
; round the human comcen becomes visible.
; ----------------------------------------------------------------------
lda side0ComcenEnergy ; E179 human comcen energy byte, bit 7 = currently spotted
bpl L_E183 ; E17C not spotted this round
sta D_896C ; E17E spotted: bit 7 sticks in $896C, so randomiseCoordinate scatters less from now on...
bmi L_E188 ; E181 ...and the rally point is refreshed immediately
L_E183:
dec D_896B ; E183 otherwise count the rally-point refresh timer down
bpl L_E190 ; E186 not due yet
L_E188:
jsr pickTrainerRallyPoint ; E188 pick a new random cell near the human comcen
lda #$05 ; E18B 5 = next refresh in six rounds
sta D_896B ; E18D reload the timer
; ----------------------------------------------------------------------
; This round's action budget. Normally it is the AI comcen's own energy (0-50); with the COMCEN
; DAMAGE option on ($0BA3 bit 4) the comcen's stun counter modifies it. A budget under 20 means the
; AI can only afford to repair and refuel its comcen.
; ----------------------------------------------------------------------
L_E190:
lda side1ComcenEnergy ; E190 AI comcen energy (unit 99)
and #$3F ; E193 bits 0-5 hold the 0-50 energy points
sta D_8965 ; E195 that is this round's action budget
lda side1ComcenStun ; E198 AI comcen stun byte (unit 99 of the $F960 array)
and #$0F ; E19B low nibble = the STUN'D counter 0-15 shown on the console
sta D_8971 ; E19D remember how badly the comcen is stunned
lda gameTypeOptions ; E1A0 game options byte
and #$10 ; E1A3 bit 4 = the COMCEN DAMAGE option
beq L_E1C1 ; E1A5 damage rules off - use a flat budget
lda D_8971 ; E1A7 stun counter
beq L_E1C6 ; E1AA not stunned - keep the energy budget
cmp #$0F ; E1AC $0F = maximum stun
D_E1AE:
beq L_E1D0 ; E1AE completely out - the AI does nothing at all this round
cmp #$08 ; E1B0 stun 8-14
bcs L_E1C1 ; E1B2 heavy stun (8-14) takes the flat 50 budget while a light stun (1-7) is penalised 16 points below - counter-intuitive, possibly a slip for a branch to $E1CD
lda D_8965 ; E1B4 stun 1-7: take the energy budget...
sec ; E1B7 prepare the subtraction
sbc #$10 ; E1B8 ...and charge a 16 point penalty
bcc L_E1CD ; E1BA budget went negative - only repair the comcen
sta D_8965 ; E1BC store the reduced budget
bcs L_E1C6 ; E1BF always taken (the SBC above left C=1)
L_E1C1:
lda #$32 ; E1C1 $32 = 50 = a full budget
sta D_8965 ; E1C3 store it
L_E1C6:
lda D_8965 ; E1C6 this round's budget
cmp #$14 ; E1C9 $14 = 20 points
L_E1CB:
bcs L_E1D1 ; E1CB enough left to manoeuvre
L_E1CD:
jsr runAiComcenActions ; E1CD too little - spend the round on comcen repair and energy
L_E1D0:
rts ; E1D0 done
; ----------------------------------------------------------------------
; Threat assessment around the AI's own comcen. assessThreatAroundAiComcen buckets the human units by
; compass direction and returns the overall threat, the deepest weapon-range penetration and the
; safest direction; those three become $8962, $8961 and $8960. The AI defends when an enemy weapon
; actually covers the comcen and it has either no assault group to rely on or has just been hit.
; ----------------------------------------------------------------------
L_E1D1:
lda side1ComcenCol ; E1D1 AI comcen column
sta cellColB ; E1D4 reference point B of the threat scan
lda side1ComcenRow ; E1D6 AI comcen row
sta cellRowB ; E1D9 reference point B of the threat scan
jsr assessThreatAroundAiComcen; E1DB bucket the human units around it into 8 compass directions
sta D_8962 ; E1DE A = overall threat score
sty D_8960 ; E1E1 Y = safest direction 0-7 (8 = nothing anywhere)
stx D_8961 ; E1E4 X = deepest weapon-range penetration, 0 = no enemy weapon covers the comcen
ldy #$00 ; E1E7 assume attack mode (0)
L_E1E9:
cpx #$00 ; E1E9 is any enemy weapon covering the comcen?
beq L_E1F9 ; E1EB no threat at all - attack
dey ; E1ED Y = $FF = defend
bit D_895D ; E1EE is an assault group selected? bit 7 = none
bmi L_E1F9 ; E1F1 no group to rely on - defend
bit D_897C ; E1F3 was the comcen hit this round or last?
bmi L_E1F9 ; E1F6 yes - defend
iny ; E1F8 threatened but coping - attack after all
L_E1F9:
sty D_8963 ; E1F9 0 = attack, $FF = defend
tya ; E1FC A = the mode just chosen
bne L_E202 ; E1FD defending - do not spend a drone
jsr chooseAiDroneTarget ; E1FF attacking - decide what the next drone should be sent at
L_E202:
jsr scanTrainerUnitsForOrders; E202 issue orders to every AI unit whose replan clock has come due
lda D_8968 ; E205 commands queued so far this round
cmp #$05 ; E208 5 is the per-round ceiling
L_E20A:
bcs L_E21E ; E20A budget spent - stop here
jsr updateFocusGroupCentre ; E20C refresh the assault group's id and centre
ldx D_8963 ; E20F attack (0) or defend ($FF)?
bmi retreatTrainerComcen ; E212 defend - pull the comcen back and recall the group
lda D_8965 ; E214 action budget
cmp #$20 ; E217 $20 = 32 points
bcs pushTrainerComcenForward; E219 plenty of energy - walk the comcen up behind the attack
jsr runAiComcenActions ; E21B low budget - repair the comcen instead
L_E21E:
rts ; E21E done
; ----------------------------------------------------------------------
; pushTrainerComcenForward - Attack-mode tail of planTrainerRound (a branch target, not a JSR entry).
; If an assault group exists, the comcen is not stunned and its own replan clock has come due, it
; queues a MOVE for the AI comcen to a cell one step beyond the enemy's reach from the assault group's
; centre, and re-arms the comcen's replan clock to gameClock - 3. This is how the trainer walks its
; command centre up behind its attack.
; In: $895D assault group id, $8971 comcen stun, $8831 comcen replan clock, gameClock $91CB, $8960
; safest direction, $8961 enemy reach, $895E/$895F assault group centre
; Out: $8831 re-armed, one $80/$81 MOVE queued through orderAiUnitToCell
; Called from: planTrainerRound $E219 (branch)
; ----------------------------------------------------------------------
pushTrainerComcenForward:
ldy D_895D ; E21F assault group id, bit 7 = none selected
L_E222:
bmi L_E265 ; E222 no group to follow
lda D_8971 ; E224 comcen stun counter
bne L_E265 ; E227 stunned - it cannot move
lda D_8831 ; E229 the comcen's own replan clock
cmp gameClock ; E22C the clock counts down, so 'stamp >= clock' means the round has arrived
bcc L_E265 ; E22F not due yet
lda gameClock ; E231 current round
sec ; E234 prepare the subtraction
sbc #$03 ; E235 next comcen replan in 3 rounds
L_E237:
bcs L_E23B ; E237 no underflow
lda #$00 ; E239 clamp at round 0
L_E23B:
sta D_8831 ; E23B re-arm the comcen replan clock
ldx D_8960 ; E23E safest direction from the threat scan
lda dirToColDelta,x ; E241 column step -1/0/+1 for that direction
tax ; E244 X = column step for offsetCoordinateClamped
ldy D_8961 ; E245 how deep the enemy weapons reach
iny ; E248 one cell beyond that
lda msgOpponentUsingNonStandardGame; E249 $895E = assault group centre column (the label is a stale message-string name)
jsr offsetCoordinateClamped ; E24C column = centre + step * distance, clamped to 0..39
sta scratch18 ; E24F stash the target column
ldx D_8960 ; E251 same direction...
lda dirToRowDelta,x ; E254 ...row step -1/0/+1
tax ; E257 X = row step
L_E258:
lda D_895F ; E258 assault group centre row
jsr offsetCoordinateClamped ; E25B row = centre + step * distance, clamped
ldx scratch18 ; E25E X = column, A = row
L_E260:
ldy #$31 ; E260 $31 = AI ordinal 49 = unit 99, the AI comcen
jsr orderAiUnitToCell ; E262 queue the MOVE
L_E265:
rts ; E265 done
; ----------------------------------------------------------------------
; retreatTrainerComcen - Defend-mode tail of planTrainerRound (a branch target). It stamps the commit
; clock $896E with the current round - from then on groups are allowed to use their full combat value
; - and, unless the comcen is stunned, queues a MOVE that walks it away along the safest direction by
; as many cells as the enemy reaches. If the threat score still exceeds the recall threshold $897A, or
; the comcen was stunned, it recomputes the assault group's statistics and jumps to
; aimGroupAtOwnComcen to pull the whole group home.
; In: $8971 comcen stun, $8960 safest direction, $8961 enemy reach, $8962 threat score, $897A recall
; threshold, $895D assault group, comcen position $F6A3/$F707
; Out: $896E commit clock, $895A cleared, one comcen MOVE and possibly a full group recall
; Called from: planTrainerRound $E212 (branch)
; ----------------------------------------------------------------------
retreatTrainerComcen:
lda gameClock ; E266 current round
sta D_896E ; E269 stamp the commit clock: groups may now use their full strength
lda D_8971 ; E26C comcen stun counter
bne L_E29F ; E26F stunned - it cannot run, go straight to recalling the group
ldx D_8960 ; E271 safest direction
lda dirToColDelta,x ; E274 its column step
tax ; E277 X = column step
ldy D_8961 ; E278 run as far as the enemy reaches
lda side1ComcenCol ; E27B AI comcen column
jsr offsetCoordinateClamped ; E27E clamped destination column
sta scratch18 ; E281 stash it
L_E283:
ldx D_8960 ; E283 safest direction again
lda dirToRowDelta,x ; E286 its row step
tax ; E289 X = row step
lda side1ComcenRow ; E28A AI comcen row
sub_E28D:
jsr offsetCoordinateClamped ; E28D clamped destination row
ldx scratch18 ; E290 X = column
ldy #$31 ; E292 $31 = AI ordinal 49 = unit 99
jsr orderAiUnitToCell ; E294 queue the comcen MOVE
lda D_8962 ; E297 threat score from the scan
L_E29A:
cmp D_897A ; E29A recall threshold, set to 5 by the setup overlay at $EC44
bcc L_E2B6 ; E29D manageable - leave the assault group where it is
L_E29F:
lda D_895D ; E29F assault group id
cmp #$FF ; E2A2 $FF = no group at all
beq L_E2B6 ; E2A4 nothing to recall
and #$0F ; E2A6 strip the disable bit, leaving the group index 0-11
tay ; E2A8 Y = the group index for computeTrainerGroupStats
jsr computeTrainerGroupStats; E2A9 recompute its members, strength, centre and box
bcs L_E2B6 ; E2AC empty group - nothing to recall
lda #$00 ; E2AE pretend the group has no slow units...
sta D_895A ; E2B0 ...so issueTrainerGroupOrders does not double the replan delay - this is an urgent recall
jmp aimGroupAtOwnComcen ; E2B3 march the whole group home to the comcen
L_E2B6:
rts ; E2B6 done
; unitCombatValueTable - combat weight per unit type, indexed by the type bits 0-2 of $F76C. Summed
; into the 16-bit group value by computeTrainerGroupStats and also copied into the per-unit influence
; weight page $0464 by buildUnitThreatTables ($E7AF).
unitCombatValueTable:
.byte $02 ; E2B7 GRUNT 2, RIDER 2, BOOMER 3, SPY 0, COMCEN 0
.byte $02,$03,$00,$00 ; E2B8 ....
; trainerUnitScanIndex - the loop counter of scanTrainerUnitsForOrders, kept as a byte inside the code
; stream rather than in the $8800 work block. Written at $E2BF and $E2EB, read at $E2CE.
trainerUnitScanIndex:
.byte $00 ; E2BC current AI ordinal being scanned, 49 down to 0
; ----------------------------------------------------------------------
; scanTrainerUnitsForOrders - Walks the AI's 50 unit slots from ordinal 49 (unit 99, the comcen) down
; to 0, stopping as soon as four commands have been queued this round. A dead slot has its replan
; clock zeroed and is removed from every group; a slot whose replan clock has been reached by the
; down-counting game clock is handed to decideTrainerGroupAction, which re-plans the whole group that
; unit belongs to. The default replan urgency is reloaded with 5 before every unit.
; In: $8968 orders this round, gameClock $91CB, side1UnitTypeTable $F79E, replan clocks $8800, group
; ids $8832
; Out: $E2BC, $8800 and $8832 entries, $896D urgency; orders queued by the callee
; Called from: planTrainerRound $E202
; ----------------------------------------------------------------------
scanTrainerUnitsForOrders:
lda #$31 ; E2BD $31 = 49, the highest AI ordinal (unit 99)
sta trainerUnitScanIndex ; E2BF start the walk at the comcen and count down
; ----------------------------------------------------------------------
; Loop over AI ordinals 49..0. It maintains the embedded counter at $E2BC and stops early once four
; commands have been queued.
; ----------------------------------------------------------------------
L_E2C2:
lda D_8968 ; E2C2 commands queued so far this round
cmp #$04 ; E2C5 4 is the ceiling for unit orders
bcs L_E2F0 ; E2C7 budget spent
lda #$05 ; E2C9 default replan urgency: re-plan again in at most 5 rounds
sta D_896D ; E2CB reload the default urgency before every unit
ldy trainerUnitScanIndex ; E2CE current AI ordinal 49..0
lda side1UnitTypeTable,y ; E2D1 type byte of unit ordinal+50
bpl L_E2E0 ; E2D4 still alive
lda #$00 ; E2D6 dead: clear its replan clock...
sta msgGetOpponentOnPhoneAnd,y; E2D8 ...at $8800+ordinal
lda #$FF ; E2DB ...and take it out of every group
sta D_8832,y ; E2DD $FF = belongs to no group
L_E2E0:
lda msgGetOpponentOnPhoneAnd,y; E2E0 this unit's replan clock
cmp gameClock ; E2E3 the clock counts down; stamp >= clock means the planned round has come
L_E2E6:
bcc L_E2EB ; E2E6 not due yet
jsr decideTrainerGroupAction; E2E8 re-plan the whole group this unit belongs to
L_E2EB:
dec trainerUnitScanIndex ; E2EB next ordinal down
bpl L_E2C2 ; E2EE loop until ordinal 0 has been done
L_E2F0:
rts ; E2F0 done
; ----------------------------------------------------------------------
; decideTrainerGroupAction - Decides what the AI group owning unit ordinal Y should do. Group 0 is the
; comcen's own escort and is only re-planned while the comcen is unstunned and no assault group is
; selected, in which case group 0 simply inherits the comcen's own ordered destination. For any group
; it caches the group flags, trims them to the low nibble and calls computeTrainerGroupStats; an empty
; group aborts. It then picks one of four behaviours: march home to the AI comcen (the home-defence
; group, or any group during a recall), charge the rally point when the group has already penetrated
; and made contact, form a defensive line on row 29 when it has not, or - for group 0 - run a free
; direction search out from the comcen.
; In: Y = AI unit ordinal, $8832 group ids, $8971 comcen stun, $895D assault group, comcen
; destination $F833/$F897, $8964 home group, $8966 flag cache, zp_1D members still at home
; Out: $88D2/$88DE group target, $893E group flags, $896D urgency, $8955-$8963 group scratch; ends in
; one of the order-issuing tails
; Called from: scanTrainerUnitsForOrders $E2E8
; ----------------------------------------------------------------------
decideTrainerGroupAction:
lda D_8832,y ; E2F1 group id of this AI unit
bne L_E31A ; E2F4 groups 1..11 are ordinary objectives
; ----------------------------------------------------------------------
; Group 0 is the comcen's own escort. It is only worth re-planning while the comcen can still move
; and no assault group has been picked; then it just adopts the comcen's own destination as its
; target.
; ----------------------------------------------------------------------
lda D_8971 ; E2F6 group 0: is the comcen stunned?
bne L_E319 ; E2F9 stunned - leave the escort alone
ldx #$03 ; E2FB urgency 3 for the comcen's escort
stx D_896D ; E2FD urgency for group 0
ldx D_895D ; E300 is an assault group already selected?
bpl L_E319 ; E303 yes - group 0 is not the one to re-plan
lda side1ComcenDestCol ; E305 AI comcen ordered destination column
and #$3F ; E308 strip the flag bits, keep the 0-39 cell
sta D_88D2 ; E30A group 0 target column = wherever the comcen is heading
lda side1ComcenDestRow ; E30D AI comcen ordered destination row
and #$3F ; E310 strip the flag bits, keep the 0-39 cell
sta D_88DE ; E312 group 0 target row
lda #$00 ; E315 A = group id 0
beq L_E31A ; E317 always taken
L_E319:
rts ; E319 nothing to do for this group
L_E31A:
tay ; E31A Y = group id
L_E31B:
lda D_893E,y ; E31B flags of that group
sta D_8966 ; E31E keep a copy for the tests below (bit 6 = has made contact with the player's comcen)
and #$0F ; E321 clear bits 4-7, leaving the start-delay nibble
sta D_893E,y ; E323 write the trimmed flags back
jsr computeTrainerGroupStats; E326 members, strength, centre and bounding box of the group
bcc L_E32C ; E329 the group has members
rts ; E32B empty group - nothing to order
L_E32C:
ldy D_8955 ; E32C group id again
cpy D_8964 ; E32F the home-defence group chosen at setup ($EC39/$ECA3)
L_E332:
bne L_E356 ; E332 not the home group - decide between charging and digging in
lda #$02 ; E334 urgency 2 - re-plan the home guard often
sta D_896D ; E336 urgency for the home guard
; ----------------------------------------------------------------------
; aimGroupAtOwnComcen - Internal tail (JMP target from retreatTrainerComcen $E2B3, fall-through from
; $E336): points the line target at the AI comcen's cell, then falls into the shared code at $E343
; that copies the group centre into the search origin and the line target into the wanted destination,
; and jumps to issueTrainerGroupOrders. Used to recall a group to defend the comcen.
; In: comcen position $F6A3/$F707, group centre zp_93/zp_92 (left there by computeTrainerGroupStats)
; Out: zp_27/zp_28 = search origin, zp_22/zp_24 = wanted group destination; tail-jumps to $E49E
; Called from: $E2B3 (JMP) and by falling through from decideTrainerGroupAction $E336
; ----------------------------------------------------------------------
aimGroupAtOwnComcen:
lda side1ComcenCol ; E339 AI comcen column
sta lineEndCol ; E33C line target column
lda side1ComcenRow ; E33E AI comcen row
sta lineEndRow ; E341 line target row
; ----------------------------------------------------------------------
; sendGroupStraightToTarget - shared tail (also reached from $E3AF and $E3CD): move the group in a
; straight line to (lineEndCol, lineEndRow) with no threat search at all.
; ----------------------------------------------------------------------
sendGroupStraightToTarget:
lda lineCurCol ; E343 group centre column (set by computeTrainerGroupStats)
sta cellColB ; E345 search origin / reference column
lda lineCurRow ; E347 group centre row
sta cellRowB ; E349 search origin / reference row
lda lineEndCol ; E34B line target column
sta cellColA ; E34D wanted group destination column
lda lineEndRow ; E34F line target row
sta cellRowA ; E351 wanted group destination row
sub_E353:
jmp issueTrainerGroupOrders ; E353 issue the move orders for the whole group
; ----------------------------------------------------------------------
; Not the home guard. A group that still has members which have not scored both end zones, and which
; has not yet made contact with the player's comcen (flags bit 6), falls back to a defensive line;
; anything else pushes on towards the rally point.
; ----------------------------------------------------------------------
L_E356:
bit D_8966 ; E356 cached group flags
bvs L_E35F ; E359 bit 6 set: the group has already made contact with the player's comcen
lda scratch1D ; E35B count of members that have not scored both end zones
bne aimGroupAtHomeLine ; E35D some are still at home - form a defensive line instead
L_E35F:
ldy D_8955 ; E35F group id
L_E362:
bne L_E37A ; E362 groups 1..11 head for the rally point
lda D_8963 ; E364 group 0 (the comcen escort): attack or defend?
bmi L_E379 ; E367 defending - the escort stays where it is
lda D_8960 ; E369 safest direction around the comcen
sta scratch19 ; E36C use it as the fan heading
jsr buildDirectionFan ; E36E lay out the 8 candidate directions in $8896-$889D
lda #$05 ; E371 try to advance 5 cells
sta D_895B ; E373 wanted advance distance for the search
jmp findTrainerAdvanceCell ; E376 run the threat-aware path search
L_E379:
rts ; E379 nothing to do
; ----------------------------------------------------------------------
; Groups 1..11: head for the rally point. How boldly depends on whether the human comcen has been
; found, how much of the game is left and whether this group has already made contact with it.
; ----------------------------------------------------------------------
L_E37A:
lda D_8969 ; E37A rally column near the human comcen
sta lineEndCol ; E37D line target column
lda D_896A ; E37F rally row
sta lineEndRow ; E382 line target row
L_E384:
lda side0ComcenEnergy ; E384 human comcen energy byte, bit 7 = spotted
bmi L_E392 ; E387 spotted
lda gameClock ; E389 rounds left in the game
cmp #$28 ; E38C $28 = 40 rounds
bcs searchGroupPathToTarget ; E38E plenty of time left - just search for a safe advance
bcc L_E39F ; E390 late in the game - be twice as bold
L_E392:
bit D_8966 ; E392 cached group flags
bvc searchGroupPathToTarget ; E395 no contact with the comcen yet - just search
lda D_893E,y ; E397 group flags
ora #$80 ; E39A bit 7 = this group has the spotted comcen in its sights
sta D_893E,y ; E39C store the flags back
L_E39F:
asl D_8956 ; E39F double the group's combat value, low byte...
D_E3A2:
rol D_8957 ; E3A2 ...and high byte, so the risk test accepts twice as much opposition
bit D_8966 ; E3A5 cached group flags
bvc searchGroupPathToTarget ; E3A8 no contact - fall back to the cautious search
lda D_8976 ; E3AA the unit the next drone is aimed at
cmp #$31 ; E3AD $31 = 49 = the human comcen
beq sendGroupStraightToTarget; E3AF the drone is already going for the comcen - the group beelines to the rally point too
jsr isPathToPlayerComcenClear; E3B1 is the straight line to the human comcen free of living enemy combat units?
bcc searchGroupPathToTarget ; E3B4 blocked - fall back to the cautious search
sub_E3B6:
ldy D_8955 ; E3B6 group id
sub_E3B9:
lda D_8902,y ; E3B9 number of members
sub_E3BC:
cmp #$04 ; E3BC 4 or more
bcc L_E3C8 ; E3BE too small to commit
lda D_893E,y ; E3C0 group flags
ora #$20 ; E3C3 bit 5 = committed to the all-out attack (chooseAiDroneTarget weighs this doubled)
sta D_893E,y ; E3C5 store the committed flag back
L_E3C8:
lda #$03 ; E3C8 urgency 3
sta D_896D ; E3CA re-plan this group again in 3 rounds at the latest
jmp sendGroupStraightToTarget; E3CD charge straight at the rally point
; ----------------------------------------------------------------------
; aimGroupAtHomeLine - Internal branch target used when the group still has members that have not
; reached the player's end zones and has no contact with his comcen: the target column stays where the
; group already is and the target row becomes 29 plus the group's own vertical extents, i.e. a
; defensive line inside the AI's own half. While the commit clock $896E has not been passed the
; group's combat value is also capped at 9, which makes the risk test reject anything but a very quiet
; cell.
; In: group centre zp_93, group spans $891A/$8932, commit clock $896E, gameClock $91CB, group value
; $8956/$8957
; Out: zp_94/zp_95 target cell, $8956/$8957 possibly capped at 9; falls into the path search at $E400
; Called from: decideTrainerGroupAction $E35D (branch)
; ----------------------------------------------------------------------
aimGroupAtHomeLine:
lda lineCurCol ; E3D0 group centre column
sta lineEndCol ; E3D2 hold the column, only the row changes
lda #$1D ; E3D4 $1D = row 29
ldy D_8955 ; E3D6 group id
clc ; E3D9 prepare the two adds
adc D_891A,y ; E3DA plus the group's reach above its centre
adc D_8932,y ; E3DD plus its reach below, so the whole formation box sits inside the AI's own half
sta lineEndRow ; E3E0 defensive line row
lda D_896E ; E3E2 commit clock, set at setup ($ED85) and re-stamped by every comcen retreat
cmp gameClock ; E3E5 the clock counts down: stamp >= clock means the commit round has passed
bcs searchGroupPathToTarget ; E3E8 committed - let the group use its real strength
L_E3EA:
lda D_8957 ; E3EA group combat value, high byte
bne L_E3F6 ; E3ED over 255 - definitely cap it
lda D_8956 ; E3EF low byte
L_E3F2:
cmp #$09 ; E3F2 9 is the cautious ceiling
bcc searchGroupPathToTarget ; E3F4 already weak enough
L_E3F6:
lda #$00 ; E3F6 cap the value at 9 so the risk test only accepts very quiet cells
sta D_8957 ; E3F8 high byte of the capped value = 0
lda #$09 ; E3FB 9 = the cautious ceiling
sta D_8956 ; E3FD low byte of the capped value
; ----------------------------------------------------------------------
; searchGroupPathToTarget - shared entry (branch target from $E38E, $E395, $E3A8, $E3B4, $E3E8 and
; $E3F4): measure how far the target is, build the eight-way candidate fan around the heading towards
; it and run the threat-aware search. When the group already stands on its target there is no
; heading, so the search is skipped and the members are merely re-stamped with a two-round replan
; delay.
; ----------------------------------------------------------------------
searchGroupPathToTarget:
lda lineCurCol ; E400 group centre column
sta cellColB ; E402 search origin column
lda lineCurRow ; E404 group centre row
sta cellRowB ; E406 search origin row
lda lineEndCol ; E408 target column
sta cellColA ; E40A point A of the distance measure
lda lineEndRow ; E40C target row
sta cellRowA ; E40E point A row
jsr cellDistance ; E410 distance from the group to its target, 0-16 cells
sta D_895B ; E413 that is how far the group would like to advance
D_E416:
jsr buildDirectionFanTowardTarget; E416 heading towards the target plus the 8 candidate directions in $8896-$889D
lda scratch19 ; E419 heading 0-7, or $FF when the group already stands on the target
bpl findTrainerAdvanceCell ; E41B valid heading - search for a safe cell along it
lda #$00 ; E41D already there: zero movement delta
sta scratch2A ; E41F no column shift
sta scratch2B ; E421 no row shift
lda #$02 ; E423 replan again in 2 rounds
sta scanListIndex ; E425 zp_29 = replan delay in rounds
jmp L_E4C7 ; E427 skip the search and just re-stamp the members
; ----------------------------------------------------------------------
; findTrainerAdvanceCell - Threat-aware path search. It first builds the per-unit influence map
; (radius in $0400, weight in $0464) with buildUnitThreatTables. Then, for each of the eight direction
; ranks in the fan at $8896, it steps outward from the search origin in jumps of two cells, testing
; every candidate with isGroupDestinationTooRisky; it keeps extending while the cell is acceptable, up
; to the wanted advance distance $895B or the per-rank cap in maxAdvanceByRankTable, and moves on to
; the next rank as soon as a candidate is rejected before either limit is reached. If no rank works
; the group stays put (direction 0, distance 0). The winning direction and distance are finally turned
; into the absolute target cell zp_22/zp_24 and the code falls into issueTrainerGroupOrders.
; In: zp_27/zp_28 search origin, $8896-$889D direction fan, $895B wanted distance, $8956/$8957 group
; strength
; Out: zp_22/zp_24 = chosen target cell, zp_23 = distance, zp_26 = direction, $0400/$0464 influence
; map; falls into $E49E
; Called from: decideTrainerGroupAction $E376 (JMP) and $E41B (branch)
; ----------------------------------------------------------------------
findTrainerAdvanceCell:
jsr buildUnitThreatTables ; E42A build the per-unit influence map in the $0400/$0464 scratch pages
D_E42D:
lda #$00 ; E42D start with direction rank 0, the best one in the fan
D_E42F:
sta cellColA ; E42F zp_22 doubles as the rank index for the length of the search
; ----------------------------------------------------------------------
; Outer loop over the eight direction ranks; inner loop at $E435 pushes two cells further out at a
; time along the current rank while the destination stays acceptable.
; ----------------------------------------------------------------------
L_E431:
lda #$00 ; E431 distance reached along this rank = 0
sta scratch23 ; E433 zp_23 = distance reached along this rank
L_E435:
ldy cellColA ; E435 current rank
ldx D_8896,y ; E437 the direction the fan put in that rank
stx scanRowCount ; E43A keep it as the candidate heading
lda scratch23 ; E43C distance reached so far
clc ; E43E prepare the add
adc #$02 ; E43F probe two cells further out
sta scratch2A ; E441 candidate distance
tay ; E443 Y = distance for the risk test
D_E444:
jsr isGroupDestinationTooRisky; E444 too much enemy influence on the formation box at that spot?
D_E447:
bcs L_E464 ; E447 rejected - stop extending this rank
ldx scanRowCount ; E449 candidate heading
ldy scratch2A ; E44B candidate distance
jsr offsetPointInDirection ; E44D does the cell itself stay on the 40x40 map?
bcs L_E464 ; E450 off the map - stop extending
lda scratch2A ; E452 accept the new distance
sta scratch23 ; E454 remember it as the distance reached on this rank
cmp D_895B ; E456 as far as the group wanted to go?
D_E459:
bcs L_E480 ; E459 yes - take it
D_E45B:
ldy cellColA ; E45B rank
cmp maxAdvanceByRankTable,y ; E45D per-rank distance cap: 5,5,5,3,3,1,1,1
bcs L_E480 ; E460 cap reached - take it
bcc L_E435 ; E462 keep pushing outward along this rank
L_E464:
lda scratch23 ; E464 distance already accepted on this rank
cmp D_895B ; E466 far enough anyway?
bcs L_E480 ; E469 yes - take it
ldy cellColA ; E46B rank
cmp maxAdvanceByRankTable,y ; E46D per-rank cap
bcs L_E480 ; E470 at the cap - take it
inc cellColA ; E472 try the next-best direction
lda cellColA ; E474 the new rank index
cmp #$08 ; E476 the fan holds 8 candidate directions
bcc L_E431 ; E478 still ranks left to try
lda #$00 ; E47A nothing worked at all: stay put
sta scratch23 ; E47C distance 0...
L_E47E:
sta scanRowCount ; E47E ...and direction 0
; ----------------------------------------------------------------------
; Turn the chosen (direction, distance) pair back into an absolute cell and fall into the order
; issuer.
; ----------------------------------------------------------------------
L_E480:
ldx scanRowCount ; E480 chosen heading
lda dirToColDelta,x ; E482 its column step -1/0/+1
tax ; E485 X = column step
L_E486:
ldy scratch23 ; E486 chosen distance
lda cellColB ; E488 search origin column
jsr offsetCoordinateClamped ; E48A target column = origin + step * distance, clamped to 0..39
sta cellColA ; E48D chosen target column
ldx scanRowCount ; E48F chosen heading
lda dirToRowDelta,x ; E491 its row step -1/0/+1
tax ; E494 X = row step
ldy scratch23 ; E495 chosen distance
lda cellRowB ; E497 search origin row
jsr offsetCoordinateClamped ; E499 target row, clamped
sta cellRowA ; E49C chosen target row - fall into the order issuer
; ----------------------------------------------------------------------
; issueTrainerGroupOrders - Issues the move orders for the group in $8955. It first stamps every
; member's replan clock with the current round so the scan loop cannot pick the group again, clips the
; wanted target with clampGroupTargetToMap (which also yields the movement delta zp_2A/zp_2B relative
; to the group's average ordered destination), derives a replan delay from the distance to the target
; (doubled when the group contains GRUNTs or BOOMERs, floored at 4 rounds and capped by the urgency
; $896D), and then walks all 50 AI slots giving every member a MOVE to its own destination shifted by
; the group delta - so the whole formation translates without changing shape. A member that also
; belongs to a real in-game group propagates its new replan clock to all its formation mates.
; In: $8955 current group, zp_22/zp_24 wanted target, gameClock $91CB, $895A slow-unit flag, $896D
; urgency, $8832 group ids, destinations $F802/$F866, unit flags $F73A
; Out: replan clocks $8800, group target $88D2/$88DE, commands appended to the queue at $88A0, zp_29
; delay, zp_2A/zp_2B delta
; Called from: aimGroupAtOwnComcen $E353 (JMP) and by falling through from findTrainerAdvanceCell
; $E49C
; Stamp every member of the group with the current round, so that scanTrainerUnitsForOrders will not
; re-plan the same group again later in this pass.
; ----------------------------------------------------------------------
issueTrainerGroupOrders:
ldy #$31 ; E49E walk AI ordinals 49..0
L_E4A0:
lda D_8832,y ; E4A0 group id of this unit
cmp D_8955 ; E4A3 the group being ordered
bne L_E4AE ; E4A6 not a member
lda gameClock ; E4A8 current round
sta msgGetOpponentOnPhoneAnd,y; E4AB stamp the member's replan clock ($8800+ordinal)
L_E4AE:
dey ; E4AE next ordinal
bpl L_E4A0 ; E4AF loop over all 50 slots
; ----------------------------------------------------------------------
; Work out where the formation may actually stand and how long it should be left alone afterwards.
; ----------------------------------------------------------------------
jsr clampGroupTargetToMap ; E4B1 clip the target so the whole formation box stays on the map; leaves the delta in zp_2A/zp_2B
jsr cellDistance ; E4B4 distance from the group centre to the clipped target
bit D_895A ; E4B7 does the group contain GRUNTs or BOOMERs?
bpl L_E4BD ; E4BA all fast - use the raw distance
asl a ; E4BC slow group - leave it alone twice as long
L_E4BD:
sta scanListIndex ; E4BD replan delay in rounds
cmp #$04 ; E4BF minimum 4 rounds
bcs L_E4C7 ; E4C1 already at least 4
lda #$04 ; E4C3 floor the delay at 4
sta scanListIndex ; E4C5 store the floored delay
L_E4C7:
lda D_896D ; E4C7 urgency of this group (2 home guard, 3 comcen escort / committed attack, 5 default)
cmp scanListIndex ; E4CA is the delay longer than the urgency allows?
bcs L_E4D0 ; E4CC no
sta scanListIndex ; E4CE cap the delay at the urgency
; ----------------------------------------------------------------------
; Second pass over all 50 AI slots: every member gets a MOVE to its own ordered destination plus the
; group delta, and a new replan clock of gameClock - delay. zp_22 is reused here as the loop counter.
; ----------------------------------------------------------------------
L_E4D0:
lda #$31 ; E4D0 walk AI ordinals 49..0 again
sub_E4D2:
sta cellColA ; E4D2 zp_22 is the loop counter for this pass
L_E4D4:
ldy cellColA ; E4D4 current ordinal
lda D_8832,y ; E4D6 its group id
cmp D_8955 ; E4D9 the group being ordered
bne L_E529 ; E4DC not a member
lda msgGetOpponentOnPhoneAnd,y; E4DE its replan clock
cmp gameClock ; E4E1 must still carry the stamp written above
bcc L_E529 ; E4E4 a formation mate already re-planned it
lda side1UnitDestColTable,y ; E4E6 the unit's own ordered destination column
and #$3F ; E4E9 drop the flag bits, keep the 0-39 cell
clc ; E4EB prepare the add
adc scratch2A ; E4EC shift it by the group's column delta
tax ; E4EE X = target column
lda side1UnitDestRowTable,y ; E4EF its ordered destination row
and #$3F ; E4F2 drop the flag bits, keep the 0-39 cell
clc ; E4F4 prepare the add
adc scratch2B ; E4F5 shift it by the group's row delta
jsr orderAiUnitToCell ; E4F7 queue $80 MOVE (or $81 MOVE GROUP) for this unit
L_E4FA:
ldy cellColA ; E4FA ordinal again
lda gameClock ; E4FC current round
sec ; E4FF prepare the subtraction
sbc scanListIndex ; E500 next replan in 'delay' rounds
sub_E502:
bcs L_E506 ; E502 no underflow
lda #$00 ; E504 clamp at round 0
L_E506:
sta msgGetOpponentOnPhoneAnd,y; E506 new replan clock for this unit
sta scratch18 ; E509 keep a copy for the formation mates
lda side1UnitFlagsTable,y ; E50B the unit's authoritative flags byte
and #$7C ; E50E bits 2-6: in-game group id (2-4), side (5) and the 'in a group' bit (6)
cmp #$40 ; E510 bit 6 clear means the unit is not in a player-style group
bcc L_E529 ; E512 no formation mates to update
L_E514:
sta scratch19 ; E514 the group key to match
ldx #$31 ; E516 scan all 50 AI ordinals
L_E518:
lda side1UnitFlagsTable,x ; E518 flags of that unit
sub_E51B:
and #$7C ; E51B same mask
cmp scratch19 ; E51D same in-game group?
bne L_E526 ; E51F no
lda scratch18 ; E521 the replan clock just computed
sta msgGetOpponentOnPhoneAnd,x; E523 give the formation mate the same clock, so the group is not ordered twice
L_E526:
dex ; E526 next ordinal
bpl L_E518 ; E527 loop over all 50 slots
L_E529:
dec cellColA ; E529 next ordinal
bmi L_E530 ; E52B all 50 slots done
L_E52D:
jmp L_E4D4 ; E52D keep going (out of branch range)
L_E530:
rts ; E530 done
; ----------------------------------------------------------------------
; computeTrainerGroupStats - Gathers the statistics of AI group Y. It scans the 50 AI slots and, for
; every living member, sums the per-type combat weights into the 16-bit group value, notes whether the
; group contains slow units (GRUNT or BOOMER), counts the members that have not yet scored both end
; zones, and tracks the minimum and maximum of the members' ordered destinations. computeGroupCentroid
; then supplies the member count and the average current and ordered positions; those become the group
; centre, the group size and the four formation-box offsets (reach left, right, up, down). Returns C=1
; for an empty group.
; In: Y = group id, $8832 group ids, types $F79E, destinations $F802/$F866, penetration flags $8864,
; $895D assault group, $8895 comcen penetration flag
; Out: $8955 current group, $8956/$8957 value, $895A slow flag, $8902 size, $88EA/$88F6 centre,
; $890E/$8926/$891A/$8932 box, zp_1D still-at-home count, zp_93/zp_92 = centre, zp_1E/zp_1F =
; average destination; C=1 if the group is empty
; Called from: retreatTrainerComcen $E2A9 and decideTrainerGroupAction $E326
; ----------------------------------------------------------------------
computeTrainerGroupStats:
sty D_8955 ; E531 remember which group is being measured
sub_E534:
ldx #$00 ; E534 0 for the accumulators
stx scratch1D ; E536 members that have not scored both end zones
stx D_8956 ; E538 combat value low byte
stx D_8957 ; E53B combat value high byte
stx cellRowA ; E53E zp_24 = highest destination column seen (max tracker starts at 0)
sub_E540:
stx scanColCount ; E540 zp_25 = highest destination row seen
stx D_895A ; E542 no slow units found yet
dex ; E545 $FF for the minimum trackers
stx cellColA ; E546 zp_22 = lowest destination column seen
stx scratch23 ; E548 zp_23 = lowest destination row seen
; ----------------------------------------------------------------------
; Loop over AI ordinals 49..0 accumulating strength, the slow-unit flag, the still-at-home count and
; the bounding box of the members' ordered destinations.
; ----------------------------------------------------------------------
ldy #$31 ; E54A walk AI ordinals 49..0
L_E54C:
lda D_8832,y ; E54C group id of this unit
cmp D_8955 ; E54F the group being measured
bne L_E5A2 ; E552 not a member
lda side1UnitTypeTable,y ; E554 type byte
bmi L_E5A2 ; E557 dead
and #$07 ; E559 bits 0-2 = unit type 0-4
tax ; E55B X = unit type, used as the index into unitCombatValueTable
beq L_E562 ; E55C type 0 GRUNT is slow
cpx #$02 ; E55E type 2 BOOMER is slow too
bne L_E567 ; E560 RIDER, SPY and COMCEN are not
L_E562:
lda #$FF ; E562 $FF = the group contains slow units
sta D_895A ; E564 flag the group as slow
L_E567:
lda unitCombatValueTable,x ; E567 combat weight of this type (2,2,3,0,0)
clc ; E56A prepare the add
L_E56B:
adc D_8956 ; E56B add it into the 16-bit group value
sta D_8956 ; E56E new low byte
lda D_8957 ; E571 high byte
sub_E574:
adc #$00 ; E574 carry into the high byte
sta D_8957 ; E576 new high byte
lda D_8864,y ; E579 this unit's 'has scored both end zones' flag
bmi L_E580 ; E57C already deep in the player's half
inc scratch1D ; E57E still at home - count it
L_E580:
lda side1UnitDestColTable,y ; E580 its ordered destination column
and #$3F ; E583 strip the flag bits
cmp cellColA ; E585 below the running minimum?
bcs L_E58B ; E587 no
sta cellColA ; E589 new leftmost destination
L_E58B:
cmp cellRowA ; E58B above the running maximum?
bcc L_E591 ; E58D no
sta cellRowA ; E58F new rightmost destination
L_E591:
lda side1UnitDestRowTable,y ; E591 its ordered destination row
and #$3F ; E594 strip the flag bits
cmp scratch23 ; E596 below the running minimum?
bcs L_E59C ; E598 no
sta scratch23 ; E59A new topmost destination
L_E59C:
cmp scanColCount ; E59C above the running maximum?
bcc L_E5A2 ; E59E no
sta scanColCount ; E5A0 new bottom-most destination
L_E5A2:
dey ; E5A2 next ordinal
bpl L_E54C ; E5A3 loop over all 50 slots
ldy D_8955 ; E5A5 group id
cpy D_895D ; E5A8 is this the assault group?
bne L_E5B4 ; E5AB no
lda D_8895 ; E5AD $8895 = $8864+49 = the AI comcen's own 'has penetrated' flag
bmi L_E5B4 ; E5B0 the comcen is already deep in enemy ground
inc scratch1D ; E5B2 otherwise the assault group counts as one more unit still at home
; ----------------------------------------------------------------------
; Average the members' positions and destinations, then store the centre, the size and the four
; formation-box offsets that the risk test and the target clipper work with.
; ----------------------------------------------------------------------
L_E5B4:
ldy #$31 ; E5B4 scan ordinals 49..0
sub_E5B6:
lda D_8955 ; E5B6 group id to match
jsr computeGroupCentroid ; E5B9 average position and average ordered destination of the members
D_E5BC:
sty scratch18 ; E5BC Y = average row
D_E5BE:
ldy D_8955 ; E5BE group id
L_E5C1:
lda scratch1C ; E5C1 member count
sub_E5C3:
sta D_8902,y ; E5C3 group size
bne L_E5CA ; E5C6 the group has members
sec ; E5C8 C=1 = empty group
rts ; E5C9 nothing more to do
L_E5CA:
txa ; E5CA X = average column
sta D_88EA,y ; E5CB group centre column
sta lineCurCol ; E5CE and the line walker's current column, for the callers
lda scratch18 ; E5D0 average row
sta D_88F6,y ; E5D2 group centre row
L_E5D5:
sta lineCurRow ; E5D5 and the line walker's current row
lda scratch1E ; E5D7 average ordered destination column
sec ; E5D9 prepare the subtraction
sbc cellColA ; E5DA minus the leftmost destination
sta D_890E,y ; E5DC = how far the formation reaches left of its centre
lda cellRowA ; E5DF rightmost destination
sec ; E5E1 prepare the subtraction
sbc scratch1E ; E5E2 minus the average
L_E5E4:
clc ; E5E4 clear carry for the +1 below
adc #$01 ; E5E5 +1 so the box includes the edge cell itself
sta D_8926,y ; E5E7 = how far the formation reaches right
lda scratch1F ; E5EA average ordered destination row
sec ; E5EC prepare the subtraction
sbc scratch23 ; E5ED minus the topmost destination
sta D_891A,y ; E5EF = reach above the centre
lda scanColCount ; E5F2 bottom-most destination
sec ; E5F4 prepare the subtraction
sbc scratch1F ; E5F5 minus the average
clc ; E5F7 clear carry for the +1 below
L_E5F8:
adc #$01 ; E5F8 +1 for the edge cell
sta D_8932,y ; E5FA = reach below the centre
clc ; E5FD C=0 = the group exists
rts ; E5FE done
; ----------------------------------------------------------------------
; clampGroupTargetToMap - Clips the wanted group centre in zp_22/zp_24 so that the whole formation box
; (reach left/right in $890E/$8926 and up/down in $891A/$8932) still fits inside the 40x40 map, stores
; the clipped cell as the group's target and returns the movement delta relative to the group's
; average ordered destination in zp_2A (columns, also kept in $8958) and zp_2B (rows).
; In: zp_22/zp_24 wanted centre, zp_1E/zp_1F average ordered destination, $8955 current group, the
; four box offsets
; Out: zp_22/zp_24 clipped, zp_2A/zp_2B delta, $8958, $88D2/$88DE group target
; Called from: issueTrainerGroupOrders $E4B1; trainerSetupEC00 shiftAiGroupDestinations $EF00
; ----------------------------------------------------------------------
clampGroupTargetToMap:
lda cellColA ; E5FF wanted target column
sec ; E601 prepare the subtraction
ldy D_8955 ; E602 group id
sbc D_890E,y ; E605 minus the formation's reach to the left
bpl L_E60F ; E608 left edge still >= column 0
lda D_890E,y ; E60A too far left: the leftmost the centre may sit at
sta cellColA ; E60D push the centre right so the box fits
L_E60F:
lda cellColA ; E60F target column
clc ; E611 prepare the add
adc D_8926,y ; E612 plus the reach to the right
cmp #$28 ; E615 $28 = 40 columns
bcc L_E620 ; E617 right edge fits
lda #$27 ; E619 $27 = column 39; the CMP above left C=1, so this SBC is exact without a SEC
sbc D_8926,y ; E61B minus the reach right
sta cellColA ; E61E clipped centre column
L_E620:
lda cellColA ; E620 clipped column
sec ; E622 prepare the subtraction
ldy D_8955 ; E623 group id (Y already holds it - a redundant reload)
sbc scratch1E ; E626 minus the group's average ordered column
L_E628:
sta D_8958 ; E628 column delta, also kept here for the setup overlay
sta scratch2A ; E62B column delta the caller adds to every member
lda cellRowA ; E62D wanted target row
sec ; E62F prepare the subtraction
ldy D_8955 ; E630 group id (redundant reload)
sbc D_891A,y ; E633 minus the reach upward
bpl L_E63D ; E636 top edge still >= row 0
lda D_891A,y ; E638 too high: the topmost the centre may sit at
sta cellRowA ; E63B push the centre down so the box fits
L_E63D:
lda cellRowA ; E63D target row
clc ; E63F prepare the add
adc D_8932,y ; E640 plus the reach downward
cmp #$28 ; E643 $28 = 40 rows
bcc L_E64F ; E645 bottom edge fits
lda #$27 ; E647 $27 = row 39
sec ; E649 explicit SEC here, unlike the column path above which relies on the CMP carry
sbc D_8932,y ; E64A minus the reach down
sta cellRowA ; E64D clipped centre row
L_E64F:
lda cellRowA ; E64F clipped row
sec ; E651 prepare the subtraction
ldy D_8955 ; E652 group id (redundant reload)
sbc scratch1F ; E655 minus the group's average ordered row
sub_E657:
sta scratch2B ; E657 row delta the caller adds to every member
lda cellColA ; E659 clipped column
sta D_88D2,y ; E65B remember it as this group's target cell
lda cellRowA ; E65E clipped row
sta D_88DE,y ; E660 and its target row
L_E663:
rts ; E663 done
; maxAdvanceByRankTable - how many cells the path search may advance for direction-preference rank
; 0..7. The three best headings may be followed up to 5 cells, the next two 3 cells and the worst
; three only 1, so a group that has to detour a long way round also moves a shorter distance.
maxAdvanceByRankTable:
.byte $05 ; E664 5,5,5,3,3,1,1,1 cells per rank
.byte $05,$05,$03,$03,$01,$01,$01; E665 .......
; ----------------------------------------------------------------------
; offsetPointInDirection - Computes the cell that lies Y steps in compass direction X from the
; reference point (zp_27 column, zp_28 row), using dirToColDelta/dirToRowDelta and
; offsetCoordinateClamped. The result is left in zp_18 (column) and zp_19 (row); carry set means the
; raw coordinate left the 40x40 map, and when the column already left it the row is not computed at
; all.
; In: X = direction 0..7 (0 = north, clockwise), Y = number of steps, zp_27/zp_28 = origin column/row
; Out: zp_18/zp_19 = target cell, C=1 when the point fell off the map; self-modifies the LDX immediate
; at $E67D (L_E67C+1)
; Called from: findTrainerAdvanceCell $E44D, isGroupDestinationTooRisky $E69C, the drone target scan
; $EF9F
; ----------------------------------------------------------------------
offsetPointInDirection:
stx L_E67C+1 ; E66C patch the direction into the LDX immediate at $E67D so it can be reused for the row table
lda dirToColDelta,x ; E66F column delta -1/0/+1 of that direction
tax ; E672 X = column step for offsetCoordinateClamped
L_E673:
lda cellColB ; E673 origin column
jsr offsetCoordinateClamped ; E675 column + step * distance, clamped to 0..39
bcs L_E689 ; E678 left the map - report failure, the row is not computed
sta scratch18 ; E67A candidate column
L_E67C:
ldx #$FF ; E67C operand patched at $E66C with the direction index
lda dirToRowDelta,x ; E67E row delta -1/0/+1 of that direction
tax ; E681 X = row step
lda cellRowB ; E682 origin row
jsr offsetCoordinateClamped ; E684 row + step * distance, clamped to 0..39
sta scratch19 ; E687 candidate row
L_E689:
rts ; E689 C=1 when either coordinate had to be clamped back onto the 40x40 map
; Compass direction tables shared by the whole trainer AI. Directions are numbered 0-7 clockwise from
; north: 0 N, 1 NE, 2 E, 3 SE, 4 S, 5 SW, 6 W, 7 NW. A ninth entry (index 8) holds the delta pair 0,0
; so that 'no direction at all' can be expressed as an ordinary index - assessThreatAroundAiComcen
; returns 8 when nothing threatens the AI comcen. Read by $E241/$E274 (comcen push/retreat),
; $E482 (path search), offsetPointInDirection ($E66F/$E67E) and getStepDirection ($E816/$E81E).
dirToColDelta:
.byte $00,$01,$01,$01,$00,$FF,$FF,$FF; E68A ........ column step of directions 0-7: N 0, NE +1, E +1, SE +1, S 0, SW -1, W -1, NW -1
.byte $00 ; E692 index 8: the null direction, column step 0
; dirToRowDelta: byteTable, 9 bytes. Row delta of the same 8 directions: -1,-1,0,+1,+1,+1,0,-1, plus
; the 9th zero entry. Direction 0 = north, 2 = east, 4 = south, 6 = west.
dirToRowDelta:
.byte $FF,$FF,$00,$01,$01,$01,$00,$FF; E693 ........ row step of directions 0-7: N -1, NE -1, E 0, SE +1, S +1, SW +1, W 0, NW -1
.byte $00 ; E69B index 8: the null direction, row step 0
; ----------------------------------------------------------------------
; isGroupDestinationTooRisky - influence-map test of one candidate destination for the AI group in
; currentAiGroup ($8955). Steps Y cells in direction X away from (cellColB,cellRowB) with
; offsetPointInDirection, grows that cell into the rectangle the whole formation would cover (the four
; span bytes $890E left / $891A up / $8926 right / $8932 down, clamped to 0..39) and plants the four
; edges into the four CMP operands of its own scan loop. It then walks all 100 units and sums a
; signed 16-bit score: every unit whose square influence area (side 2*unitThreatRadius $0400) overlaps
; the rectangle adds unitThreatWeight ($0464) if it is one of the human player's units 0-49 and
; subtracts it if it is one of the AI's own 50-99 (units of the moving group itself were already given
; radius 0 by buildUnitThreatTables). The verdict compares the enemy surplus with the group's own
; combat value $8956/$8957.
; In: X = direction 0-7, Y = distance in cells, zp_27/zp_28 = origin column/row; $8955
; currentAiGroup, $8954 aiGroupCount,
; span tables $890E/$891A/$8926/$8932, threat tables $0400/$0464 (built by
; buildUnitThreatTables), group value $8956/$8957,
; $896E aggression-phase clock, $91CB game clock
; Out: C=1 = reject (off the map, or enemy threat >= own combat value); C=0 = acceptable. zp_18/zp_19
; = the candidate cell,
; zp_1A/zp_1B = the signed 16-bit threat surplus, zp_1C = the radius of the unit being weighed.
; Self-modifies the CMP operands at $E704, $E70B, $E715 and $E71C.
; Called from: findTrainerAdvanceCell ($E444) only.
; ----------------------------------------------------------------------
isGroupDestinationTooRisky:
jsr offsetPointInDirection ; E69C candidate cell = origin (cellColB,cellRowB) moved Y cells in direction X
L_E69F:
bcc L_E6A2 ; E69F the candidate is on the map, so weigh it
rts ; E6A1 the step ran off the edge: return C=1 = reject
L_E6A2:
ldy D_8955 ; E6A2 Y = the group being moved
cpy D_8954 ; E6A5 is it one of the aiGroupCount live groups?
bcc L_E6AC ; E6A8 yes: measure the threat over its formation footprint
clc ; E6AA no such group, so there is nothing to be afraid of
rts ; E6AB return C=0 = destination acceptable
; ----------------------------------------------------------------------
; Grow the candidate cell into the rectangle the whole formation would stand on, clamp it to the 40x40
; map, and write its four edges straight into the four CMP instructions of the unit scan below.
; ----------------------------------------------------------------------
L_E6AC:
lda scratch18 ; E6AC candidate column
sec ; E6AE prepare the subtraction
sbc D_890E,y ; E6AF minus the group's span to the left = left edge of the formation box
bpl L_E6B6 ; E6B2 still on the map
lda #$00 ; E6B4 hangs off the left edge: clamp the box to column 0
L_E6B6:
sta L_E714+1 ; E6B6 self-modify: the CMP at $E714 now tests against the box's left column
lda scratch18 ; E6B9 candidate column again
clc ; E6BB prepare the addition
adc D_8926,y ; E6BC plus the group's span to the right = right edge of the box
cmp #$28 ; E6BF past the last column (40)?
bcc L_E6C5 ; E6C1 no, keep it
L_E6C3:
lda #$27 ; E6C3 yes: clamp the box to column 39
L_E6C5:
sta L_E71B+1 ; E6C5 self-modify: the CMP at $E71B now tests against the box's right column
L_E6C8:
lda scratch19 ; E6C8 candidate row
sec ; E6CA prepare the subtraction
sbc D_891A,y ; E6CB minus the group's span upwards = top edge of the box
bpl L_E6D2 ; E6CE still on the map
lda #$00 ; E6D0 hangs off the top: clamp the box to row 0
L_E6D2:
sta L_E703+1 ; E6D2 self-modify: the CMP at $E703 now tests against the box's top row
lda scratch19 ; E6D5 candidate row again
clc ; E6D7 prepare the addition
adc D_8932,y ; E6D8 plus the group's span downwards = bottom edge of the box
cmp #$28 ; E6DB past the last row (40)?
bcc L_E6E1 ; E6DD no, keep it
lda #$27 ; E6DF yes: clamp the box to row 39
L_E6E1:
sta L_E70A+1 ; E6E1 self-modify: the CMP at $E70A now tests against the box's bottom row
; ----------------------------------------------------------------------
; Walk all 100 units, unit 99 down to unit 0, and accumulate a signed 16-bit 'how dangerous is this
; square' score in zp_1A/zp_1B: enemy influence adds, friendly influence subtracts. A unit only
; counts when its influence square (centre = its cell, half-width = unitThreatRadius) overlaps the
; formation box just computed.
; ----------------------------------------------------------------------
lda #$00 ; E6E4 clear the running score
sta scratch1A ; E6E6 threat surplus, low byte
sta scratch1B ; E6E8 threat surplus, high byte
ldy #$63 ; E6EA start at unit 99
L_E6EC:
lda checksumXorConstant,y ; E6EC unitThreatRadius[unit] from the $0400 influence page (also trainerPlaybook0200:play3Col12)
beq L_E740 ; E6EF radius 0 (destroyed, spy/comcen, or a member of the moving group): the unit does not count
L_E6F1:
sta scratch1C ; E6F1 keep this unit's influence radius
cpy #$32 ; E6F3 unit 50-99 = one of the AI's own?
bcc L_E6FC ; E6F5 no, it is one of the human player's units 0-49
lda D_8955 ; E6F7 own unit: is the group being moved group 0, the AI comcen's own group?
beq L_E740 ; E6FA yes: friendly cover is not credited to the comcen group, so skip own units entirely
L_E6FC:
lda unitRowTable,y ; E6FC row of this unit
L_E6FF:
tax ; E6FF keep the row for the second test
clc ; E700 prepare the addition
adc scratch1C ; E701 row + radius = bottom edge of its influence square
L_E703:
cmp #$FF ; E703 operand patched at $E6D2 with the box's top row
bcc L_E740 ; E705 its influence stops above the box: no overlap, next unit
txa ; E707 the row again (the CMP above left C=1 for the subtraction)
sbc scratch1C ; E708 row - radius = top edge of its influence square
L_E70A:
cmp #$FF ; E70A operand patched at $E6E1 with the box's bottom row
bcs L_E740 ; E70C its influence starts below the box: no overlap
lda unitColTable,y ; E70E column of this unit
tax ; E711 keep the column for the second test
L_E712:
adc scratch1C ; E712 column + radius = right edge of its influence square (the CMP above left C=0)
L_E714:
cmp #$FF ; E714 operand patched at $E6B6 with the box's left column
bcc L_E740 ; E716 its influence stops left of the box: no overlap
txa ; E718 the column again (the CMP above left C=1 for the subtraction)
sbc scratch1C ; E719 column - radius = left edge of its influence square
L_E71B:
cmp #$FF ; E71B operand patched at $E6C5 with the box's right column
bcs L_E740 ; E71D its influence starts right of the box: no overlap
cpy #$32 ; E71F the squares overlap, so whose unit is it?
bcc addEnemyThreatWeight ; E721 human player's unit 0-49: its weight adds to the danger
lda scratch1A ; E723 one of the AI's own: subtract its weight instead (friendly cover)
sbc play4Grp12,y ; E725 unitThreatWeight[unit] from the $0464 influence page (the CPY above left C=1)
sta scratch1A ; E728 store the surplus low byte
lda scratch1B ; E72A surplus high byte
sbc #$00 ; E72C propagate the borrow
sta scratch1B ; E72E store it back
jmp L_E740 ; E730 on to the next unit
addEnemyThreatWeight:
lda scratch1A ; E733 surplus low byte
adc play4Grp12,y ; E735 add unitThreatWeight[unit] (the CPY at $E71F left C=0)
sta scratch1A ; E738 store it back
lda scratch1B ; E73A surplus high byte
sub_E73C:
adc #$00 ; E73C propagate the carry
sta scratch1B ; E73E store it back
L_E740:
dey ; E740 next unit
bpl L_E6EC ; E741 until unit 0 has been weighed
; ----------------------------------------------------------------------
; Verdict. A negative or zero surplus means the AI already dominates the square. Otherwise the enemy
; surplus is compared with this group's own combat value, and an exact draw is settled by the game
; clock and the RNG so that both machines would reach the same answer.
; ----------------------------------------------------------------------
judgeGroupThreatTotal:
lda scratch1B ; E743 sign of the 16-bit surplus
bpl L_E749 ; E745 not negative: weigh it against the group's own strength
L_E747:
clc ; E747 friendly influence outweighs the enemy there
rts ; E748 C=0 = destination acceptable
L_E749:
lda scratch1A ; E749 surplus low byte
ora scratch1B ; E74B is the whole 16-bit surplus exactly zero?
beq L_E747 ; E74D no enemy influence at all: accept
lda scratch1A ; E74F surplus low byte
sec ; E751 prepare the subtraction
L_E752:
sbc D_8956 ; E752 minus the group's own combat value, low byte
L_E755:
sta scratch1A ; E755 keep the difference
lda scratch1B ; E757 surplus high byte
sbc D_8957 ; E759 minus the group's combat value, high byte
sta scratch1B ; E75C keep it
ora scratch1A ; E75E is the difference exactly zero?
bne L_E772 ; E760 no: return the carry of the subtraction (C=1 = threat >= own strength = reject)
lda D_896E ; E762 dead heat: consult the aggression-phase clock set at setup ($EF00 area) or by retreatTrainerComcen
L_E765:
cmp gameClock ; E765 has the game reached that round? the game clock counts down, so 'phase clock >= clock' means yes
bcs L_E76D ; E768 yes, late enough in the game: toss a coin
sec ; E76A still early: play safe
L_E76B:
bcs L_E772 ; E76B always taken (the BCS above fell through with C=1): return C=1 = reject
L_E76D:
jsr nextGameRandom ; E76D step the lock-step game RNG so both machines stay in sync
cmp #$80 ; E770 C=1 for half of the random values, i.e. reject a tie 50 percent of the time
L_E772:
rts ; E772 carry tells the caller whether the destination is too dangerous
; ----------------------------------------------------------------------
; buildUnitThreatTables - rebuilds the two 100-entry influence-map tables that
; isGroupDestinationTooRisky reads. For each unit 99..0 it writes an influence radius into $0400 and a
; combat weight into $0464. Destroyed units, spies and comcens (type 3 and 4) and every AI unit
; belonging to the group that is about to be moved get radius 0 and weight 0. A grunt or rider gets
; base radius 3, a boomer 6, both plus the terrain sight bonus of the tile the unit is standing on, so
; units on high ground project further. The weight comes from unitCombatValueTable ($E2B7) =
; 2,2,3,0,0.
; In: $8955 currentAiGroup, aiUnitGroup ($8800+absolute unit index), unitTypeTable $F76C,
; unitTerrainUnderTable $FAF0,
; terrainClassTable $077E, terrainSightBonusTable $35BB, unitCombatValueTable $E2B7
; Out: $0400[0..99] = influence radius, $0464[0..99] = combat weight; zp_18 and zp_19 clobbered
; Called from: findTrainerAdvanceCell ($E42A), immediately before its search loop.
; One entry per unit, walking backwards from unit 99 to unit 0.
; ----------------------------------------------------------------------
buildUnitThreatTables:
ldy #$63 ; E773 start at unit 99
L_E775:
cpy #$32 ; E775 is this one of the AI's own units 50-99?
bcc L_E781 ; E777 no, it is a human player unit: no group test needed
lda msgGetOpponentOnPhoneAnd,y; E779 aiUnitGroup[unit] - the group table addressed by absolute index ($8800+unit = $8832+ordinal)
cmp D_8955 ; E77C does it belong to the very group we are about to move?
beq L_E786 ; E77F yes: a group must not weigh its own members
L_E781:
lda unitTypeTable,y ; E781 unit type byte, bit 7 = destroyed
bpl L_E78A ; E784 alive: work out its influence radius
L_E786:
lda #$00 ; E786 destroyed, or a member of the moving group: no influence
beq L_E7A7 ; E788 always taken (A = 0): store radius 0
L_E78A:
ldx #$03 ; E78A default influence radius 3 cells
L_E78C:
and #$07 ; E78C unit type 0-4 without the status bits
sta scratch19 ; E78E keep the type for the weight look-up
cmp #$02 ; E790 GRUNT (0) or RIDER (1)?
bcc L_E798 ; E792 yes: keep radius 3
bne L_E786 ; E794 type 3 SPY or 4 COMCEN: they project no threat at all
ldx #$06 ; E796 BOOMER (2): its guns reach twice as far, radius 6
L_E798:
stx scratch18 ; E798 stash the base radius
ldx unitTerrainUnderTable,y ; E79A terrain code of the tile hidden under the unit
lda D_077E,x ; E79D terrain class 0-8 of that tile
tax ; E7A0 class becomes the index
lda terrainSightBonusTable,x; E7A1 extra range the terrain grants: 0, 1 or 2 cells
clc ; E7A4 prepare the addition
adc scratch18 ; E7A5 radius = base radius + terrain bonus
L_E7A7:
sta checksumXorConstant,y ; E7A7 unitThreatRadius[unit] into the $0400 scratch page (also trainerPlaybook0200:play3Col12)
tax ; E7AA is the radius zero?
beq L_E7B2 ; E7AB yes: A is already 0, so the weight becomes 0 too
ldx scratch19 ; E7AD unit type
lda unitCombatValueTable,x ; E7AF combat weight: GRUNT 2, RIDER 2, BOOMER 3, SPY 0, COMCEN 0
L_E7B2:
sta play4Grp12,y ; E7B2 unitThreatWeight[unit] into the $0464 scratch page
L_E7B5:
dey ; E7B5 next unit
bpl L_E775 ; E7B6 until unit 0 has an entry
rts ; E7B8 both influence tables are current
; ----------------------------------------------------------------------
; multiplyAbyY - 8-bit multiply by repeated addition: A = A * Y modulo 256, and A = 0 when Y = 0. The
; multiplicand is copied into its own ADC operand and Y is restored from a second patched operand
; before returning, so the caller's step count survives the call.
; In: A = multiplicand, Y = count
; Out: A = A*Y; Y unchanged; X untouched, flags clobbered; self-modifies the operands at $E7CB and
; $E7D0
; Called from: offsetCoordinateClamped ($EA22) only, where A is a step of 0, +1 or $FF and Y a cell
; distance.
; ----------------------------------------------------------------------
multiplyAbyY:
sty L_E7CF+1 ; E7B9 self-modify: stash the count in the LDY at $E7CF so Y can be handed back
sta L_E7CA+1 ; E7BC self-modify: the multiplicand becomes the operand of the ADC at $E7CA
cpy #$00 ; E7BF multiply by zero?
bne L_E7C6 ; E7C1 no: run the addition loop
tya ; E7C3 Y is 0, so A = 0
beq L_E7CE ; E7C4 always taken: return zero
L_E7C6:
dey ; E7C6 one addition per unit of the count
beq L_E7CE ; E7C7 count used up: A holds the product
L_E7C9:
clc ; E7C9 clear the carry before each addition
L_E7CA:
adc #$FF ; E7CA operand patched at $E7BC: add the multiplicand once more
bne L_E7C6 ; E7CC keep adding; a running total of exactly 0 would end the loop early, which the callers' 0/+1/-1 steps never produce
L_E7CE:
pha ; E7CE hold the product across the Y restore
L_E7CF:
ldy #$FF ; E7CF operand patched at $E7B9: give the caller its count back
pla ; E7D1 product back into A
rts ; E7D2 A = A*Y
; ----------------------------------------------------------------------
; buildDirectionFanTowardTarget - finds the compass heading from the line start
; (lineCurCol,lineCurRow) to the line end (lineEndCol,lineEndRow) with getStepDirection, saves it in
; zp_19 and falls into buildDirectionFan so the eight candidate move directions are laid out around
; that heading. Nothing is built when start and target are the same cell.
; In: zp_92/zp_93 = start row/column, zp_94/zp_95 = target row/column, $8955 currentAiGroup
; Out: zp_19 = heading 0-7 ($FF and no fan when start = target); $8896-$889D = the eight candidate
; directions;
; zp_18 and the line-stepper state $8C-$97 clobbered
; Called from: the group-movement code at $E416.
; ----------------------------------------------------------------------
buildDirectionFanTowardTarget:
jsr getStepDirection ; E7D3 heading of the first Bresenham step from the group centre toward its target
sty scratch19 ; E7D6 remember the heading for buildDirectionFan
bpl buildDirectionFan ; E7D8 a real heading 0-7: lay the eight candidates out around it
rts ; E7DA Y = $FF, the group is already on its target cell: no fan to build
; dirMirrorTable - eight bytes, entry d = (8 - d) AND 7, i.e. the mirror of a heading offset about the
; north-south axis (0 stays 0, +1 becomes -1 and so on). buildDirectionFan runs the offsets of a
; 'left-handed' group through it so that half the groups sweep clockwise and half anticlockwise. The
; label in the middle of the table is a branch target of the modem-driver build at the same address,
; not a separate block.
dirMirrorTable:
.byte $00,$07,$06,$05,$04 ; E7DB ..... mirror of heading offsets 0-4: 0, -1, -2, -3, -4 expressed modulo 8
L_E7E0:
.byte $03 ; E7E0 mirror of heading offsets 5-7: +3, +2, +1
.byte $02,$01 ; E7E1 ..
; ----------------------------------------------------------------------
; buildDirectionFan - fills the eight-byte scratch buffer $8896-$889D with the move directions the
; path search should try, in order of preference: entry i = (dirFanOffsetTable[i] + heading + 8) AND
; 7. dirFanOffsetTable ($894A) is built once per game by the setup overlay's
; buildAiHeadingSearchOrder ($EEC3) as 0, +/-1, +/-1, +/-2, +/-2, +/-3, +/-3, +/-4 with a random
; left/right bias, so the search always tries straight ahead first and fans outwards. When bit 0 of
; the group's groupFlags is set every offset is mirrored through dirMirrorTable, giving that group the
; opposite handedness.
; In: zp_19 = wanted heading 0-7, $8955 currentAiGroup, $893E groupFlags, $894A dirFanOffsetTable
; Out: $8896-$889D = eight candidate directions; zp_18 = $00/$FF mirror flag; A/X/Y clobbered
; Called from: decideTrainerGroupAction ($E36E) and by falling through from
; buildDirectionFanTowardTarget ($E7D8).
; The list is consumed by the move search at $E431-$E47E, whose per-rank distance caps are in
; maxAdvanceByRankTable ($E664).
; ----------------------------------------------------------------------
buildDirectionFan:
ldx #$00 ; E7E3 assume the fan is not mirrored
ldy D_8955 ; E7E5 the group being moved
lda D_893E,y ; E7E8 its groupFlags; the low nibble is the random start delay handed out at setup
and #$01 ; E7EB bit 0 of that delay serves as the group's fixed handedness
beq L_E7F0 ; E7ED clear: fan out the normal way
dex ; E7EF set: X = $FF, mirror every offset
L_E7F0:
stx scratch18 ; E7F0 mirror flag for the loop below
ldy #$07 ; E7F2 fill the eight preference slots, 7 down to 0
L_E7F4:
lda D_894A,y ; E7F4 heading offset of preference rank Y
L_E7F7:
bit scratch18 ; E7F7 is this group mirrored? (BIT leaves A alone)
bpl L_E7FF ; E7F9 no: use the offset as it is
tax ; E7FB offset becomes the index
lda dirMirrorTable,x ; E7FC yes: swap left for right
L_E7FF:
clc ; E7FF clear the carry before adding
adc scratch19 ; E800 candidate = wanted heading + offset
clc ; E802 the addition above may have carried
adc #$08 ; E803 bias by a whole turn so a negative offset stays positive
L_E805:
and #$07 ; E805 wrap back into a compass direction 0-7
sta D_8896,y ; E807 store the candidate for preference rank Y
L_E80A:
dey ; E80A next rank
bpl L_E7F4 ; E80B until all eight ranks are filled
rts ; E80D the fan is ready for the path search
; ----------------------------------------------------------------------
; getStepDirection - sets a Bresenham walker up between (lineCurCol,lineCurRow) and
; (lineEndCol,lineEndRow), takes exactly one step, and converts the deltas that step applied ($90F5
; column, $90F4 row) back into one of the eight compass directions by searching
; dirToColDelta/dirToRowDelta from index 7 downwards. stepLine zeroes both deltas before it moves, so
; a zero-length line leaves 0,0 - which matches no entry 0-7 and comes out as Y = $FF.
; In: zp_92/zp_93 = start row/column, zp_94/zp_95 = end row/column
; Out: Y = direction 0-7, or $FF when start and end are the same cell; A = the last delta compared;
; line-stepper state $8C-$97 and $90F4/$90F5 updated
; Called from: buildDirectionFanTowardTarget ($E7D3), assessThreatAroundAiComcen ($EC35), the drone
; code ($EEC7)
; and the $EE00 drone-flight overlay ($EEB1).
; ----------------------------------------------------------------------
getStepDirection:
jsr initLineStepper ; E80E set the Bresenham walker up between the two cells
jsr stepLine ; E811 take one step; it records the deltas it applied in $90F5 (column) and $90F4 (row), zeroing them first
ldy #$07 ; E814 search the direction tables backwards from 7
L_E816:
lda dirToColDelta,y ; E816 column step of direction Y
cmp lastStepColDelta ; E819 does it match the step just taken?
bne L_E826 ; E81C no: try the next direction
lda dirToRowDelta,y ; E81E row step of direction Y
cmp lastStepRowDelta ; E821 does that match too?
beq L_E829 ; E824 both match: Y is the heading
L_E826:
dey ; E826 next direction down
bpl L_E816 ; E827 index 8 (the null delta) is deliberately never tested, so Y = $FF means 'the line has no step'
L_E829:
rts ; E829 Y = compass direction, or $FF
; ----------------------------------------------------------------------
; cellDistance - integer distance between two map cells. distanceSquared ($3870) gives |dcol|^2 +
; |drow|^2 (each delta capped at 15, the sum saturating at $FF with C=1), and this routine converts it
; back through squaresTable ($3860) into the largest n with n*n <= dsq. A saturated squared distance
; simply reports 16.
; In: zp_22/zp_24 = cell column/row, zp_27/zp_28 = reference column/row
; Out: A = X = distance 0-16; Y untouched
; Called from: $E410, $E4B4, assessThreatAroundAiComcen ($EC24), the missile code ($ED43/$EE0C/$EE5C)
; and the drone overlay.
; ----------------------------------------------------------------------
cellDistance:
jsr distanceSquared ; E82A A = squared distance between (cellColA,cellRowA) and (cellColB,cellRowB), C=1 if it saturated at 255
ldx #$10 ; E82D 16 is the largest distance this routine reports
bcs L_E83A ; E82F saturated: everything beyond 15 cells is simply 'far'
dex ; E831 otherwise start the search at 15
L_E832:
cmp squaresTable,x ; E832 is the squared distance at least X*X?
bcs L_E83A ; E835 yes: X is the integer distance
dex ; E837 try the next smaller radius
bne L_E832 ; E838 X = 0 falls out of the loop as distance 0
L_E83A:
txa ; E83A hand the distance back in A as well as X
rts ; E83B A = X = distance in cells
; ----------------------------------------------------------------------
; updateFocusGroupCentre - keeps focusGroupId ($895D) and the focus centre ($895E column / $895F row)
; current. The focus group is the assault group the AI comcen walks along behind. When the id has
; bit 7 set the centre falls back on the AI comcen's own cell, and a suspended group is taken back
; into service as soon as bit 7 of its groupFlags has cleared. An active group whose groupFlags bit 7
; is set (it has reached its objective) is suspended by ORing $80 into the id. Otherwise the centroid
; of the group's combat members is recomputed, and a group that has lost every member is dropped
; ($895D = $FF).
; In: $895D focusGroupId, $893E groupFlags, unitCol[99] $F6A3, unitRow[99] $F707, $8832 aiUnitGroup
; Out: $895D, $895E = centre column, $895F = centre row; zp_18-zp_21 clobbered
; Called from: planTrainerRound ($E20C), once per round just before the comcen push/retreat tail.
; ----------------------------------------------------------------------
updateFocusGroupCentre:
ldy D_895D ; E83C focusGroupId: $FF = no group, bit 7 set = the group is suspended
bpl L_E860 ; E83F an active group id 0-15: go and refresh its centre
lda side1ComcenCol ; E841 no usable group: fall back on the AI comcen's own cell (unit 99)
L_E844:
sta msgOpponentUsingNonStandardGame; E844 focus centre column ($895E)
lda side1ComcenRow ; E847 AI comcen row
sta D_895F ; E84A focus centre row
cpy #$FF ; E84D was the focus group dropped altogether?
beq L_E85F ; E84F yes: nothing left to revive
tya ; E851 suspended group: recover its id
and #$0F ; E852 strip the suspend bit
tay ; E854 index the group tables with it
lda D_893E,y ; E855 its groupFlags
L_E858:
bmi L_E85F ; E858 bit 7 still set (the group is at its objective): leave it suspended
sty D_895D ; E85A the flag cleared: put the group back in service
bpl L_E860 ; E85D always taken (an id 0-15 is positive): recompute its centre
L_E85F:
rts ; E85F nothing to update this round
L_E860:
lda D_893E,y ; E860 groupFlags of the active focus group
bpl L_E86C ; E863 still on its way: recompute the centre
tya ; E865 bit 7 set: the group has arrived
ora #$80 ; E866 mark the id as suspended
sta D_895D ; E868 store it back; the centre keeps the comcen fallback from the next call
rts ; E86B done for this round
L_E86C:
lda D_895D ; E86C group id for the centroid scan
ldy #$2D ; E86F scan AI ordinals 45 down to 0 - the combat units only, since 46-48 are the spies and 49 the comcen
jsr computeGroupCentroid ; E871 A = member count, X = average column, Y = average row
bne L_E87B ; E874 the group still has members
lda #$FF ; E876 no member left
sta D_895D ; E878 drop the focus group; X/Y are 0/$FF here, so the centre is stale for the one round until the fallback runs
L_E87B:
stx msgOpponentUsingNonStandardGame; E87B focus centre column
sty D_895F ; E87E focus centre row
rts ; E881 the comcen now has something to escort
; ----------------------------------------------------------------------
; computeGroupCentroid - averages the position and the ordered destination of every living member of
; one AI group. It scans AI ordinals Y down to 0 (absolute units 50+Y down to 50), accumulates four
; 16-bit sums and divides each by the member count with divideSigned16by8. The wanted group id is
; patched into its own CMP, and the two averaged coordinates are handed back through two patched
; immediate operands so that A can carry the member count.
; In: A = group id, Y = highest AI ordinal to scan (usually $31 = 49 or $2D = 45); $8832 aiUnitGroup,
; $F79E unitType,
; $F672 unitCol, $F6D6 unitRow, $F802 unitDestCol, $F866 unitDestRow (all indexed by AI ordinal)
; Out: A = member count (Z set when the group is empty), X = average column, Y = average row, zp_1E =
; average destination column,
; zp_1F = average destination row, zp_1C = member count; zp_18-zp_21 clobbered;
; self-modifies the operands at $E89D, $E913 and $E915
; Called from: computeTrainerGroupStats ($E5B9) and updateFocusGroupCentre ($E871).
; ----------------------------------------------------------------------
computeGroupCentroid:
sta L_E89C+1 ; E882 self-modify: the wanted group id becomes the operand of the CMP at $E89C
lda #$00 ; E885 clear all nine accumulator bytes
sta scratch18 ; E887 column sum, low
sta scratch19 ; E889 column sum, high
sta scratch1A ; E88B row sum, low
sta scratch1B ; E88D row sum, high
sta scratch1C ; E88F member count
sta scratch1E ; E891 ordered-destination column sum, low
L_E893:
sta scratch1F ; E893 ordered-destination column sum, high
sta scratch20 ; E895 ordered-destination row sum, low
sta scratch21 ; E897 ordered-destination row sum, high
; ----------------------------------------------------------------------
; One pass per AI ordinal, from the caller's starting ordinal down to 0.
; ----------------------------------------------------------------------
L_E899:
lda D_8832,y ; E899 aiUnitGroup[ordinal] - which group this AI unit belongs to
L_E89C:
cmp #$FF ; E89C operand patched at $E882: is it the group we are averaging?
L_E89E:
bne L_E8DF ; E89E no: next ordinal
lda side1UnitTypeTable,y ; E8A0 type byte of AI unit 50+ordinal
bmi L_E8DF ; E8A3 bit 7 set: destroyed, so it does not count
inc scratch1C ; E8A5 one more live member
lda side1UnitColTable,y ; E8A7 its current column
clc ; E8AA clear the carry
adc scratch18 ; E8AB into the 16-bit column sum
sta scratch18 ; E8AD store the low byte
lda scratch19 ; E8AF column sum, high byte
adc #$00 ; E8B1 propagate the carry
sta scratch19 ; E8B3 store it back
lda side1UnitRowTable,y ; E8B5 its current row
clc ; E8B8 clear the carry
adc scratch1A ; E8B9 into the 16-bit row sum
sta scratch1A ; E8BB store the low byte
lda scratch1B ; E8BD row sum, high byte
adc #$00 ; E8BF propagate the carry
sta scratch1B ; E8C1 store it back
lda side1UnitDestColTable,y ; E8C3 the column it has been ordered to
and #$3F ; E8C6 the coordinate is in bits 0-5; bits 6-7 are order flags
clc ; E8C8 clear the carry
adc scratch1E ; E8C9 into the destination-column sum
sta scratch1E ; E8CB store the low byte
bcc L_E8D1 ; E8CD no carry
inc scratch1F ; E8CF carry into the high byte
L_E8D1:
lda side1UnitDestRowTable,y ; E8D1 the row it has been ordered to
and #$3F ; E8D4 coordinate bits only
clc ; E8D6 clear the carry
adc scratch20 ; E8D7 into the destination-row sum
L_E8D9:
sta scratch20 ; E8D9 store the low byte
bcc L_E8DF ; E8DB no carry
inc scratch21 ; E8DD carry into the high byte
L_E8DF:
dey ; E8DF next ordinal
bpl L_E899 ; E8E0 down to AI ordinal 0
; ----------------------------------------------------------------------
; Divide the four sums by the member count. The two positional averages are returned in X and Y
; through the two immediate operands patched below.
; ----------------------------------------------------------------------
ldx scratch1C ; E8E2 member count is the divisor
beq L_E916 ; E8E4 no members: skip the divisions and report zero
lda scratch18 ; E8E6 column sum, low
ldy scratch19 ; E8E8 column sum, high
jsr divideSigned16by8 ; E8EA average column
sta L_E912+1 ; E8ED self-modify: it becomes the operand of the LDX at $E912
lda scratch1A ; E8F0 row sum, low
L_E8F2:
ldy scratch1B ; E8F2 row sum, high
ldx scratch1C ; E8F4 divisor again (divideSigned16by8 clobbers X)
jsr divideSigned16by8 ; E8F6 average row
sta L_E914+1 ; E8F9 self-modify: it becomes the operand of the LDY at $E914
ldx scratch1C ; E8FC divisor again
lda scratch1E ; E8FE destination-column sum, low
ldy scratch1F ; E900 destination-column sum, high
jsr divideSigned16by8 ; E902 average ordered destination column
sta scratch1E ; E905 returned in zp_1E
ldx scratch1C ; E907 divisor again
sub_E909:
lda scratch20 ; E909 destination-row sum, low
ldy scratch21 ; E90B destination-row sum, high
jsr divideSigned16by8 ; E90D average ordered destination row
sta scratch1F ; E910 returned in zp_1F, overwriting the sum byte that has just been consumed
L_E912:
ldx #$FF ; E912 operand patched at $E8ED: X = average column
L_E914:
ldy #$FF ; E914 operand patched at $E8F9: Y = average row
L_E916:
lda scratch1C ; E916 A = member count, Z set when the group is empty
rts ; E918 caller gets count in A, centre in X/Y, average destination in zp_1E/zp_1F
; ----------------------------------------------------------------------
; orderAiUnitToCell - the trainer's central 'issue a move order' helper. It does nothing when the
; unit's ordered destination already is the requested cell, so redundant orders never take up room in
; the queue or in the packet. Otherwise it falls into queueMirroredMoveOrder, which mirrors both
; coordinates (the engine will mirror them back because it believes the command arrived from a remote
; opponent) and queues $80 MOVE UNIT or $81 MOVE GROUP.
; In: X = target column, A = target row, Y = AI ordinal 0-49 (absolute unit = Y+50)
; Out: nothing when the order is redundant; otherwise $8896 = $80/$81, $8897 = absolute unit index,
; $8898/$8899 = mirrored column/row, and the command appended to the AI queue at $88A0
; Called from: retreatTrainerComcen ($E262), pushTrainerComcenForward ($E294), issueTrainerGroupOrders
; ($E4F7)
; and haltAiComcen ($EBEC).
; ----------------------------------------------------------------------
orderAiUnitToCell:
stx D_8898 ; E919 stage the target column as the command's first coordinate
sta D_8899 ; E91C and the target row as the second
lda side1UnitDestColTable,y ; E91F the column AI unit 50+Y has already been ordered to
and #$3F ; E922 coordinate bits only; bits 6-7 are order flags
L_E924:
cmp D_8898 ; E924 same column as the new order?
bne L_E934 ; E927 no: the order is worth sending
lda side1UnitDestRowTable,y ; E929 the row it has already been ordered to
and #$3F ; E92C coordinate bits only
cmp D_8899 ; E92E same row too?
bne L_E934 ; E931 no: send the order
rts ; E933 the unit is already under exactly this order: queue nothing
L_E934:
lda D_8898 ; E934 target column, ready for the mirror below
; ----------------------------------------------------------------------
; queueMirroredMoveOrder - second half of orderAiUnitToCell. Mirrors the staged coordinates with
; mirrorMapCoordinate (39 - v), picks command $80 cmdMoveUnit or $81 cmdMoveGroup according to bit 6
; of the unit's flags, converts the AI ordinal into the absolute unit index and tail-jumps into
; queueAiCommand. Both players see their own side at the bottom of the map, so an incoming command's
; coordinates are always mirrored by the engine; the trainer pre-mirrors so the round trip comes out
; right.
; In: $8898/$8899 = target column/row, Y = AI ordinal 0-49, $F73A unitFlags indexed by ordinal
; Out: the four bytes $8896-$8899 staged and queued
; Called from: reached only by falling through from orderAiUnitToCell ($E934); the XREF entries
; $E917/$EA01 belong to the
; modem-driver build that occupies the same addresses.
; ----------------------------------------------------------------------
queueMirroredMoveOrder:
jsr mirrorMapCoordinate ; E937 39 - column: the engine mirrors it back when it 'receives' the packet
sta D_8898 ; E93A store the mirrored column
lda D_8899 ; E93D target row
jsr mirrorMapCoordinate ; E940 39 - row
sta D_8899 ; E943 store the mirrored row
ldx #$80 ; E946 command $80 = cmdMoveUnit
lda side1UnitFlagsTable,y ; E948 unitFlags of AI unit 50+Y
and #$40 ; E94B bit 6 = the unit is a member of an in-game group
beq L_E950 ; E94D not grouped: keep $80
inx ; E94F grouped: use $81 = cmdMoveGroup so its formation follows
L_E950:
stx D_8896 ; E950 command byte
tya ; E953 AI ordinal 0-49
clc ; E954 clear the carry
adc #$32 ; E955 plus 50: the AI plays side 1, so its units are 50-99
sta msgPickUpPhoneThenPressSpace; E957 first parameter of the command ($8897)
L_E95A:
jmp queueAiCommand ; E95A append the staged command to the AI order queue
; ----------------------------------------------------------------------
; isPathToPlayerComcenClear - walks a Bresenham line from the current line position (which the caller
; has set to the group centre) to the human player's comcen (unit 49) and reports whether any living
; enemy fighting unit stands on it. Only human grunts, riders and boomers (types 0-2) block; the AI's
; own units, destroyed units, spies and the comcen do not. Both exits put the group centre back into
; the line position through loadGroupCentreIntoLinePos.
; In: zp_92/zp_93 = start row/column, $8955 currentAiGroup, $8954 aiGroupCount, $F671/$F6D5 player
; comcen cell, map at $F000
; Out: C=1 = the lane to the comcen is open, C=0 = blocked or the group id was invalid; zp_92/zp_93 =
; the group centre;
; zp_48/zp_49 (map pointer) and the line-stepper state $8C-$97 clobbered
; Called from: decideTrainerGroupAction ($E3B1), to decide whether the group may make a straight run
; at the comcen.
; ----------------------------------------------------------------------
isPathToPlayerComcenClear:
ldy D_8955 ; E95D the group asking for a straight run
L_E960:
cpy D_8954 ; E960 is it one of the live groups?
bcs L_E995 ; E963 no: report 'blocked' and do nothing else
lda side0ComcenCol ; E965 column of the human player's comcen (unit 49)
sta lineEndCol ; E968 line target column
lda side0ComcenRow ; E96A its row
sub_E96D:
sta lineEndRow ; E96D line target row
jsr initLineStepper ; E96F set the walker up from the current position to the comcen
; ----------------------------------------------------------------------
; Walk the lane one cell at a time, stopping at the first live enemy fighting unit.
; ----------------------------------------------------------------------
L_E972:
jsr stepLine ; E972 advance one cell along the lane
sub_E975:
beq loadGroupCentreIntoLinePos; E975 the line ran out without a blocker: restore the centre and return C=1
ldx lineCurCol ; E977 column the walker is standing on
ldy lineCurRow ; E979 and its row
L_E97B:
jsr readMapCell ; E97B map byte of that cell; bit 7 set means a unit occupies it
bpl L_E972 ; E97E bare terrain: keep walking
and #$7F ; E980 strip the occupancy bit to get the unit index
L_E982:
cmp #$32 ; E982 one of the AI's own units 50-99?
bcs L_E972 ; E984 yes: a unit never blocks its own side's lane
L_E986:
tay ; E986 human player's unit: index it
lda unitTypeTable,y ; E987 its type byte
bmi L_E972 ; E98A destroyed: not an obstacle
and #$07 ; E98C unit type 0-4
cmp #$03 ; E98E type 3 SPY or 4 COMCEN?
bcs L_E972 ; E990 yes: neither blocks a charge
jsr loadGroupCentreIntoLinePos; E992 a live grunt, rider or boomer sits in the lane: put the group centre back
L_E995:
clc ; E995 C=0 = the lane is blocked (also the exit for an invalid group id)
rts ; E996 carry tells the caller whether it may charge
; ----------------------------------------------------------------------
; loadGroupCentreIntoLinePos - copies the current group's centre (groupCentreCol $88EA /
; groupCentreRow $88F6) into the line walker's position and returns with C=1. It doubles as the 'path
; is clear' exit of isPathToPlayerComcenClear, which is why it ends with SEC.
; In: $8955 currentAiGroup, $88EA/$88F6 per-group centre tables
; Out: zp_93 = centre column, zp_92 = centre row, C=1; A and Y clobbered
; Called from: isPathToPlayerComcenClear ($E992) and as its C=1 exit ($E975).
; ----------------------------------------------------------------------
loadGroupCentreIntoLinePos:
ldy D_8955 ; E997 the current group
lda D_88EA,y ; E99A its centre column
sta lineCurCol ; E99D line walker column
lda D_88F6,y ; E99F its centre row
L_E9A2:
sta lineCurRow ; E9A2 line walker row
sec ; E9A4 C=1: this is also the 'lane is clear' answer of isPathToPlayerComcenClear
rts ; E9A5 line position restored
; ----------------------------------------------------------------------
; scoreTargetBlock - scores the 2x2 block of map cells whose top-left corner is (X,Y) as a bombardment
; target. A missile and a drone both cover 2x2 cells, so all four are added up: every unit standing
; in them contributes targetValueByUnitType, positive for the human player's units and negative for
; the AI's own. A block with a negative total is never chosen; otherwise, when the total beats the
; running best in zp_1C, the corner is remembered in zp_95/zp_94 and the highest human unit index
; found in the block becomes the nominal target in zp_1D (which favours the comcen, unit 49).
; In: X = column, Y = row of the top-left cell; zp_1C = best score so far (may be negative to accept
; anything),
; zp_1D = best target so far; map at $F000, unitTypeTable $F76C
; Out: zp_1A = the block's score, zp_1B = highest human unit index in it; on an improvement zp_1C,
; zp_1D, zp_94 and zp_95 are
; updated; zp_48/zp_49 = map pointer; self-modifies the operands at $E9E4 and $E9E8
; Called from: the missile/drone target scan at $EFA8, and from the $EE00 drone-flight overlay.
; ----------------------------------------------------------------------
scoreTargetBlock:
stx L_E9E3+1 ; E9A6 self-modify: keep the block's column in the LDA at $E9E3
sty L_E9E7+1 ; E9A9 self-modify: keep the block's row in the LDA at $E9E7
lda #$00 ; E9AC clear the accumulators
sta scratch1A ; E9AE score of this 2x2 block
sta scratch1B ; E9B0 highest human unit index seen in it
jsr readMapCell ; E9B2 top-left cell; readMapCell also leaves mapCellPtr pointing at it
bpl L_E9BA ; E9B5 bare terrain
jsr addUnitTargetValue ; E9B7 a unit stands here: score it
; ----------------------------------------------------------------------
; The other three cells of the block are reached straight through mapCellPtr: +1 is the cell to the
; right, +40 the cell below (the map is 40 cells wide) and +41 the diagonal.
; ----------------------------------------------------------------------
L_E9BA:
ldy #$01 ; E9BA offset +1 = the cell to the right
lda (mapCellPtr),y ; E9BC read it
bpl L_E9C3 ; E9BE bare terrain
jsr addUnitTargetValue ; E9C0 score the unit standing there
L_E9C3:
ldy #$28 ; E9C3 offset +40 = one map row down
lda (mapCellPtr),y ; E9C5 read it
bpl L_E9CC ; E9C7 bare terrain
jsr addUnitTargetValue ; E9C9 score it
L_E9CC:
ldy #$29 ; E9CC offset +41 = down and to the right
lda (mapCellPtr),y ; E9CE read it
bpl L_E9D5 ; E9D0 bare terrain
jsr addUnitTargetValue ; E9D2 score it
L_E9D5:
lda scratch1A ; E9D5 total value of the block
bmi L_E9F0 ; E9D7 negative: the AI's own units dominate it, so never bombard it
L_E9D9:
cmp scratch1C ; E9D9 better than the best block found so far?
bcs L_E9E1 ; E9DB yes: take it
bit scratch1C ; E9DD no, but is the running best itself still negative (nothing chosen yet)?
bpl L_E9F0 ; E9DF no: keep the previous best
L_E9E1:
sta scratch1C ; E9E1 new best score
L_E9E3:
lda #$FF ; E9E3 operand patched at $E9A6: the block's column
sta lineEndCol ; E9E5 remember it as the aim point
L_E9E7:
lda #$FF ; E9E7 operand patched at $E9A9: the block's row
L_E9E9:
sta lineEndRow ; E9E9 aim point row
lda scratch1B ; E9EB the biggest human unit index in the block
sta scratch1D ; E9ED becomes the nominal target unit (49 = the comcen wins any tie)
rts ; E9EF a new best target has been recorded
L_E9F0:
rts ; E9F0 rejected: the running best is left alone
; targetValueByUnitType - five bytes indexed by unit type, the bombardment value of a unit standing in
; a candidate target block. Boomers and the comcen are worth four times a grunt.
targetValueByUnitType:
.byte $01,$02,$04,$01,$04 ; E9F1 ..... GRUNT 1, RIDER 2, BOOMER 4, SPY 1, COMCEN 4
; ----------------------------------------------------------------------
; addUnitTargetValue - helper of scoreTargetBlock. Takes a map cell byte that holds $80 plus a unit
; index, ignores destroyed units, and adds targetValueByUnitType[type] to the block score in zp_1A -
; negated when the unit belongs to the AI itself, so the trainer will not shell its own troops. For a
; human unit it also keeps the largest index seen, which is how the human comcen (unit 49) ends up
; being preferred as the nominal target.
; In: A = map cell byte with bit 7 set, unitTypeTable $F76C, targetValueByUnitType $E9F1, zp_1A/zp_1B
; accumulators
; Out: zp_1A += the (possibly negated) value, zp_1B = highest human unit index seen; X and Y clobbered
; Called from: scoreTargetBlock, once per cell of the 2x2 block ($E9B7, $E9C0, $E9C9, $E9D2).
; ----------------------------------------------------------------------
addUnitTargetValue:
and #$7F ; E9F6 drop the 'a unit is here' bit: what is left is the unit index
tax ; E9F8 index the unit tables with it
lda unitTypeTable,x ; E9F9 its type byte
bmi L_EA1A ; E9FC destroyed: worth nothing
and #$07 ; E9FE unit type 0-4
tay ; EA00 index the value table
lda targetValueByUnitType,y ; EA01 bombardment value of that type
cpx #$32 ; EA04 unit 50-99 = one of the AI's own?
bcs L_EA10 ; EA06 yes: its value counts against the block
cpx scratch1B ; EA08 human unit: is it the highest index seen in this block so far?
bcc L_EA15 ; EA0A no: just add its value
stx scratch1B ; EA0C yes: it becomes the block's nominal target
bcs L_EA15 ; EA0E always taken (the BCS above fell through with C=1)
L_EA10:
eor #$FF ; EA10 negate the value ...
clc ; EA12 clear the carry ...
adc #$01 ; EA13 ... two's complement, so friendly units subtract
L_EA15:
clc ; EA15 clear the carry before accumulating
adc scratch1A ; EA16 add into the block score
sta scratch1A ; EA18 store it back
L_EA1A:
rts ; EA1A cell scored
; ----------------------------------------------------------------------
; offsetCoordinateClamped - returns clamp(A + X*Y, 0..39): the base coordinate A displaced by Y steps
; of the signed step X, held inside the 40-cell battlefield, with the carry reporting whether the
; unclamped value had left the map. The base coordinate and the caller's step count are both parked
; in immediate operands so nothing but A is disturbed.
; In: A = base coordinate, X = signed step (0, +1 or $FF), Y = number of steps
; Out: A = clamped coordinate 0-39, C=1 when the raw value was negative or past 39, Y unchanged;
; self-modifies the operands at $EA27 and $EA36
; Called from: pushTrainerComcenForward/retreatTrainerComcen ($E24C/$E25B/$E27E/$E28D), the path
; search ($E48A/$E499)
; and offsetPointInDirection ($E675/$E684).
; ----------------------------------------------------------------------
offsetCoordinateClamped:
sty L_EA35+1 ; EA1B self-modify: save the step count in the LDY at $EA35 so Y survives the multiply
sta L_EA26+1 ; EA1E self-modify: the base coordinate becomes the operand of the ADC at $EA26
txa ; EA21 A = the signed step, 0, +1 or $FF
jsr multiplyAbyY ; EA22 A = step * number of steps
clc ; EA25 clear the carry
L_EA26:
adc #$FF ; EA26 operand patched at $EA1E: add the base coordinate
tay ; EA28 keep the raw, unclamped result for the range test
L_EA29:
bpl L_EA2D ; EA29 positive: no low clamp needed
lda #$00 ; EA2B walked off the top or left edge: clamp to 0
L_EA2D:
cmp #$28 ; EA2D past the last cell (40)?
bcc L_EA33 ; EA2F no, keep it
lda #$27 ; EA31 yes: clamp to 39
L_EA33:
cpy #$28 ; EA33 C=1 when the raw value was 40 or more, which also covers a negative value (>= $80)
L_EA35:
ldy #$FF ; EA35 operand patched at $EA1B: hand the caller its step count back
rts ; EA37 A = clamped coordinate, C=1 if it had to be clamped
; ----------------------------------------------------------------------
; mirrorBlockCornerForOpponent - converts the top-left corner of a 2x2 object (the drone) into the
; coordinates that the engine's mirrorCmdCoordsIfRemoteDec ($5005, mirror then decrement) will turn
; back into that same corner: X = 39-(X+1), Y = 39-(Y+1). Mirroring alone would give the bottom-right
; corner of the mirrored block, hence the pre-increment.
; In: X = column, Y = row of the block's top-left cell
; Out: X, Y = the mirrored corner; A clobbered
; Called from: the fake drone-position packet at $EEB1 and $EF8F, and from the drone-flight overlay.
; ----------------------------------------------------------------------
mirrorBlockCornerForOpponent:
inx ; EA38 the engine subtracts 1 after mirroring, so add it here first
txa ; EA39 column+1 into A
jsr mirrorMapCoordinate ; EA3A 39 - (column+1)
L_EA3D:
tax ; EA3D mirrored column back into X
iny ; EA3E same for the row
tya ; EA3F row+1 into A
jsr mirrorMapCoordinate ; EA40 39 - (row+1)
tay ; EA43 mirrored row back into Y
rts ; EA44 corner is now expressed the way a remote opponent would have sent it
; aiCmdQueuePeak - a single byte of data sitting in the code stream between
; mirrorBlockCornerForOpponent and queueAiCommand: the high-water mark of the AI's order queue. It is
; written and compared only inside queueAiCommand and read nowhere else, so it is a development
; leftover.
aiCmdQueuePeak:
.byte $00 ; EA45 . longest the AI order queue has ever grown this game; nothing ever reads it back
; ----------------------------------------------------------------------
; queueAiCommand - appends the command staged in $8896..$8899 to the AI's 50-byte order queue at
; $88A0. It looks the total command length up with getCommandLength, keeps a high-water mark in
; aiCmdQueuePeak, and when the queue would reach 50 bytes it drops the command and turns the border
; blue instead, as a visible warning that the AI is producing orders faster than the lock-step packets
; can carry them away.
; In: $8896-$8899 = the staged command, $8952 aiCmdQueueLength, commandLengthTable through
; getCommandLength ($4D9F)
; Out: queue at $88A0 extended, $8952 += length, $8968 incremented, $92EE = length (consumed as the
; copy counter),
; $EA45 peak updated; on overflow $9003 (the border-colour shadow) = 6 and the command is thrown
; away
; Called from: queueMirroredMoveOrder ($E95A) and queueComcenCommand ($EBE0); the trainer setup
; overlay has its own
; call at its own $EC82.
; ----------------------------------------------------------------------
queueAiCommand:
inc D_8968 ; EA46 count one more command issued this round
lda D_8896 ; EA49 the staged command byte
jsr getCommandLength ; EA4C currentCommandLength ($92EE) = 1 command byte plus its parameters
clc ; EA4F clear the carry
adc D_8952 ; EA50 how long the queue would be afterwards
cmp aiCmdQueuePeak ; EA53 beyond the previous high-water mark?
bcc L_EA5B ; EA56 no
sta aiCmdQueuePeak ; EA58 record the new peak
L_EA5B:
cmp #$32 ; EA5B the queue at $88A0 holds 50 bytes
bcc L_EA65 ; EA5D it still fits
lda #$06 ; EA5F colour 6 = blue
sta vicBorderShadow ; EA61 overflow: recolour the border through the VIC shadow and drop the command
rts ; EA64 command lost (the round counter at $8968 was already bumped)
L_EA65:
ldx #$00 ; EA65 copy from the first staging byte
ldy D_8952 ; EA67 append at the current end of the queue
L_EA6A:
lda D_8896,x ; EA6A next staged byte
sta D_88A0,y ; EA6D into the queue
inx ; EA70 advance the staging index
iny ; EA71 advance the queue index
inc D_8952 ; EA72 the queue is one byte longer
dec currentCommandLength ; EA75 one byte of this command done
bne L_EA6A ; EA78 until the whole command has been copied
rts ; EA7A the order is now waiting to be handed to the engine
; ----------------------------------------------------------------------
; injectAiCommandsIntoPacket - the heart of the solo trainer. Where the modem build would decode a
; packet that arrived over the wire, this build fabricates one: it copies whole commands out of the
; AI's own queue at $88A0 straight into the comm module's received-packet buffer ($E020, length
; $E01E), so the lock-step engine executes the computer opponent's orders through exactly the same
; command interpreter a human opponent's orders would use. It first runs the missile/drone decision,
; which may claim the whole packet for itself, and then moves as many complete commands as fit while
; the packet stays under 8 bytes, shifting the remainder of the queue down after each one.
; In: $8952 aiCmdQueueLength, the queue at $88A0, $E01E current packet length, getCommandLength
; ($4D9F)
; Out: $E020.. filled, $E01E updated, the queue compacted and $8952 reduced; $8966 cleared then
; possibly set by runAiMissileTurn;
; $92EE clobbered
; Called from: buildTrainerReplyPacket ($E0E4), once per lock-step exchange.
; ----------------------------------------------------------------------
injectAiCommandsIntoPacket:
lda #$00 ; EA7B clear the flag first
sta D_8966 ; EA7D $8966 bit 7 will mean 'the missile/drone code owns this packet'
jsr runAiMissileTurn ; EA80 offer the AI a missile shot or a drone launch; it writes straight into the packet
lda D_8966 ; EA83 did it claim the packet?
bmi L_EACE ; EA86 yes: leave the order queue for the next exchange
lda D_8952 ; EA88 is anything queued at all?
beq L_EACE ; EA8B no: send an empty packet
; ----------------------------------------------------------------------
; One iteration per queued command: measure it, check that it still fits in the packet, copy it into
; the packet, then slide the rest of the queue down over it.
; ----------------------------------------------------------------------
L_EA8D:
ldy #$00 ; EA8D read from the front of the queue
lda D_88A0,y ; EA8F its command byte
jsr getCommandLength ; EA92 currentCommandLength = its total length
lda rxPacketLength ; EA95 bytes already in the fabricated 'received' packet
clc ; EA98 clear the carry
adc currentCommandLength ; EA99 plus this command
cmp #$08 ; EA9C a packet carries at most 7 command bytes
bcs L_EACE ; EA9E it would not fit: the rest waits for the next exchange
D_EAA0:
ldx rxPacketLength ; EAA0 append at the current end of the packet buffer
L_EAA3:
lda D_88A0,y ; EAA3 next byte of the command
sta rxPacketBuffer,x ; EAA6 into the received-packet buffer at $E020
inx ; EAA9 advance the packet index
iny ; EAAA advance the queue index
cpy currentCommandLength ; EAAB whole command copied?
bcc L_EAA3 ; EAAE no: keep copying
stx rxPacketLength ; EAB0 new packet length
ldx #$00 ; EAB3 now compact the queue from its start
L_EAB5:
lda D_88A0,y ; EAB5 byte just past the command that was taken
D_EAB8:
sta D_88A0,x ; EAB8 slide it down to the front
D_EABB:
inx ; EABB advance the write index
D_EABC:
iny ; EABC advance the read index
D_EABD:
cpy D_8952 ; EABD until the old end of the queue
D_EAC0:
bcc L_EAB5 ; EAC0 keep shifting
D_EAC2:
lda D_8952 ; EAC2 old queue length
D_EAC5:
sec ; EAC5 prepare the subtraction
D_EAC6:
sbc currentCommandLength ; EAC6 minus the command just handed over
sta D_8952 ; EAC9 new queue length
bne L_EA8D ; EACC more orders waiting: try to fit another into the same packet
L_EACE:
rts ; EACE the packet the engine is about to 'receive' is ready
; ----------------------------------------------------------------------
; updateAiUnitTracking - per-unit hook, run from trainerUnitStepHook after the engine has stepped each
; unit. It maintains three pieces of AI awareness. For a human unit it latches the first
; grunt/rider/boomer that has picked the AI comcen as its fire target into comcenAttackerUnit ($8972).
; For one of its own units it marks that unit's group as having the human comcen in reach (groupFlags
; bit 6) and stamps the unit for an immediate replan, once the comcen has been spotted and the unit is
; within 8 cells of it. Finally, an AI spy (units 96-98) that has reached the opponent's end zone
; while still inside an ordinary group is split off into a brand new group of its own, seeded with its
; current destination, so it can operate alone.
; In: $90FA currentUnit, $F76C unitTypeTable, $91F0 fireTargetUnit, $F9F5 human comcen energy byte
; (bit 7 = spotted),
; $F671/$F6D5 human comcen cell, $F640/$F6A4 unit cells, aiUnitOrderRound ($87CE+unit),
; aiUnitGroup ($8800+unit),
; aiUnitDeepFlag ($8832+unit), $91CB game clock, $8954 aiGroupCount, $8964 home group id
; Out: $8972 comcenAttackerUnit, groupFlags $893E bit 6, $8953 last allocated group id, the unit's
; group and round stamp,
; and the new group's target cell $88D2/$88DE
; Called from: trainerUnitStepHook ($E124), for every unit the engine steps.
; ----------------------------------------------------------------------
updateAiUnitTracking:
ldy currentUnit ; EACF the unit the engine has just stepped
cpy #$32 ; EAD2 unit 0-49 or 50-99?
bcs L_EAF1 ; EAD4 one of the AI's own: run the contact tracking
ldx D_8972 ; EAD6 comcenAttackerUnit, $FF while nobody is known to be shooting at the AI comcen
bpl L_EAEE ; EAD9 already latched: nothing to learn
lda fireTargetUnit ; EADB the unit this human unit has picked as its fire target
cmp #$63 ; EADE unit 99 = the AI comcen
bne L_EAEE ; EAE0 aiming at something else
lda unitTypeTable,y ; EAE2 its type byte
and #$07 ; EAE5 unit type 0-4
cmp #$03 ; EAE7 type 3 SPY or 4 COMCEN?
bcs L_EAEE ; EAE9 yes: they cannot shoot, so they are not a threat to record
D_EAEB:
sty D_8972 ; EAEB remember the first unit that is aiming at the AI comcen
L_EAEE:
jmp L_EB66 ; EAEE human unit handled
; ----------------------------------------------------------------------
; AI unit: has it got close enough to the human comcen to count as contact? The two coordinate tests
; accept a difference in -8..+7 by comparing the signed byte against $F8 and then against 8.
; ----------------------------------------------------------------------
L_EAF1:
lda side0ComcenEnergy ; EAF1 energy byte of the human comcen; bit 7 doubles as 'the AI has spotted it'
bpl L_EB2C ; EAF4 not spotted yet: there is no contact to record
lda side0ComcenCol ; EAF6 column of the human comcen
sec ; EAF9 prepare the subtraction
sbc unitColTable,y ; EAFA minus this AI unit's column
cmp #$F8 ; EAFD is the difference at least -8?
D_EAFF:
bcs L_EB05 ; EAFF yes, within 8 columns to one side
D_EB01:
cmp #$08 ; EB01 is it less than +8?
sub_EB03:
bcs L_EB2C ; EB03 no: more than 8 columns away
L_EB05:
lda side0ComcenRow ; EB05 row of the human comcen
sec ; EB08 prepare the subtraction
sbc unitRowTable,y ; EB09 minus this AI unit's row
cmp #$F8 ; EB0C is the difference at least -8?
bcs L_EB14 ; EB0E yes
cmp #$08 ; EB10 is it less than +8?
bcs L_EB2C ; EB12 no: more than 8 rows away
L_EB14:
ldx msgGetOpponentOnPhoneAnd,y; EB14 aiUnitGroup[unit], the group table addressed by absolute index
cpx D_8964 ; EB17 is it the home/guard group?
beq L_EB66 ; EB1A yes: that group is not supposed to chase the comcen
lda D_893E,x ; EB1C its groupFlags
ora #$40 ; EB1F bit 6 = this group has the human comcen within reach
sta D_893E,x ; EB21 store the flags back
bmi L_EB2C ; EB24 bit 7 set (the group is already suspended): do not restamp its clock
lda gameClock ; EB26 the down-counting round clock
sta msgWeWonByAKnockout,y ; EB29 aiUnitOrderRound[unit]: stamping the current clock makes the planner replan this unit at once (also mapGenerator6F00:msgWeWonByAKnockout)
; ----------------------------------------------------------------------
; An AI spy (units 96-98) that has scored both end zones is promoted out of its group into a group of
; its own so it can act independently behind the human lines.
; ----------------------------------------------------------------------
L_EB2C:
cpy #$63 ; EB2C the AI comcen itself (unit 99)?
bcs L_EB66 ; EB2E yes: nothing else to do for it
cpy #$60 ; EB30 units 96-98 are the AI's three SPYs
bcc L_EB66 ; EB32 any other unit: done
lda D_8832,y ; EB34 aiUnitDeepFlag[unit], bit 7 = the unit has scored both territory zones
bpl L_EB66 ; EB37 this spy has not got that far yet
lda msgGetOpponentOnPhoneAnd,y; EB39 its current group id
cmp D_8954 ; EB3C does it still belong to one of the ordinary groups?
bcs L_EB66 ; EB3F no (already split off, or $FF): leave it alone
cmp D_8964 ; EB41 is it in the home/guard group?
beq L_EB66 ; EB44 yes: that one stays together
inc D_8953 ; EB46 allocate the next group id; note that aiGroupCount $8954 is not bumped with it, and that ids past 11 would run off the end of the twelve-entry per-group tables
lda D_8953 ; EB49 the new id
tax ; EB4C index the per-group tables with it
sta msgGetOpponentOnPhoneAnd,y; EB4D the spy becomes a group of its own
lda gameClock ; EB50 the current round
sta msgWeWonByAKnockout,y ; EB53 stamp it so the planner picks the new group up on the next scan (also mapGenerator6F00:msgWeWonByAKnockout)
lda unitDestColTable,y ; EB56 the destination the spy is already heading for
and #$3F ; EB59 coordinate bits only
sta D_88D2,x ; EB5B becomes the new group's target column
lda unitDestRowTable,y ; EB5E and its destination row
and #$3F ; EB61 coordinate bits only
sta D_88DE,x ; EB63 becomes the new group's target row
L_EB66:
rts ; EB66 unit tracked
; ----------------------------------------------------------------------
; runAiComcenActions - spends whatever is left of the AI comcen's three command slots for this round.
; A stunned comcen needs two of them: one to halt itself and one for the 1-in-8 repair attempt that
; queues command $9E, exactly the odds the human REPAIR tab uses. An unstunned comcen is halted once
; and then spends every remaining slot on maybeQueueComcenEnergyRepair.
; In: $8968 commands already issued this round, $8971 the comcen's stun counter (low nibble of
; unitStunTable[99])
; Out: $8967 = the comcen's remaining slot budget, commands queued through queueAiCommand; returns
; silently when there is
; no budget left
; Called from: planTrainerRound at $E1CD (comcen too weak to attack) and $E21B (defend mode).
; ----------------------------------------------------------------------
runAiComcenActions:
lda #$03 ; EB67 the comcen may spend at most 3 command slots in a round
sec ; EB69 prepare the subtraction
sbc D_8968 ; EB6A minus the orders the planner has already queued
bcc L_EBE3 ; EB6D already over budget: do nothing at all
sta D_8967 ; EB6F slots left for the comcen
tax ; EB72 keep the budget in X as well
lda D_8971 ; EB73 the comcen's stun counter
beq L_EB96 ; EB76 not stunned: go straight to the energy repairs
cpx #$02 ; EB78 a stun repair attempt costs two slots
bcc L_EBE3 ; EB7A not enough left
dex ; EB7C spend one ...
dex ; EB7D ... and the second
stx D_8967 ; EB7E the reduced budget
jsr haltAiComcen ; EB81 halt the comcen; a moving comcen cannot be worked on
jsr nextGameRandom ; EB84 step the lock-step RNG
and #$07 ; EB87 one chance in eight
bne L_EBE3 ; EB89 the repair attempt failed this round
lda #$9E ; EB8B command $9E = cmdComcenRepairedOrSetupReady, which clears the comcen stun counter on both machines
jsr queueComcenCommand ; EB8D queue it ($8897/$8898 are ignored, the command is one byte long)
inc D_8967 ; EB90 cancel the DEC that the shared loop tail below is about to do
jmp L_EB9F ; EB93 join the energy-repair loop with the remaining budget
L_EB96:
txa ; EB96 slots left
beq L_EBE3 ; EB97 none: nothing to do
jsr haltAiComcen ; EB99 halt the comcen so it can repair
L_EB9C:
jsr maybeQueueComcenEnergyRepair; EB9C spend one slot on an energy repair
L_EB9F:
dec D_8967 ; EB9F one slot used
bne L_EB9C ; EBA2 keep spending until the budget is exhausted
rts ; EBA4 the comcen has done what it can this round
; ----------------------------------------------------------------------
; maybeQueueComcenEnergyRepair - the trainer's version of the human console's energy repair. With 50
; percent probability, and only while the AI comcen's energy is below 45 of 50, it queues command $9A
; cmdAddEnergy for unit 99 with the same amount the human repair uses, (50 - energy) / 8 + 1. The
; projected energy is accumulated in $8965 (which planTrainerRound seeded from the comcen's real
; energy and uses as its action budget), and the amount is nudged by one whenever that projection
; would land on a multiple of four.
; In: $FA27 = unitEnergyTable[99] (energy in bits 0-5), $8965 projected comcen energy, nextGameRandom
; Out: $8896 = $9A, $8897 = $63 (unit 99), $8898 = the amount, command queued; $8965 raised by the
; same amount
; Called from: runAiComcenActions ($EB9C), once per remaining comcen command slot.
; ----------------------------------------------------------------------
maybeQueueComcenEnergyRepair:
jsr nextGameRandom ; EBA5 step the lock-step RNG
and #$01 ; EBA8 an energy repair succeeds one time in two, as on the human console
beq L_EBE3 ; EBAA failed this time: spend the slot on nothing
lda side1ComcenEnergy ; EBAC energy byte of AI comcen unit 99
and #$3F ; EBAF energy 0-50 lives in bits 0-5; bits 6-7 are visibility flags
cmp #$2D ; EBB1 45 out of 50 or better?
bcs L_EBE3 ; EBB3 healthy enough: do not waste a command on it
sta scratch18 ; EBB5 keep the current energy
lda #$32 ; EBB7 50 = a full comcen
sec ; EBB9 prepare the subtraction
sbc scratch18 ; EBBA the shortfall
lsr a ; EBBC divide by 8 ...
lsr a ; EBBD ...
lsr a ; EBBE ... so repairs get smaller as the comcen recovers
clc ; EBBF clear the carry
adc #$01 ; EBC0 amount = shortfall/8 + 1
sta D_8898 ; EBC2 second parameter of the command
clc ; EBC5 clear the carry
adc D_8965 ; EBC6 add it to the energy the comcen is projected to end up with
sta D_8965 ; EBC9 keep the projection
and #$03 ; EBCC did the projection land on a multiple of four?
bne L_EBD6 ; EBCE no: the amount stands
inc D_8898 ; EBD0 yes: repair one point more so it does not
inc D_8965 ; EBD3 and keep the projection in step with it
L_EBD6:
lda #$63 ; EBD6 unit 99 = the AI comcen
sta msgPickUpPhoneThenPressSpace; EBD8 first parameter of the command ($8897)
lda #$9A ; EBDB command $9A = cmdAddEnergy (unit, amount); falls straight into queueComcenCommand
; ----------------------------------------------------------------------
; queueComcenCommand - shared tail of the comcen actions: stores A as the command byte and jumps into
; queueAiCommand, leaving
; $8897 and $8898 as the caller staged them.
; In: A = command byte, $8897/$8898 = parameters already staged
; Out: the command appended to the AI order queue
; Called from: runAiComcenActions ($EB8D) and by falling through from maybeQueueComcenEnergyRepair
; ($EBDB).
; ----------------------------------------------------------------------
queueComcenCommand:
sta D_8896 ; EBDD command byte into the staging area
jmp queueAiCommand ; EBE0 append the staged bytes to the queue
L_EBE3:
rts ; EBE3 shared 'do nothing this slot' exit of the comcen action code
; ----------------------------------------------------------------------
; haltAiComcen - orders the AI comcen to move to the cell it is already standing on.
; orderAiUnitToCell turns that into a real
; $80/$81 command only when the comcen currently has some other destination, so this stops a moving
; comcen and costs nothing when
; it is already still. A comcen has to be stationary before it can repair or launch.
; In: $F6A3 unitCol[99], $F707 unitRow[99]
; Out: possibly one $80/$81 command queued; nothing at all when the comcen is already halted
; Called from: runAiComcenActions ($EB81 stunned path, $EB99 repair path).
; ----------------------------------------------------------------------
haltAiComcen:
ldx side1ComcenCol ; EBE4 the column the AI comcen is standing on
lda side1ComcenRow ; EBE7 and its row
ldy #$31 ; EBEA AI ordinal 49 = absolute unit 99, the AI comcen
jmp orderAiUnitToCell ; EBEC ordering it to its own cell halts it; a redundant order is dropped
; ----------------------------------------------------------------------
; assessThreatAroundAiComcen - builds a directional pressure map around the AI comcen and picks the
; direction to run. Every living human combat unit (ordinals 0-45: the 28 grunts, 12 riders and 6
; boomers) within 20 cells adds a weight into one of eight compass buckets at $8896-$889D: 1 for
; merely being nearby, +1 within 10 cells, and +4 more when the comcen is inside that unit's weapon
; range (5 cells, 9 for a boomer). The base 1 is dropped once the comcen has actually been hit
; ($897C), so only units that can reach it steer the escape. The tail rankThreatDirections ($EC82,
; just past this chunk) smooths the ring and returns the centre of the quietest three-direction arc.
; In: zp_27/zp_28 = AI comcen column/row (set by the caller at $E1D1),
; unitTypeTable/unitColTable/unitRowTable,
; $897C = 'the comcen was hit this round or last', zp_57 game RNG low byte for the tie break
; Out: A = (total pressure - number of threats) / 2 = overall threat level, X = deepest weapon-range
; surplus (0 = nothing in
; range), Y = safest compass direction 0-7, or 8 (the null delta entry) when nothing threatens;
; $8896-$889D and $0400-$0407 hold the direction buckets, zp_18-zp_23 clobbered
; Called from: planTrainerRound ($E1DB), which files the results into $8962/$8960/$8961, and the drone
; code ($EF7B).
; ----------------------------------------------------------------------
assessThreatAroundAiComcen:
lda #$00 ; EBEF clear the accumulators
sta scratch1F ; EBF1 total enemy pressure
sta scratch20 ; EBF3 number of units that count as a threat
sta scratch21 ; EBF5 deepest weapon-range surplus seen
sta scratch1E ; EBF7 cleared but never read again in either half of the routine: a leftover
ldy #$07 ; EBF9 clear the eight compass buckets, 7 down to 0
L_EBFB:
sta D_8896,y ; EBFB bucket for direction Y
D_EBFE:
dey ; EBFE next direction
bpl L_EBFB ; EBFF until all eight are clear
; ----------------------------------------------------------------------
; One pass per human combat unit, ordinal 45 down to 0 (the roster is 28 grunts, 12 riders, 6 boomers,
; 3 spies and the comcen, so 0-45 is exactly the units that can shoot).
; ----------------------------------------------------------------------
lda #$2D ; EC01 scan the human player's units 45 down to 0
sta scratch18 ; EC03 loop counter
L_EC05:
ldx scratch18 ; EC05 the unit being weighed
lda unitTypeTable,x ; EC07 its type byte
D_EC0A:
bmi L_EC74 ; EC0A destroyed: skip it
ldy #$05 ; EC0C weapon range 5 cells for a grunt or rider
and #$02 ; EC0E bit 1 of the type marks a BOOMER (type 2); a SPY would match too, but the scan never reaches one
beq L_EC14 ; EC10 not a boomer: keep range 5
L_EC12:
ldy #$09 ; EC12 a boomer reaches 9 cells
L_EC14:
sty scratch19 ; EC14 weapon range of this unit
lda unitColTable,x ; EC16 its column
sta cellColA ; EC19 point A of the distance test
sta lineEndCol ; EC1B and the target cell of the direction test
lda unitRowTable,x ; EC1D its row
sta cellRowA ; EC20 point A row
sta lineEndRow ; EC22 target row
jsr cellDistance ; EC24 distance from the AI comcen, which the caller left in cellColB/cellRowB
cmp #$14 ; EC27 20 cells
bcs L_EC74 ; EC29 too far away to matter
sub_EC2B:
sta scratch1A ; EC2B keep the distance
lda cellColB ; EC2D the comcen's own cell ...
sta lineCurCol ; EC2F ... is where the direction is measured from
lda cellRowB ; EC31 comcen row
sta lineCurRow ; EC33 line walker row
jsr getStepDirection ; EC35 Y = compass direction from the comcen toward this unit
bpl L_EC3C ; EC38 a real direction 0-7
ldy #$00 ; EC3A the unit is standing on the comcen's own cell: charge it to direction 0
; ----------------------------------------------------------------------
; Weigh this unit: a base 1 for being nearby (suppressed once the comcen is actually under fire), +1
; within 10 cells and +4 more when the comcen sits inside its weapon range.
; ----------------------------------------------------------------------
L_EC3C:
inc scratch20 ; EC3C one more threatening unit
ldx #$00 ; EC3E the weight this unit will contribute
bit D_897C ; EC40 was the comcen hit in this round or the previous one?
bmi L_EC46 ; EC43 yes: only units that can actually reach it get any weight
inx ; EC45 no: every nearby enemy is worth 1
L_EC46:
lda scratch1A ; EC46 the distance again
cmp #$0A ; EC48 closer than 10 cells?
bcs L_EC5E ; EC4A no: it only counts as a distant presence
inx ; EC4C +1 for being close
lda scratch19 ; EC4D its weapon range
sec ; EC4F prepare the subtraction
sbc scratch1A ; EC50 range minus distance: how deep the comcen sits inside its envelope
bcc L_EC5E ; EC52 the comcen is out of range of this unit
sub_EC54:
inx ; EC54 +1 ...
inx ; EC55 +2 ...
inx ; EC56 +3 ...
inx ; EC57 +4 more: the comcen is inside this unit's weapon range
sub_EC58:
cmp scratch21 ; EC58 is this the deepest penetration so far?
L_EC5A:
bcc L_EC5E ; EC5A no
sta scratch21 ; EC5C remember the worst range surplus, returned in X
; ----------------------------------------------------------------------
; Add the weight to its compass bucket and to the running total, both saturating at 255.
; ----------------------------------------------------------------------
L_EC5E:
txa ; EC5E the weight of this unit
clc ; EC5F clear the carry
adc D_8896,y ; EC60 into the bucket of its compass direction
bcc L_EC67 ; EC63 no overflow
lda #$FF ; EC65 saturate the bucket at 255
L_EC67:
sta D_8896,y ; EC67 store the bucket back
txa ; EC6A the weight again
clc ; EC6B clear the carry
L_EC6C:
adc scratch1F ; EC6C into the overall pressure total
bcc L_EC72 ; EC6E no overflow
lda #$FF ; EC70 saturate the total at 255
L_EC72:
sta scratch1F ; EC72 store it back
L_EC74:
dec scratch18 ; EC74 next human unit
bpl L_EC05 ; EC76 until unit 0 has been weighed
; ----------------------------------------------------------------------
; Hand the ring over to rankThreatDirections, or - when nothing at all threatened the comcen - report
; the null direction 8, whose entry in dirToColDelta/dirToRowDelta is the zero step.
; ----------------------------------------------------------------------
lda scratch20 ; EC78 did anything threaten the comcen?
bne rankThreatDirections ; EC7A yes: smooth the ring and pick the quietest arc
tax ; EC7C no threats: A = X = 0
ldy #$08 ; EC7D direction 8 = the null entry of the direction tables, i.e. 'no need to move'
jmp threatScanDone ; EC7F share the RTS at the end of rankThreatDirections
; ----------------------------------------------------------------------
; rankThreatDirections - Tail of assessThreatAroundAiComcen ($EBEF): the scan loop above has just
; turned every one of the human player's combat units 0-45 into a saturating 'pressure' weight in one
; of the eight compass buckets $8896-$889D. This half blurs that ring circularly, finds the centre of
; the quietest three-direction arc and returns the summary the planner stores in $8960-$8962.
; In: $8896-$889D = raw per-direction pressure, zp_1F = total pressure, zp_20 = number of units that
; contributed, zp_21 = deepest weapon-range penetration, zp_57 = low byte of the lock-step game
; RNG state (tie break)
; Out: A = (zp_1F - zp_20) / 2 = overall threat level, X = zp_21, Y = zp_18 = the quietest compass
; direction 0-7; $8896-$889D holds the blurred ring and $0400-$0407 the raw copy; zp_18, zp_19,
; zp_1A and zp_23 clobbered
; Called from: branch at $EC7A only (the tail of $EBEF); when no unit threatened at all the path at
; $EC7C returns A=0, X=0, Y=8 instead - index 8 is the deliberate null entry (0,0) of
; dirToColDelta/dirToRowDelta
; Take an unsmoothed copy of the eight direction buckets into the $0400 scratch page, because the blur
; below overwrites $8896-$889D while it still needs each direction's original neighbours.
;
; A warning for readers of the rest of this chunk: $EC00-$EFFF is shared by four different overlay
; images (this trainer tail, the modem driver, the track 29 trainer setup and the track 29 drone AI),
; and XREF merges their cross references. Many of the L_/D_/sub_ labels below are branch targets of
; one of the other builds that happen to land on an instruction boundary here; in this file they sit
; in the middle of a routine and nothing reaches them.
; ----------------------------------------------------------------------
rankThreatDirections:
ldy #$07 ; EC82 start at direction 7 (north-west) and walk the ring backwards
copyThreatRingLoop:
lda D_8896,y ; EC84 raw pressure of direction Y from the 8-byte ring at $8896
sta checksumXorConstant,y ; EC87 keep the untouched copy in the $0400 scratch page (the same page finishRound uses for scan lists) (also trainerPlaybook0200:play3Col12)
dey ; EC8A next direction down
bpl copyThreatRingLoop ; EC8B until all 8 directions have been copied
lda #$07 ; EC8D the blur pass also starts at direction 7
sta scratch23 ; EC8F zp_23 = the direction currently being smoothed
; ----------------------------------------------------------------------
; smoothThreatRing - Circular 1-2-1 blur of the eight-direction threat ring. For i = 7 down to 0 it
; computes (2*raw[i] + raw[i-1] + raw[i+1]) / 4, reading the untouched copy from $0400 and writing the
; result back over $8896+i. Indices wrap modulo 8, so north's neighbours are north-west and
; north-east.
; In: $0400-$0407 = raw direction weights, zp_23 = direction index counter (7 on entry)
; Out: $8896-$889D = blurred weights; zp_19 = the sum's high bits; zp_23 ends at $FF; A/X/Y clobbered
; Called from: nothing - this is the loop head of rankThreatDirections, branched to from the DEC/BPL
; at $ECCA. (XREF shows a JSR from $7AE9, but that call belongs to the other overlay that shares
; these addresses.)
; ----------------------------------------------------------------------
smoothThreatRing:
lda scratch23 ; EC91 i = the direction being smoothed
L_EC93:
tax ; EC93 X indexes the centre sample
tay ; EC94 Y will index the neighbour on the counter-clockwise side ...
dey ; EC95 ... i - 1
bpl blurSumStart ; EC96 no wrap needed while i-1 is still 0..7
ldy #$07 ; EC98 i was 0, so its counter-clockwise neighbour is direction 7
blurSumStart:
lda #$00 ; EC9A clear the two extra bits the weighted sum can produce
L_EC9C:
sta scratch19 ; EC9C zp_19 = bits 8-9 of the running sum
lda checksumXorConstant,x ; EC9E raw pressure of the centre direction (also trainerPlaybook0200:play3Col12)
asl a ; ECA1 double it - the centre direction carries weight 2
bcc addCounterClockwiseNeighbour; ECA2 no carry out of the doubling
inc scratch19 ; ECA4 carry the 9th bit into zp_19
addCounterClockwiseNeighbour:
clc ; ECA6 clear carry before adding a neighbour
adc checksumXorConstant,y ; ECA7 + the counter-clockwise neighbour, weight 1 (also trainerPlaybook0200:play3Col12)
bcc stepToClockwiseNeighbour; ECAA no carry out of the add
inc scratch19 ; ECAC carry into the high bits again
stepToClockwiseNeighbour:
inx ; ECAE step X to the clockwise neighbour, i + 1
cpx #$08 ; ECAF past the last direction?
bcc addClockwiseNeighbour ; ECB1 no, i+1 is still inside the ring
ldx #$00 ; ECB3 wrap i+1 round to direction 0
addClockwiseNeighbour:
clc ; ECB5 clear carry before the last add
adc checksumXorConstant,x ; ECB6 + the clockwise neighbour, weight 1 (also trainerPlaybook0200:play3Col12)
bcc finishBlurredValue ; ECB9 no carry
inc scratch19 ; ECBB carry into the high bits
finishBlurredValue:
lsr scratch19 ; ECBD divide the 10-bit sum by 4: high bits down ...
ror a ; ECBF ... into A (first halving)
lsr scratch19 ; ECC0 and again ...
ror a ; ECC2 ... A = (2*raw[i] + raw[i-1] + raw[i+1]) / 4
ldy scratch23 ; ECC3 Y = i again
sta D_8896,y ; ECC5 write the blurred pressure back into the live ring at $8896+i
dec scratch23 ; ECC8 next direction down
bpl smoothThreatRing ; ECCA repeat for directions 7..0
; ----------------------------------------------------------------------
; Now look for the calmest 135-degree arc: three adjacent directions whose blurred pressures add up to
; the smallest total. The centre of that arc is the direction the comcen will run towards.
; ----------------------------------------------------------------------
lda #$FF ; ECCC $FF = no arc chosen yet, and as an arc sum 255 is worse than anything real
sta scratch18 ; ECCE zp_18 = best (quietest) centre direction so far
sta scratch19 ; ECD0 zp_19 = the best arc sum so far
ldy #$07 ; ECD2 test the eight arcs starting with the one led by direction 7
; ----------------------------------------------------------------------
; findQuietestDirectionArc - For every leading direction y = 7..0 it adds the blurred pressures of y,
; y-1 and y-2 (wrapping round the ring) and keeps the smallest total, remembering the arc's middle
; direction y-1. The result is the centre of the calmest 135-degree wedge around the reference point.
; Exact ties are decided by bit 7 of the game RNG state, so the trainer does not always break them the
; same way.
; In: $8896-$889D = blurred direction weights, zp_57 = game RNG state byte 0, zp_18/zp_19 preset to
; $FF
; Out: zp_18 = quietest centre direction 0-7, zp_19 = its arc sum; zp_1A = scratch for the candidate
; centre; A/X/Y clobbered
; Called from: nothing - loop head of rankThreatDirections, branched to from the DEY/BPL at $ECFE.
; (The XREF calls from $EC74/$EC7E belong to the modem-driver build that shares this address.)
; ----------------------------------------------------------------------
findQuietestDirectionArc:
tya ; ECD4 A = the arc's leading direction
tax ; ECD5 X indexes the first of the three samples
lda D_8896,x ; ECD6 blurred pressure of direction y
dex ; ECD9 step to y - 1, the middle of the arc
bpl addArcMiddleDirection ; ECDA no wrap needed
ldx #$07 ; ECDC y was 0, so the middle direction is 7
addArcMiddleDirection:
stx scratch1A ; ECDE remember the middle direction - that is what the caller receives
clc ; ECE0 clear carry before the add
adc D_8896,x ; ECE1 + pressure of the middle direction
dex ; ECE4 step to y - 2, the trailing direction
bpl addArcTrailingDirection ; ECE5 no wrap needed
L_ECE7:
ldx #$07 ; ECE7 wrap y-2 round to direction 7
addArcTrailingDirection:
clc ; ECE9 clear carry before the last add
adc D_8896,x ; ECEA + pressure of the trailing direction; A = the arc total (8-bit, and it can wrap if the whole ring is saturated at $FF)
L_ECED:
cmp scratch19 ; ECED compare with the quietest arc found so far
bcc rememberQuietestArc ; ECEF strictly quieter - take this arc
bne nextArcCandidate ; ECF1 louder - keep the previous best
L_ECF3:
bit gameRngState0 ; ECF3 a tie: toss a coin on bit 7 of the game RNG state. zp_57 is not stepped here, so every tie inside one call falls the same way
bmi nextArcCandidate ; ECF5 coin says keep the arc that was found first
rememberQuietestArc:
sta scratch19 ; ECF7 remember the new best arc sum ...
ldx scratch1A ; ECF9 ... and its middle direction ...
stx scratch18 ; ECFB ... as the escape direction to return
nextArcCandidate:
dey ; ECFD next leading direction
bpl findQuietestDirectionArc; ECFE eight arcs in all
; ----------------------------------------------------------------------
; Return the summary: A = threat level, X = deepest weapon-range penetration, Y = escape direction.
; Subtracting the unit count removes the +1 base weight each threatening unit contributed, so what is
; left is the 'extra' pressure from being close or inside somebody's weapon range.
; ----------------------------------------------------------------------
lda scratch1F ; ED00 total pressure accumulated over all threatening units
sec ; ED02 no borrow
sbc scratch20 ; ED03 minus one per threatening unit: strip the base weight each one added
lsr a ; ED05 halve it - A is the threat level the planner keeps in $8962
ldy scratch18 ; ED06 Y = the quietest direction; callers add 4 mod 8 to get the direction the enemy is in
ldx scratch21 ; ED08 X = the largest weapon-range surplus seen, 0 = nothing has this point in range ($8961)
threatScanDone:
rts ; ED0A shared exit; the 'nothing threatens at all' path at $EC7F jumps here with A=0, X=0, Y=8
; ----------------------------------------------------------------------
; runAiMissileTurn - The computer opponent's one 'weapon' decision per lock-step exchange. It first
; offers to launch the AI's own drone (launchAiDrone), then, if the human is steering a drone of his
; own and the AI still has missiles and is reloaded, it decides whether to shoot that drone down: the
; aim point is the drone's cell, a reluctance score is built from the range to the AI comcen, the
; comcen's stun state and energy and a missile-conservation rule, and the shot either kills the drone
; (command $92 plus the $90 burst) or lands scattered around it. Whatever happens it falls through
; into fireMissileAtSpottedUnit.
; In: $8966 action budget for this exchange (0 = free), $920D drone state flags, $92B1 AI missiles
; left, $92AE human drones left, $8970 reload countdown, $92F6/$92F7 drone camera window origin,
; $F6A3/$F707 AI comcen cell, $F9C3 comcen stun, $FA27 comcen energy, $920B/$896F drone heading,
; game RNG
; Out: may put command $92 and/or $90 col,row into the fabricated 'received' packet ($E020, length
; $E01E); $8970 reload timer, $8966 spent, $8968 += 2;
; zp_22/zp_23/zp_24/zp_27/zp_28/zp_29/zp_2B/zp_18 clobbered
; Called from: injectAiCommandsIntoPacket ($EA80), which itself runs once per exchange from the $E000
; jump-table entry with X=1
; The AI is allowed exactly one drone launch or missile shot per exchange: injectAiCommandsIntoPacket
; clears $8966 to 0 before calling, and each action decrements it to $FF.
; ----------------------------------------------------------------------
runAiMissileTurn:
jsr launchAiDrone ; ED0B first chance of the exchange: send a drone up if one is due
lda D_8966 ; ED0E $8966 = this exchange's action budget
bmi skipAntiDroneShot ; ED11 the drone launch already used it - go straight to the ground-target path
lda side1ComcenCol ; ED13 column of the AI's own comcen (unit 99)
sta cellColB ; ED16 zp_27/zp_28 = point B of every cellDistance below: the comcen is what the AI defends
L_ED18:
lda side1ComcenRow ; ED18 row of the AI comcen
sta cellRowB ; ED1B zp_28 = reference row
bit droneControlFlags ; ED1D $920D bit 6 = the human is steering his own drone right now
bvs considerAntiDroneShot ; ED20 he is - think about shooting it down
skipAntiDroneShot:
jmp fireMissileAtSpottedUnit; ED22 no drone to shoot at, so look for an ordinary target
considerAntiDroneShot:
lda opponentMissilesLeft ; ED25 $92B1 = missiles the computer opponent has left
beq skipAntiDroneShot ; ED28 out of missiles
lda D_8970 ; ED2A $8970 = reload countdown; $FF (negative) means ready to fire
L_ED2D:
bpl skipAntiDroneShot ; ED2D still reloading
; ----------------------------------------------------------------------
; Aim at the human's drone. $92F6/$92F7 are the top-left corner of his 14x12 drone camera window and
; the drone always sits in the middle of it, hence the +6/+5.
; ----------------------------------------------------------------------
lda #$00 ; ED2F 0 = 'the comcen is undamaged' until proved otherwise
L_ED31:
sta scratch2B ; ED31 zp_2B = comcen-damaged flag
lda droneViewOriginCol ; ED33 left edge of the drone camera window
clc ; ED36 clear carry for the add
adc #$06 ; ED37 the drone sits 6 columns into the window
sta cellColA ; ED39 zp_22 = aim column (point A)
lda droneViewOriginRow ; ED3B top edge of the drone camera window
clc ; ED3E clear carry
adc #$05 ; ED3F the drone sits 5 rows into the window
sta cellRowA ; ED41 zp_24 = aim row
L_ED43:
jsr cellDistance ; ED43 A = range in cells (0-16) from the AI comcen to the drone
sta scratch23 ; ED46 zp_23 = reluctance to fire, starting at that range
lsr a ; ED48 half the range ...
sta scanListIndex ; ED49 ... is what the reload timer will be set to if the shot goes off
lda side1ComcenStun ; ED4B stun byte of the AI comcen (unit 99)
and #$0F ; ED4E bits 0-3 = the stun counter
beq checkComcenEnergy ; ED50 not stunned - check the energy instead
dec scratch2B ; ED52 zp_2B = $FF: remember that the comcen is stunned - it costs another point of reluctance at $ED69
L_ED54:
cmp #$0E ; ED54 stun counter 14 or 15 = the comcen is effectively out of action
bcs skipAntiDroneShot ; ED56 too badly stunned to shoot at a drone
bcc bumpReluctance ; ED58 always taken (the CMP above set carry only for >= $0E): skip the energy test
checkComcenEnergy:
lda side1ComcenEnergy ; ED5A energy byte of the AI comcen
and #$03 ; ED5D low two bits of the 0-50 energy value
bne applyStunPenalty ; ED5F not a multiple of 4 - no extra hesitation
bumpReluctance:
inc scratch23 ; ED61 energy divisible by 4: +1 reluctance, a cheap energy-driven hesitation
applyStunPenalty:
lda scratch23 ; ED63 A = the reluctance BEFORE the stun bump below
bit scratch2B ; ED65 was the comcen stunned? (zp_2B = $FF if so)
bpl testCloseShot ; ED67 no
inc scratch23 ; ED69 stunned: +1 reluctance. A keeps the old value, so the test at $ED6B still uses the unbumped score
testCloseShot:
cmp #$03 ; ED6B reluctance under 3 - a close shot from a healthy comcen: fire without the conservation test
bcc fireAntiDroneMissile ; ED6D take the shot
testReluctanceLimit:
lda scratch23 ; ED6F reload the (possibly bumped) reluctance
cmp #$05 ; ED71 5 or more = far away and/or a hurt comcen ...
bcs skipAntiDroneShot ; ED73 ... not worth a missile
lda dronesLeft ; ED75 $92AE = drones the human still has in stock
L_ED78:
asl a ; ED78 x2 ...
asl a ; ED79 ... x4: four missiles are reserved for every future drone
sta scratch18 ; ED7A zp_18 = the reserve
lda opponentMissilesLeft ; ED7C missiles the AI owns
sec ; ED7F no borrow wanted
sbc scratch18 ; ED80 missiles above the reserve
bcs clampMissileSurplus ; ED82 positive surplus
lda #$00 ; ED84 below the reserve - treat the surplus as 0
clampMissileSurplus:
clc ; ED86 clear carry
adc #$01 ; ED87 surplus + 1
cmp scratch23 ; ED89 compare with the reluctance
bcc fireMissileAtSpottedUnit; ED8B too few spare missiles for a shot this reluctant - save it for later
; ----------------------------------------------------------------------
; The shot goes off. Whether it actually hits is decided by one random byte against a threshold that
; grows with the reluctance and jumps by $40 when the drone has changed heading since the last shot,
; so a jinking drone is much harder to hit.
; ----------------------------------------------------------------------
fireAntiDroneMissile:
lda scanListIndex ; ED8D half the range, computed at $ED48
sta D_8970 ; ED8F start the reload countdown
inc scratch23 ; ED92 reluctance + 1 so that even a perfect shot can miss
lda scratch23 ; ED94 A = reluctance + 1
asl a ; ED96 x2 ...
asl a ; ED97 x4 ...
asl a ; ED98 x8 ...
asl a ; ED99 x16 ...
asl a ; ED9A x32: the miss threshold is (reluctance+1) * 32
ldx droneHeading ; ED9B $920B = the heading of the drone right now
cpx D_896F ; ED9E $896F = the heading it had at the last shot
beq storeMissThreshold ; EDA1 unchanged - the drone is flying straight
clc ; EDA3 clear carry
adc #$40 ; EDA4 it jinked: +$40 makes a hit far less likely
storeMissThreshold:
sta scratch18 ; EDA6 zp_18 = miss threshold
jsr nextGameRandom ; EDA8 step the lock-step game RNG (both machines would get the same byte)
cmp scratch18 ; EDAB random byte against the threshold
bcs droneShotDownCleanly ; EDAD random >= threshold: a clean hit on the drone
lsr scratch23 ; EDAF a miss: scatter radius = reluctance / 2 ...
inc scratch23 ; EDB1 ... + 1, so it is at least one cell off
lda scratch23 ; EDB3 A = scatter radius
jsr randomlySignValue ; EDB5 randomly plus or minus
clc ; EDB8 clear carry
adc cellColA ; EDB9 shift the aim column
sta cellColA ; EDBB zp_22 = the missed column
lda scratch23 ; EDBD A = scatter radius again
L_EDBF:
jsr randomlySignValue ; EDBF randomly plus or minus
clc ; EDC2 clear carry
adc cellRowA ; EDC3 shift the aim row
sta cellRowA ; EDC5 zp_24 = the missed row
jmp queueScatteredStrike ; EDC7 queue the strike at the scattered cell
droneShotDownCleanly:
lda #$92 ; EDCA command $92 = DRONE SHOT DOWN (handler cmdDroneShotDown $538B, clears $920D bits 6-7)
sta rxPacketBuffer ; EDCC it is the first byte of the packet - $E01E is still 0 whenever this path is reached
inc rxPacketLength ; EDCF packet is now 1 byte long
queueScatteredStrike:
jsr queueMissileStrikeCommand; EDD2 append the $90 strike so the human also sees the missile burst on the drone's cell
; ----------------------------------------------------------------------
; fireMissileAtSpottedUnit - Second half of the missile turn, reached whenever no anti-drone shot was
; taken. It fires a comcen missile at the unit stored in $8972 (the last human unit seen shooting at
; the AI comcen) or, when there is none or it is dead, at the human comcen itself while that comcen is
; visible. The target must sit on the AI comcen's 21x17 radar grid; the reload timer is then set to
; half the range and the $90 strike is queued. The tail remembers the human drone's heading for the
; jink test and ticks the reload timer down.
; In: $920D bit 6, $92AE human drones left, $92B1 AI missiles, $8970 reload countdown, $8966 action
; budget, $8972 current missile target, $F76C unit type/dead bits, $F9F5 human comcen
; energy/visibility, $920B drone heading, zp_27/zp_28 = AI comcen cell
; Out: $8972 ($FF when the target was dropped), $8970, $896F, and via queueMissileStrikeCommand the
; bytes $90 col,row in the fabricated packet
; Called from: JMP at $ED22 and BCC at $ED8B in runAiMissileTurn, plus the fall-through from $EDD2
; Missile conservation: while the human still owns drones the AI keeps two missiles in hand for each
; of them, so it will not empty the rack on ground targets and be defenceless against the next drone.
; ----------------------------------------------------------------------
fireMissileAtSpottedUnit:
bit droneControlFlags ; EDD5 $920D bit 6 = the human is flying a drone
bvs forgetMissileTarget ; EDD8 he is - the anti-drone code above owns this exchange, so no ground shot
lda dronesLeft ; EDDA drones the human still has
asl a ; EDDD two missiles reserved per drone
sta scratch18 ; EDDE zp_18 = the reserve
lda opponentMissilesLeft ; EDE0 missiles the AI owns
beq forgetMissileTarget ; EDE3 none at all
sec ; EDE5 no borrow wanted
sbc scratch18 ; EDE6 missiles minus the reserve
bcc forgetMissileTarget ; EDE8 fewer than the reserve - hold fire
beq forgetMissileTarget ; EDEA exactly the reserve - hold fire as well
lda D_8970 ; EDEC reload countdown
bpl missileTurnTail ; EDEF still reloading (>= 0): skip to the tail, which ticks it down
lda D_8966 ; EDF1 this exchange's action budget
bmi forgetMissileTarget ; EDF4 already spent on the drone launch - drop the target and leave
ldx D_8972 ; EDF6 $8972 = human unit last seen shooting at the AI comcen, $FF = none known
bmi targetEnemyComcenWithMissile; EDF9 none - try the human comcen instead
lda unitTypeTable,x ; EDFB type byte of that unit
bpl checkTargetOnRadar ; EDFE still alive (bit 7 clear) - shoot at it
; ----------------------------------------------------------------------
; targetEnemyComcenWithMissile - Fall-through tail of fireMissileAtSpottedUnit: when the AI has no
; remembered attacker, or that attacker is dead, it shoots at the human's COMCEN (unit $31 = 49)
; instead, but only while that comcen is currently visible.
; In: $F9F5 = energy/visibility byte of unit 49 (bit 7 = visible right now)
; Out: X = $31 when the comcen may be shot at, otherwise branches to $EE19 which clears the target
; Called from: BMI at $EDF9 and fall-through from $EDFE. XREF lists 'call:E0A5', but that JSR only
; ever runs while the track 29 drone-flight overlay occupies $EE00-$EFFF, so it never enters this
; code
; ----------------------------------------------------------------------
targetEnemyComcenWithMissile:
lda side0ComcenEnergy ; EE00 energy/visibility byte of the human comcen
bpl forgetMissileTarget ; EE03 bit 7 clear = not visible; nothing to shoot at
ldx #$31 ; EE05 $31 = 49 = the human player's COMCEN
checkTargetOnRadar:
jsr isUnitOnAiRadar ; EE07 put the target cell in zp_22/zp_24 and test it against the AI comcen's radar grid
bcs forgetMissileTarget ; EE0A outside the 21x17 grid - the AI cannot see it, so no shot
jsr cellDistance ; EE0C range from the AI comcen (zp_27/zp_28) to the target
L_EE0F:
lsr a ; EE0F half of it ...
sta D_8970 ; EE10 ... becomes the reload countdown: long shots take longer to repeat
jsr queueMissileStrikeCommand; EE13 append the $90 col,row strike to the fabricated packet
jmp missileTurnTail ; EE16 keep $8972 so the same unit stays the target next exchange
forgetMissileTarget:
lda #$FF ; EE19 $FF = no target
sta D_8972 ; EE1B forget the remembered attacker
; ----------------------------------------------------------------------
; Shared tail: remember the human drone's heading so the next anti-drone shot can tell whether it
; jinked, and tick the reload countdown down towards $FF = ready.
; ----------------------------------------------------------------------
missileTurnTail:
lda droneHeading ; EE1E current heading of the human's drone
sta D_896F ; EE21 $896F is compared against it at $ED9E next time
lda D_8970 ; EE24 reload countdown
bmi missileTurnDone ; EE27 already at $FF: the launcher is ready, leave it alone
dec D_8970 ; EE29 one exchange closer to being reloaded
missileTurnDone:
rts ; EE2C back to injectAiCommandsIntoPacket
; ----------------------------------------------------------------------
; queueMissileStrikeCommand - Appends the 3-byte command $90 col,row (MISSILE STRIKE, handler
; cmdMissileStrike $53C0: 14 points of damage, 7 to a comcen) to the packet the trainer pretends to
; have received from the opponent. Both coordinates are pre-mirrored with mirrorMapCoordinate (39-v)
; because the command dispatcher un-mirrors every parameter that arrives from a remote player.
; In: zp_22 = strike column, zp_24 = strike row, $E01E = current packet length
; Out: three bytes appended at $E020+, $E01E advanced by 3, $8966 spent (decremented to $FF), $8968
; charged two of the round's command slots; X clobbered
; Called from: $EDD2 (anti-drone shot) and $EE13 (shot at a spotted unit)
; ----------------------------------------------------------------------
queueMissileStrikeCommand:
ldx rxPacketLength ; EE2D X = current length of the fabricated incoming packet
lda #$90 ; EE30 $90 = MISSILE STRIKE
sta rxPacketBuffer,x ; EE32 command byte into the received-packet buffer
inx ; EE35 next byte
lda cellColA ; EE36 strike column
jsr mirrorMapCoordinate ; EE38 $3FA3: 39 - column, undoing the mirror the engine will apply to a remote command
sta rxPacketBuffer,x ; EE3B first parameter
L_EE3E:
inx ; EE3E next byte
lda cellRowA ; EE3F strike row
L_EE41:
jsr mirrorMapCoordinate ; EE41 mirror it as well
sta rxPacketBuffer,x ; EE44 second parameter
inx ; EE47 past the last byte
stx rxPacketLength ; EE48 packet is three bytes longer
dec D_8966 ; EE4B this exchange's single action is now spent
inc D_8968 ; EE4E charge the first of the two command slots a missile costs
inc D_8968 ; EE51 and the second: a missile counts double against the four commands a round allows
L_EE54:
rts ; EE54 done
; ----------------------------------------------------------------------
; randomlySignValue - Returns A or -A with equal probability, using bit 0 of the lock-step game RNG.
; Used twice by runAiMissileTurn to scatter the column and the row of a missile that is meant to miss
; the human's drone.
; In: A = magnitude
; Out: A = zp_18 = +A or -A (two's complement); the game RNG has been stepped
; Called from: $EDB5 (column offset) and $EDBF (row offset)
; ----------------------------------------------------------------------
randomlySignValue:
sta scratch18 ; EE55 keep the magnitude
jsr nextGameRandom ; EE57 one random byte from the lock-step RNG
and #$01 ; EE5A just its lowest bit
beq returnSignedValue ; EE5C 0 - leave the magnitude positive
lda scratch18 ; EE5E 1 - negate it: two's complement ...
eor #$FF ; EE60 ... invert ...
clc ; EE62 clear carry for the +1
adc #$01 ; EE63 ... and add one
sta scratch18 ; EE65 zp_18 = -magnitude
returnSignedValue:
lda scratch18 ; EE67 return the signed value
rts ; EE69 back to the missile scatter
; ----------------------------------------------------------------------
; isUnitOnAiRadar - Loads unit X's cell into zp_22/zp_24 and asks whether it lies inside the 21x17
; radar grid centred on the computer opponent's own COMCEN. Because mapCellToRadarGrid centres the
; grid on comcenUnit ($9236), this routine swaps that variable to $63 (unit 99, side 1's comcen) for
; the test and puts it back to $31 (unit 49) afterwards - the unconditional $31 is proof that this
; module always assumes the human is side 0.
; In: X = unit index, $F640/$F6A4 = unit column/row arrays
; Out: zp_22 = column, zp_24 = row of that unit (point A for the next cellDistance), C = 0 when the
; cell is on the AI's radar grid and C = 1 when it is off it; $9236 left at $31; X and Y clobbered
; Called from: $EE07 (missile targeting) and $EEFA (drone targeting in THE BOMB). Note it calls into
; the $6F00 comcen-screens overlay, so overlay B must be the resident one
; ----------------------------------------------------------------------
isUnitOnAiRadar:
lda unitColTable,x ; EE6A map column of unit X
sta cellColA ; EE6D zp_22 = point A column, also the strike coordinate if a missile follows
lda unitRowTable,x ; EE6F map row of unit X
L_EE72:
sta cellRowA ; EE72 zp_24 = point A row
tay ; EE74 Y = row, as mapCellToRadarGrid wants it
ldx cellColA ; EE75 X = column
L_EE77:
lda #$63 ; EE77 $63 = 99 = side 1's COMCEN
sta comcenUnit ; EE79 pretend the local comcen is the AI's, so the radar grid is centred on it
jsr mapCellToRadarGrid ; EE7C $7A13 in the comcen-screens overlay: C=0 when the cell is inside the 21 x 17 grid
lda #$31 ; EE7F $31 = 49 = the human player's COMCEN
sta comcenUnit ; EE81 restore comcenUnit - the rest of the game expects the local player's comcen there
rts ; EE84 carry from the grid test survives both stores
; ----------------------------------------------------------------------
; launchAiDrone - Sends the computer opponent's drone up when one is due. It writes the launch
; straight into the packet the game is about to 'receive': $97 DRONE LAUNCH followed by a $8D DRONE
; MOVE that places the drone on the AI comcen and points it at the chosen target unit. The AI keeps
; its own copy of the drone position in $8974/$8975 and a 100-step fuel counter in $8973; $8977 = 3
; holds the next launch off for three exchanges.
; In: $8977 launch delay (must be 0), $8976 target unit (>= 0), $920D bit 7 (no drone of the human's
; in the air), $F6A3/$F707 AI comcen cell, $F640/$F6A4 target cell
; Out: $E020 = $97, $E021-$E024 = $8D heading,col,row, $E01E = 5; $8974/$8975 launch cell, $8973 = $64
; fuel, $8977 = 3, $896F heading, $8966 spent; zp_92-zp_95 line-stepper state clobbered
; Called from: $ED0B, the first thing runAiMissileTurn does each exchange
; Three gates before a launch: the delay must have run out, a target must have been picked by
; chooseAiDroneTarget, and no drone may be in the air already - the engine tracks only one at a time.
; ----------------------------------------------------------------------
launchAiDrone:
lda D_8977 ; EE85 $8977 = exchanges to wait before the next launch ($FF also means the drone-flight overlay is resident)
bne noDroneLaunch ; EE88 not due yet
lda D_8976 ; EE8A $8976 = the unit the drone should be sent at
bmi noDroneLaunch ; EE8D bit 7 set = no target chosen
lda droneControlFlags ; EE8F $920D drone state flags
bmi noDroneLaunch ; EE92 bit 7 = a drone flight is already in progress - wait for it to end
dec D_8966 ; EE94 spend this exchange's single action
lda #$97 ; EE97 $97 = DRONE LAUNCH (handler cmdDroneLaunch $53A3: 'DRONE ALERT!' and one drone off the opponent's stock)
sta rxPacketBuffer ; EE99 first byte of the fabricated packet
lda #$8D ; EE9C $8D = DRONE MOVE, 4 bytes: heading, column, row
sta rxPacketArg1 ; EE9E second byte
L_EEA1:
ldx side1ComcenCol ; EEA1 the drone starts over the AI's own comcen
stx D_8974 ; EEA4 $8974 = the AI's private copy of the drone column
stx lineCurCol ; EEA7 zp_93 = line-stepper start column
ldy side1ComcenRow ; EEA9 comcen row
sty D_8975 ; EEAC $8975 = drone row
L_EEAF:
sty lineCurRow ; EEAF zp_92 = line-stepper start row
jsr mirrorBlockCornerForOpponent; EEB1 add one to each coordinate and mirror: exactly undoes the mirror-then-decrement the engine applies to a remote $8D (the drone is a 2x2 object)
stx rxPacketArg3 ; EEB4 column parameter of the $8D
sty rxPacketArg4 ; EEB7 row parameter
ldy D_8976 ; EEBA the chosen target unit
lda unitColTable,y ; EEBD its column ...
sta lineEndCol ; EEC0 ... becomes the line-stepper target column
lda unitRowTable,y ; EEC2 its row ...
sta lineEndRow ; EEC5 ... the target row
jsr getStepDirection ; EEC7 one Bresenham step towards the target: Y = compass direction 0-7, A = that direction's row delta
sta D_896F ; EECA remember the heading for the AI's own bookkeeping
sta rxPacketArg2 ; EECD heading parameter of the $8D - but this is A, the row delta (-1/0/+1), not the direction index in Y. cmdDroneMove draws sprite shape (heading+4) AND 7 = 3, 4 or 5, so the AI's drone is always drawn flying south towards the player. Probably a slip for TYA
L_EED0:
lda #$05 ; EED0 5 bytes: one $97 plus four for the $8D
sta rxPacketLength ; EED2 hand the packet to the engine as if the opponent had sent it
lda #$64 ; EED5 $64 = 100 steps of fuel, one per exchange, matching the human's drone tank
sta D_8973 ; EED7 $8973 = the AI's own fuel counter
lda #$03 ; EEDA wait three exchanges before considering another launch
sta D_8977 ; EEDC $8977 also stays non-negative, marking the native $EE00 tail as the resident one
noDroneLaunch:
rts ; EEDF nothing to launch
; ----------------------------------------------------------------------
; chooseAiDroneTarget - Picks what the computer opponent should send its next drone at and leaves the
; unit index in $8976. It bails out when the AI has no drones left or a target is still pending. In
; game type 2 (THE BOMB) the only worthwhile target is the human COMCEN, as soon as it is visible or
; shows on the AI's radar. Otherwise, once the comcen is visible, it adds up the strength of every
; group that has reached it (double for a group with a clear run at it) and, at a total of 8, commits
; to an all-out attack: every group is flagged committed, all 46 combat units are made due for new
; orders, the drone is held back 21 exchanges and then aimed at the comcen. If the comcen is not
; visible it falls into pickDroneTargetAlongThreatAxis.
; In: $92AF drones the AI has left, $8976, $0BA3 bits 0-2 = game type, $F9F5 human comcen visibility,
; $8902 group strengths, $893E group flags, $8953 highest group id, $8954 group count, $91CB game
; clock
; Out: $8976 = target unit ($31 = the human comcen), $893E bit 6 set for every group, $8800-$882D
; stamped with the clock, $8977 = $15; zp_18, zp_19 clobbered
; Called from: planTrainerRound ($E1FF), once per round and only while the AI is in attack mode ($8963
; = 0)
; ----------------------------------------------------------------------
chooseAiDroneTarget:
lda opponentDronesLeft ; EEE0 $92AF = drones the computer opponent still owns
beq droneTargetDone ; EEE3 none left - it will never launch again
lda D_8976 ; EEE5 $8976 = pending drone target
bpl droneTargetDone ; EEE8 bit 7 clear = a target is already chosen, so leave it alone
lda gameTypeOptions ; EEEA game options byte
L_EEED:
and #$07 ; EEED bits 0-2 = the game type
cmp #$02 ; EEEF type 2 = THE BOMB, where only the comcen counts
bne chooseTargetByGroupPressure; EEF1 any other game type
lda side0ComcenEnergy ; EEF3 energy/visibility of the human comcen
bmi targetHumanComcen ; EEF6 bit 7 = visible right now: send the drone at it
ldx #$31 ; EEF8 $31 = 49 = the human comcen
jsr isUnitOnAiRadar ; EEFA is it at least on the AI comcen's radar grid?
L_EEFD:
bcs droneTargetDone ; EEFD not on the radar either - no drone target this round
bcc targetHumanComcen ; EEFF on the radar - target it
; ----------------------------------------------------------------------
; Every other game type: measure how much of the AI's army has actually converged on the human comcen.
; $893E bit 5 = this group has a clear line to the comcen (set at $E3C3), bit 6 = one of its members
; has
; come within 8 cells of it (set at $EB1F).
; ----------------------------------------------------------------------
chooseTargetByGroupPressure:
lda side0ComcenEnergy ; EF01 comcen visibility again
bpl pickDroneTargetAlongThreatAxis; EF04 not visible - go and look for a worthwhile cluster of units instead
ldy D_8954 ; EF06 $8954 = number of AI groups ...
dey ; EF09 ... so the last group index is one less
lda #$00 ; EF0A zp_18 = 0
sta scratch18 ; EF0C running total of converging strength
sumConvergingGroupsLoop:
lda D_8902,y ; EF0E $8902 = number of units in group Y
sta scratch19 ; EF11 zp_19 = this group's weight
ldx D_893E,y ; EF13 group flags
txa ; EF16 test them ...
and #$20 ; EF17 ... bit 5 = the group has a clear run at the human comcen
beq testGroupContactFlag ; EF19 no clear run - try the contact bit
asl scratch19 ; EF1B a clear run counts double
jmp addGroupWeight ; EF1D add it in
testGroupContactFlag:
txa ; EF20 flags again
and #$40 ; EF21 bit 6 = a member of this group has reached the comcen
L_EF23:
beq nextGroupInSum ; EF23 neither flag - this group does not count
addGroupWeight:
lda scratch19 ; EF25 the group's weight
clc ; EF27 clear carry
L_EF28:
adc scratch18 ; EF28 add to the running total
sta scratch18 ; EF2A keep the total
nextGroupInSum:
dey ; EF2C next group down
bpl sumConvergingGroupsLoop ; EF2D until group 0 has been counted
lda scratch18 ; EF2F converging strength
cmp #$08 ; EF31 eight units' worth is the trigger for the all-out attack
bcc pickDroneTargetAlongThreatAxis; EF33 not enough yet - go back to hunting clusters
; ----------------------------------------------------------------------
; All-out attack: flag every group as committed, then stamp the order clock of all 46 mobile AI units
; with the current game clock. scanTrainerUnitsForOrders re-plans a unit whose stamp has caught up
; with the down-counting clock ($E2E3), so this makes the whole army replan on the next scan.
; ----------------------------------------------------------------------
commitAllGroups:
ldy D_8953 ; EF35 $8953 = highest group id in use
commitGroupLoop:
lda D_893E,y ; EF38 flags of group Y
ora #$40 ; EF3B bit 6 = committed to the attack
sta D_893E,y ; EF3D store them back
dey ; EF40 next group down
bpl commitGroupLoop ; EF41 until group 0 is done
ldy #$2D ; EF43 $2D = 45: units 50..95, the AI's 28 grunts, 12 riders and 6 boomers (its 3 spies and comcen are left alone)
lda gameClock ; EF45 $91CB = the down-counting game clock
stampOrderClockLoop:
sta msgGetOpponentOnPhoneAnd,y; EF48 $8800+ordinal = the round this unit may next be given orders
dey ; EF4B next AI unit
bpl stampOrderClockLoop ; EF4C all 46 of them
lda #$15 ; EF4E $15 = 21 exchanges ...
sta D_8977 ; EF50 ... before the drone follows the ground attack in
targetHumanComcen:
lda #$31 ; EF53 $31 = 49 = the human COMCEN
sta D_8976 ; EF55 that is the drone's target
droneTargetDone:
rts ; EF58 no target this round
; ----------------------------------------------------------------------
; pickDroneTargetAlongThreatAxis - Tail of chooseAiDroneTarget for the case where the human comcen is
; no use as a drone target. It scans the threat around the AI's current objective (the escorted
; group's centre, or the AI comcen when no group is being escorted), and if the pressure reaches that
; group's own strength and something really has the point in weapon range, it walks a ray in the
; direction the enemy is coming from ((quietest+4) AND 7) at radii 4 to 14, scoring each 2x2 block of
; cells with scoreTargetBlock. The highest-numbered human unit in the best-scoring block becomes the
; drone target, and the acceptance threshold $8979 is cut to three quarters so the AI grows less
; choosy as the game goes on.
; In: $895D escorted group ($FF = none), $88EA/$88F6 group centre, $8902 group strength, $8979
; block-score threshold (set per opening play by the trainer setup overlay: $FF, $FF, 8, $0C,
; $0C), $F6A3/$F707 AI comcen cell
; Out: $8976 = drone target unit (untouched when nothing scored), $8979 scaled by 3/4, $8956 = the
; threshold used, zp_1C/zp_1D best score and unit, zp_1E/zp_1F ray direction and radius,
; zp_18/zp_19/zp_22/zp_24/zp_27/zp_28 and zp_94/zp_95 clobbered
; Called from: BPL at $EF04 and BCC at $EF33 inside chooseAiDroneTarget - it is that routine's
; fall-through tail, not a separate entry
; Choose the point to scan around and the pressure it takes to make the AI bother: the group the
; comcen escorts and its member count, or the AI comcen itself with a fixed threshold of 3.
; ----------------------------------------------------------------------
pickDroneTargetAlongThreatAxis:
ldx side1ComcenCol ; EF59 default scan point: the AI's own comcen column
ldy side1ComcenRow ; EF5C and row
lda #$03 ; EF5F default threat threshold
sta D_8956 ; EF61 $8956 doubles as the trainer's 'how much pressure is worth reacting to' value
lda D_895D ; EF64 $895D = the group the AI comcen escorts
bmi storeScanReferencePoint ; EF67 bit 7 = no group is being escorted - keep the comcen defaults
tay ; EF69 Y = that group
lda D_8902,y ; EF6A $8902 = its member count
L_EF6D:
sta D_8956 ; EF6D a big group needs more pressure before its drone is called in
ldx D_88EA,y ; EF70 the group's centre column ...
lda D_88F6,y ; EF73 ... and centre row ...
tay ; EF76 ... become the scan point
storeScanReferencePoint:
stx cellColB ; EF77 zp_27 = reference column for the threat scan
sty cellRowB ; EF79 zp_28 = reference row (the scan routine is written for the comcen but works on any point)
jsr assessThreatAroundAiComcen; EF7B A = threat level, X = deepest weapon-range penetration, Y = quietest compass direction
cmp D_8956 ; EF7E compare the measured pressure with the threshold
bcc pickDroneTargetDone ; EF81 too quiet around the objective - no drone target
txa ; EF83 X = the range surplus
beq pickDroneTargetDone ; EF84 nothing actually has the objective in weapon range - not worth a drone either
; ----------------------------------------------------------------------
; Walk a ray from the objective straight into the enemy - the direction opposite the quietest one -
; and score every 2x2 block of cells along it from 4 to 14 cells out. scoreTargetBlock keeps the best
; score in zp_1C and the strongest human unit of that block in zp_1D.
; ----------------------------------------------------------------------
tya ; EF86 A = the quietest direction
clc ; EF87 clear carry
adc #$04 ; EF88 +4 of the 8 compass points = the exact opposite ...
and #$07 ; EF8A ... modulo 8: the direction the enemy is pressing from
sta scratch1E ; EF8C zp_1E = the ray's direction
lda #$04 ; EF8E start 4 cells out from the objective
sta scratch1F ; EF90 zp_1F = the current radius
lda D_8979 ; EF92 $8979 = the score a block has to beat
sta scratch1C ; EF95 zp_1C = the score to beat. A value with bit 7 set (the $FF that plays 0 and 1 start with) makes scoreTargetBlock accept any block whose score is not negative
lda #$FF ; EF97 $FF = no unit picked yet
sta scratch1D ; EF99 zp_1D = the unit the best block yields, none yet
scanRayLoop:
ldx scratch1E ; EF9B direction of the ray
ldy scratch1F ; EF9D how far out
jsr offsetPointInDirection ; EF9F zp_18/zp_19 = the cell zp_1F cells along the ray, clamped to the map (offsetPointInDirection patches its own LDX operand at $E67C+1 with the direction index); C=1 means it fell off the 40x40 map
bcs pickBestScoredBlock ; EFA2 the ray ran off the 40x40 map - stop scanning
ldx scratch18 ; EFA4 X = candidate column
ldy scratch19 ; EFA6 Y = candidate row
jsr scoreTargetBlock ; EFA8 score the 2x2 block at that corner - grunt 1, rider 2, boomer 4, spy 1, comcen 4, negative for the AI's own units. scoreTargetBlock patches $E9E3+1/$E9E7+1 with X and Y so a winning block can drop its corner into zp_95/zp_94; nothing here uses that copy
inc scratch1F ; EFAB one cell further out
lda scratch1F ; EFAD current radius
cmp #$0F ; EFAF stop after 14 cells
bcc scanRayLoop ; EFB1 keep walking the ray
pickBestScoredBlock:
lda scratch1D ; EFB3 zp_1D = highest-numbered human unit standing in the best block. Since the roster runs 0-27 grunts, 28-39 riders, 40-45 boomers, 46-48 spies, 49 comcen, the highest index is also the most valuable unit there
bmi pickDroneTargetDone ; EFB5 still $FF - no block beat the threshold, so no drone target this round
sta D_8976 ; EFB7 it becomes the drone's target ($8976); launchAiDrone aims at it on a later exchange
; ----------------------------------------------------------------------
; Having spent a target, drop the acceptance threshold to three quarters, so later drones settle for
; smaller clusters. Plays 0 and 1 start at $FF, which counts as 'accept anything'; after three
; targets the threshold lands on $6C = 108, which no 2x2 block (maximum 16) can ever reach again.
; ----------------------------------------------------------------------
lda D_8979 ; EFBA the current threshold
lsr a ; EFBD /2 ...
lsr a ; EFBE ... /4
sta scratch18 ; EFBF zp_18 = a quarter of it
lda D_8979 ; EFC1 the threshold again
sec ; EFC4 no borrow wanted
sbc scratch18 ; EFC5 threshold minus a quarter = 3/4 of it
bcc pickDroneTargetDone ; EFC7 never taken: A - A/4 cannot borrow, so this is a redundant guard
sta D_8979 ; EFC9 store the reduced threshold
pickDroneTargetDone:
rts ; EFCC back to the planner
; strayTextTail - The last three characters, 'CE' and '.' with bit 7 set, of the string 'INSERT DATA
; DISK AND PRESS SPACE.' that occupies $EFB6-$EFCF in the modem-driver build of this overlay slot.
; Here the trainer's code runs up to $EFCC, so only this fragment survives.
; In: -
; Out: -
; Called from: nothing; unreferenced
; $EFCD-$EFFF is not code or data of the trainer: it is the tail of the disk sector, and every build
; of
; the $EC00/$EE00 overlay slot carries the same mastering-tool leftovers here. Nothing in the
; disassembly reads any of it.
strayTextTail:
.byte "CE",'.'|$80 ; EFCD 'CE' plus '.' with bit 7 set - the tail of a string that belongs to another build
; msgDatagamecustomOff2: text, 18 bytes. 'DATAGAMECUSTOM OFF' with bit 7 on the final F.
; Byte-identical to the same block in ovl_E000_T34, ovl_EC00_T29 and ovl_EE00_T29 - mastering-tool
; filler at the tail of every $EC00/$EE00 overlay build. No reference anywhere in the disassembly.
msgDatagamecustomOff2:
.byte "DATA" ; EFD0 'DATA' - start of the shared filler block found at the end of every $EC00/$EE00 overlay
.byte "GAME" ; EFD4 'GAME'
.byte "CUSTOM OF",'F'|$80 ; EFD8 'CUSTOM OFF', bit 7 set on the last F to terminate the string
; msgDestroyOff2: text, 11 bytes. 'DESTROY OFF' with bit 7 on the final F. Same shared filler block;
; unreferenced.
msgDestroyOff2:
.byte "DESTROY OF",'F'|$80 ; EFE2 'DESTROY OFF', bit 7 on the final F; same filler block, never referenced
; msgAccepted2: text, 8 bytes. 'ACCEPTED' with bit 7 on the final D. Same shared filler block;
; unreferenced.
msgAccepted2:
.byte "ACCEPTE",'D'|$80 ; EFED 'ACCEPTED', bit 7 on the final D; same filler block, never referenced
; Sector padding: $C0,$DF then $00,$FF to the end of the page. Identical in all four builds of this
; overlay slot apart from the very last byte, and read by nothing.
overlayTailFiller:
.byte $C0,$DF,$00,$FF,$00,$FF,$00,$FF; EFF5 ........ $C0,$DF then alternating $00,$FF padding to the end of the page
.byte $00,$FF,$FF ; EFFD ... last three padding bytes; $EFFF is $FF here and in the track 29 variants, $00 in the modem driver