{
  "chunk": "game_ovl_E000_T35_E000",
  "unit": "game/ovl_E000_T35",
  "routines": [
    {
      "addr": "E000",
      "name": "commRequestEntry",
      "summary": "Jump-table slot 0 of the communications module (JMP handleCommRequest). The game calls it every raster IRQ with X=0 ($1129), with X=1 to transmit the outgoing packet (sendOutgoingPacket $56AE), with X=2 to acknowledge a completed exchange ($4EFF/$526F) and with X=3 to cancel a pending send. In this build (T35 = SOLO TRAINER) there is no link at all: X=1 runs one turn of the computer opponent's AI and leaves its orders in the receive buffer.",
      "inputs": "X = request code 0..3",
      "outputs": "see handleCommRequest ($E043)",
      "confidence": "high"
    },
    {
      "addr": "E003",
      "name": "commSetLinkStateEntry",
      "summary": "Jump-table slot 1 (JMP setCommLinkState). Called by initCommModule ($0F46, X=1) and by hangUpModemSetState ($1B56/$1B70, X=3) to bring the pretend link up (X=0) or down (X>=1).",
      "inputs": "X = 0 open, >=1 close",
      "outputs": "isLinkActive/commCarrierStatus/commLinkReady ($E03B/$E03C/$E03D)",
      "confidence": "high"
    },
    {
      "addr": "E006",
      "name": "commGetReceivedByteEntry",
      "summary": "Jump-table slot 2 (JMP reportNoReceivedByte). Called by receiveChatCharacter ($1399) to fetch one byte of the opponent's chat line. In the trainer build it always returns C=1 (nothing received), so the chat receiver never produces text.",
      "inputs": "none",
      "outputs": "C=1 always (no byte)",
      "confidence": "high"
    },
    {
      "addr": "E009",
      "name": "commSendByteEntry",
      "summary": "Jump-table slot 3 (JMP commNoOpClc). Jumped to from $1396 to transmit one chat character. In the trainer build the byte is discarded and C=0 (accepted) is returned.",
      "inputs": "A = byte to send (ignored)",
      "outputs": "C=0",
      "confidence": "high"
    },
    {
      "addr": "E00C",
      "name": "commFrameTickEntry",
      "summary": "Jump-table slot 4 (JMP commNoOpClc). Called once per raster IRQ from $112C; in the modem build this services the serial line, in the trainer build it does nothing.",
      "inputs": "none",
      "outputs": "C=0",
      "confidence": "high"
    },
    {
      "addr": "E012",
      "name": "commKeyboardScanVector",
      "summary": "JMP whose operand bytes $E013/$E014 are the module's keyboard-scan vector. initCommModule ($0F4B/$0F50) patches them to the game's scanKeyboard ($0DB7); until then the vector points at commNoOpClc so no key is ever reported.",
      "inputs": "none",
      "outputs": "whatever scanKeyboard returns (A=key, Y/X = shift/C= flags)",
      "confidence": "high"
    },
    {
      "addr": "E015",
      "name": "commReadKeyEntry",
      "summary": "Jump-table slot 5 (JMP commKeyboardScanVector). Called from pollKeyboardEvent ($0E56) every frame; it is the only route by which the game reads the keyboard, so the comm module can steal the keyboard while it owns the CPU.",
      "inputs": "none",
      "outputs": "key code in A, shift/C= flags in Y/X",
      "confidence": "high"
    },
    {
      "addr": "E043",
      "name": "handleCommRequest",
      "summary": "Body of commRequestEntry. X=1 jumps to buildTrainerReplyPacket ($E082) which runs the computer opponent. X=3 clears outgoingPacketLength ($E01F). Every other value (X=0 per-frame poll, X=2 packet acknowledge) sets packetExchangeStatus ($E01D) = $C0 - 'own packet sent AND opponent packet received' - and copies commSuspendRequest ($E039) into commSuspendAck ($E03A). Because $E01D is always $C0 the lock-step exchange in processCommandExchange never waits, and suspend/resume around disk access ($0F5F/$0F71) succeeds immediately.",
      "inputs": "X = request code",
      "outputs": "$E01D = $C0, $E03A = $E039, or $E01F = 0",
      "confidence": "high"
    },
    {
      "addr": "E060",
      "name": "setCommLinkState",
      "summary": "Body of commSetLinkStateEntry. X=0 stores $FF into commLinkReady ($E03D), commCarrierStatus ($E03C) and isLinkActive ($E03B); X>=1 stores 0 in all three and also clears outgoingPacketLength ($E01F). Since $E03C is $FF the game's carrier tests (bit 6 in pollMenuSession, bit 7 in hangUpModemSetState) always see a healthy link.",
      "inputs": "X",
      "outputs": "$E03B, $E03C, $E03D, $E01F",
      "confidence": "high"
    },
    {
      "addr": "E075",
      "name": "reportNoReceivedByte",
      "summary": "SEC + RTS: 'no character available' answer of commGetReceivedByteEntry in the trainer build.",
      "inputs": "none",
      "outputs": "C=1",
      "confidence": "high"
    },
    {
      "addr": "E077",
      "name": "commNoOpClc",
      "summary": "CLC + RTS shared no-op used as the body of commSendByteEntry, commFrameTickEntry and the unpatched keyboard vector. $E078 is the bare RTS.",
      "inputs": "none",
      "outputs": "C=0",
      "confidence": "high"
    },
    {
      "addr": "E079",
      "name": "runTrainerRoundHook",
      "summary": "Once-per-round hook called from finishRound ($4654) after the game clock has been decremented. Unless the drone sub-overlay is resident (trainerDroneTimer $8977 bit 7 set) it runs planTrainerRound ($E170), the top-level 'think' routine of the computer opponent.",
      "inputs": "D_8977",
      "outputs": "see planTrainerRound",
      "confidence": "high"
    },
    {
      "addr": "E082",
      "name": "buildTrainerReplyPacket",
      "summary": "Reached with X=1 from sendOutgoingPacket, i.e. once per lock-step exchange right after the human's packet was handed over. Clears receivedPacketLength ($E01E) and returns at once during the setup phase (zp_B1), film playback ($0B9B bit 7), after the game ended ($0BA8) or while paused (zp_BE). Otherwise it manages the swappable $EE00 sub-overlay: while the opponent's drone is in the air (droneStateFlags $920D bit 0) it loads track 29 s4-5 to $EE00 (the drone-flight AI) and calls $EE00 with $8977 = $FF; when the drone is gone it reloads track 35 s14-15 to $EE00 (the native tail), sets $8976 = $FF and $8977 = 3. In the normal case it counts $8977 down and calls flushTrainerCommandQueue ($EA7B), which moves up to 7 bytes of queued AI orders into the received-packet buffer $E020.",
      "inputs": "zp_B1, $0B9B, $0BA8, zp_BE, $920D, $8977",
      "outputs": "$E01E/$E020 (the 'received' packet), $8976, $8977, $EE00-$EFFF possibly reloaded from disk",
      "confidence": "high"
    },
    {
      "addr": "E0E8",
      "name": "trainerUnitStepHook",
      "summary": "Per-unit hook called at the end of processUnitStep ($4526) for currentUnit ($90FA). Dead units are skipped. For one of the AI's own units (index >= 50) that has scored both territory zones (unitTypeTable bits 3 and 4) it sets bit 7 of trainerUnitPenetrated ($8864 + ordinal) so the planner stops counting it as 'still at home'; for the AI comcen (unit 99) it additionally samples bit 1 of unitLocalFlags[99] ($FBB7, set by recordUnitHit) into trainerComcenHit ($897B) and the one-round-sticky trainerComcenHitRecent ($897C). All other units fall through to trainerUnitSpottedHook ($EACF), which tracks what the AI has seen.",
      "inputs": "$90FA, $F76C, $FBB7, $8977",
      "outputs": "$8864+ordinal bit 7, $897B, $897C; otherwise calls $EACF",
      "confidence": "medium"
    },
    {
      "addr": "E128",
      "name": "setSubOverlayLoadDest",
      "summary": "Helper: X=$00, Y=$EE, JMP setLoadDest ($085C) - points the fast loader at $EE00 before the two sub-overlay loads in buildTrainerReplyPacket.",
      "inputs": "none",
      "outputs": "loader destination = $EE00",
      "confidence": "high"
    },
    {
      "addr": "E12F",
      "name": "pickTrainerRallyPoint",
      "summary": "Chooses the AI's current objective: a random cell near the human comcen (unitCol[49] = $F671, unitRow[49] = $F6D5) via randomiseCoordinate, stored in trainerRallyCol/Row ($8969/$896A). Called every six rounds (or immediately once the human comcen has been spotted) from planTrainerRound, and once at game start by the trainer setup overlay ($EC47).",
      "inputs": "$F671, $F6D5, RNG",
      "outputs": "$8969, $896A",
      "confidence": "high"
    },
    {
      "addr": "E14A",
      "name": "randomiseCoordinate",
      "summary": "Returns A = base coordinate plus a random offset, clamped to 0..39. The base is stored into the immediate operand at $E163+1 and the offset comes from randomCoordinateOffsetTable ($E142) indexed by nextGameRandom AND 7. The offset is used raw when the human comcen is visible (bit 7 of unitEnergy[49] = $F9F5, the 'spotted' bit), doubled when trainerEnemyComcenSeen ($896C) bit 7 is set and quadrupled otherwise - so the AI aims accurately at a comcen it can see and wanders when it cannot. $E14D is the continuation that reuses the last base.",
      "inputs": "A = base coordinate, $F9F5, $896C, RNG",
      "outputs": "A = coordinate 0..39; self-modifies $E164",
      "confidence": "high"
    },
    {
      "addr": "E170",
      "name": "planTrainerRound",
      "summary": "Top-level planner of the solo trainer, run once per game round. Returns at once if the game is over ($0BA8). Resets trainerOrdersThisRound ($8968), refreshes the rally point every 6 rounds (or immediately when the human comcen is visible), sets trainerActionBudget ($8965) from the AI comcen's energy ($FA27 AND $3F) and trainerComcenDamage ($8971) from its damage nibble ($F9C3 AND $0F). With the DAMAGE option off ($0BA3 bit 4 clear) the budget is a flat 50; a fully damaged comcen ($0F) makes the AI do nothing this round; 1-7 damage costs 16 budget points. A budget below 20 diverts to the repair/energy routine ($EB67). Otherwise it evaluates the threat around its own comcen with scanEnemyThreat ($EBEF) into trainerBestDir/trainerEnemyProximity/trainerThreatScore ($8960/$8961/$8962), sets trainerDefendMode ($8963) to $FF (defend) when enemies are close and either no assault group exists or the comcen was just hit, else 0 (attack, and then considers launching a drone via $EEE0). It then runs scanTrainerUnitsForOrders ($E2BD), and while fewer than 5 orders have been issued picks/refreshes the assault group ($E83C) and finishes with pushTrainerComcenForward ($E21F, attack) or retreatTrainerComcen ($E266, defend).",
      "inputs": "$0BA8, $F9F5, $FA27, $F9C3, $0BA3, $91CB, RNG, group tables at $8800-$897C",
      "outputs": "$8965, $8968, $896B, $896C, $8960-$8963, orders queued at $88A0, plus everything the callees change",
      "confidence": "medium"
    },
    {
      "addr": "E21F",
      "name": "pushTrainerComcenForward",
      "summary": "Attack-mode tail of planTrainerRound (branch target, not a JSR entry). If an assault group exists ($895D), the comcen is undamaged and its own replan clock ($8831) has come due, it queues a move order for the AI comcen (ordinal 49) one step further along trainerBestDir ($8960) from the assault group's average position ($895E/$895F), and re-arms $8831 to gameClock - 3. This is how the trainer walks its comcen up behind its attack.",
      "inputs": "$895D, $8971, $8831, $91CB, $8960, $8961, $895E, $895F",
      "outputs": "$8831, one $80/$81 order queued through queueTrainerMoveOrder ($E919)",
      "confidence": "medium"
    },
    {
      "addr": "E266",
      "name": "retreatTrainerComcen",
      "summary": "Defend-mode tail of planTrainerRound (branch target). Records the current clock in trainerAlertClock ($896E), and unless the comcen is damaged queues a move order that walks the AI comcen away along trainerBestDir ($8960) by trainerEnemyProximity ($8961) cells. If the threat score ($8962) then still exceeds trainerRecallThreshold ($897A) - or the comcen was damaged - it recomputes the assault group's statistics and jumps to aimGroupAtOwnComcen ($E339) to pull the group back home.",
      "inputs": "$8971, $8960, $8961, $8962, $897A, $895D, $F6A3, $F707",
      "outputs": "$896E, $895A = 0, one comcen move order, possibly a full group recall",
      "confidence": "medium"
    },
    {
      "addr": "E2BD",
      "name": "scanTrainerUnitsForOrders",
      "summary": "Walks the AI's 50 unit slots from ordinal 49 down to 0 using the embedded loop counter at $E2BC, stopping as soon as 4 orders have been queued this round ($8968). A dead slot ($F79E bit 7) gets its replan clock zeroed and its group set to $FF. A slot whose replan clock ($8800+ordinal) has been reached by the down-counting game clock ($91CB) is handed to decideTrainerGroupAction. The default replan urgency ($896D) is reloaded with 5 before each unit.",
      "inputs": "$8968, $91CB, $F79E, $8800, $8832",
      "outputs": "$E2BC, $8800, $8832, $896D; orders queued by the callee",
      "confidence": "high"
    },
    {
      "addr": "E2F1",
      "name": "decideTrainerGroupAction",
      "summary": "Decides what the AI group owning unit ordinal Y should do. Group id 0 is the comcen's own group: it is only handled when the comcen is undamaged and no assault group is selected, in which case the group target ($88D2/$88DE entry 0) is set to the comcen's own destination. For any group it caches the group flags ($893E) in the scratch byte $8966, masks them down to the low nibble, and calls computeTrainerGroupStats; an empty group aborts. It then branches into the four behaviours: head for the AI comcen (home group $8964, or the assault group when defending), advance toward the rally point ($8969/$896A) when the group has already penetrated, fall back to row 29 of the AI's half when it has not, or - for group 0 - run the free direction search from the comcen position.",
      "inputs": "Y = AI unit ordinal, $8832, $8971, $895D, $F833, $F897, $8964, $8966, zp_1D",
      "outputs": "$88D2/$88DE, $893E, $896D, $8955-$8963 group scratch; ends in one of the order-issuing tails",
      "confidence": "medium"
    },
    {
      "addr": "E339",
      "name": "aimGroupAtOwnComcen",
      "summary": "Internal tail (JMP target from $E2B3 and fall-through from $E336): sets the line-stepper target zp_95/zp_94 to the AI comcen's cell ($F6A3/$F707), copies the group centre (zp_93/zp_92) into the search origin zp_27/zp_28 and the target into zp_22/zp_24, then falls into issueTrainerGroupOrders. Used to recall a group to defend the comcen.",
      "inputs": "$F6A3, $F707, zp_92/zp_93 (group centre from computeTrainerGroupStats)",
      "outputs": "zp_22/zp_24 target cell, zp_27/zp_28 origin",
      "confidence": "high"
    },
    {
      "addr": "E3D0",
      "name": "aimGroupAtHomeLine",
      "summary": "Internal branch target used when the group still has members that have not reached the enemy end zone (zp_1D non-zero) and the group's 'committed' flag (bit 6 of $893E) is clear: the target column stays where the group is and the target row becomes 29 plus the group's row extents, i.e. a defensive line inside the AI's own half. Once the alert clock ($896E) has been set, or the group is weak, the group's combat value ($8956/$8957) is capped at 9 before the search continues at $E400.",
      "inputs": "zp_93, $891A, $8932, $896E, $91CB, $8956/$8957",
      "outputs": "zp_94/zp_95 target, $8956/$8957 possibly capped",
      "confidence": "medium"
    },
    {
      "addr": "E42A",
      "name": "findTrainerAdvanceCell",
      "summary": "Threat-aware path search. First builds the per-unit threat map (buildThreatWeights $E773) into the $0400/$0464 scratch pages. Then, for each of the 8 direction ranks in the preference list at $8896 (built by buildDirectionPreference $E7E3), it steps outward from (zp_27,zp_28) in steps of 2 cells, testing every candidate with evaluateCellSafety ($E69C); it keeps extending while the cell is safe, up to the desired advance distance ($895B) or the per-rank cap in maxAdvanceByRankTable ($E664), and moves to the next rank when a cell is rejected at distance 0. If nothing works the group stays put (direction 0, distance 0). The winning direction/distance are converted into the absolute target cell zp_22/zp_24 through directionColDeltaTable/directionRowDeltaTable ($E68A/$E693) and addStepsClamped ($EA1B).",
      "inputs": "zp_27/zp_28 origin, $8896 direction preference, $895B, $E664, $8956/$8957 group strength",
      "outputs": "zp_22/zp_24 = chosen target cell, zp_23 = distance, zp_26 = direction, $0400/$0464 threat map",
      "confidence": "medium"
    },
    {
      "addr": "E49E",
      "name": "issueTrainerGroupOrders",
      "summary": "Issues the move orders for the current group ($8955). Marks every member's replan clock with the current game clock, clamps the target with clampGroupTargetToMap (which also yields the delta zp_2A/zp_2B from the group's average destination), derives the replan delay zp_29 from the distance class ($E82A, doubled when the group contains slow units - $895A) with a floor of 4 and a cap of the urgency $896D, and then walks all 50 slots: every member of the group gets a move order to its own destination plus the delta through queueTrainerMoveOrder ($E919) and a new replan clock of gameClock - zp_29. Members that also belong to a real in-game group (unitGroupByte bit 6) propagate that clock to all their in-game group mates so the AI does not re-order the same formation twice.",
      "inputs": "$8955, zp_22/zp_24 target, $91CB, $895A, $896D, $8832, $F802/$F866, $F73A",
      "outputs": "$8800 replan clocks, $88D2/$88DE group target, orders appended to the queue at $88A0, zp_29/zp_2A/zp_2B",
      "confidence": "medium"
    },
    {
      "addr": "E531",
      "name": "computeTrainerGroupStats",
      "summary": "Gathers the statistics of AI group Y (stored in $8955). Scans the 50 AI slots: sums the per-type combat weights from unitCombatValueTable ($E2B7) into the 16-bit trainerGroupValue ($8956/$8957), sets trainerGroupHasSlowUnits ($895A) when a GRUNT (type 0) or BOOMER (type 2) is present, counts members that have not yet penetrated into zp_1D, and builds the bounding box of the members' ordered destinations in zp_22/zp_24 (columns) and zp_23/zp_25 (rows). Then averageGroupPosition ($E882) gives the member count and the average current and ordered positions, which are stored as group centre ($88EA/$88F6), group size ($8902) and the four box offsets ($890E left, $8926 right, $891A top, $8932 bottom). Returns C=1 for an empty group.",
      "inputs": "Y = group id, $8832, $F79E, $F802, $F866, $8864, $895D, $8895",
      "outputs": "$8955, $8956/$8957, $895A, $8902, $88EA/$88F6, $890E/$891A/$8926/$8932, zp_1D, zp_92/zp_93 = group centre, zp_1E/zp_1F = average destination; C=1 if empty",
      "confidence": "high"
    },
    {
      "addr": "E5FF",
      "name": "clampGroupTargetToMap",
      "summary": "Clamps the group target in zp_22/zp_24 so that the whole formation box ($890E/$8926 columns, $891A/$8932 rows) still fits inside the 40x40 map, stores the clamped target as the group's target cell ($88D2/$88DE) and returns the movement delta relative to the group's average ordered position (zp_1E/zp_1F) in zp_2A (columns, also copied to $8958) and zp_2B (rows). Also called by the trainer setup overlay at $EF00.",
      "inputs": "zp_22/zp_24 target, zp_1E/zp_1F average destination, $8955, box offsets",
      "outputs": "zp_22/zp_24 clamped, zp_2A/zp_2B delta, $8958, $88D2/$88DE",
      "confidence": "high"
    },
    {
      "addr": "E66C",
      "name": "offsetPointInDirection",
      "summary": "Computes the cell that lies Y steps in compass direction X from the point (zp_27 column, zp_28 row), using directionColDeltaTable/directionRowDeltaTable ($E68A/$E693) and addStepsClamped ($EA1B). Returns C=1 when the column leaves the map (the row is then not computed); otherwise zp_18 = column, zp_19 = row. Used by the path search ($E44D), by evaluateCellSafety ($E69C) and by the drone target scan ($EF9F). Self-modifies the LDX operand at $E67D.",
      "inputs": "X = direction 0..7 (0 = north, clockwise), Y = number of steps, zp_27/zp_28 = origin",
      "outputs": "zp_18/zp_19 = target cell, C=1 if off the map",
      "confidence": "high"
    }
  ],
  "variables": [
    {"addr": "18", "scope": "zp", "name": "zpTemp18", "meaning": "general scratch; in the group code it holds the average row / a saved column, in offsetPointInDirection the resulting column", "confidence": "high"},
    {"addr": "19", "scope": "zp", "name": "zpTemp19", "meaning": "general scratch; resulting row of offsetPointInDirection, base direction for buildDirectionPreference", "confidence": "high"},
    {"addr": "1A", "scope": "zp", "name": "zpThreatBalanceLo", "meaning": "16-bit signed accumulator: enemy weight minus own weight inside the candidate box (evaluateCellSafety); also a 16-bit sum in averageGroupPosition", "confidence": "medium"},
    {"addr": "1B", "scope": "zp", "name": "zpThreatBalanceHi", "meaning": "high byte of zpThreatBalanceLo", "confidence": "medium"},
    {"addr": "1C", "scope": "zp", "name": "zpGroupMemberCount", "meaning": "number of live members found by averageGroupPosition ($E882)", "confidence": "high"},
    {"addr": "1D", "scope": "zp", "name": "zpUnpenetratedCount", "meaning": "number of group members that have not yet scored both territory zones; non-zero makes the group fall back to its own half", "confidence": "medium"},
    {"addr": "1E", "scope": "zp", "name": "zpGroupAvgDestCol", "meaning": "average ordered destination column of the group (from averageGroupPosition)", "confidence": "high"},
    {"addr": "1F", "scope": "zp", "name": "zpGroupAvgDestRow", "meaning": "average ordered destination row of the group", "confidence": "high"},
    {"addr": "22", "scope": "zp", "name": "zpTargetCol", "meaning": "multi-use scratch: minimum destination column while scanning a group, then the chosen target column, then the direction-preference rank in the path search and the unit loop index in issueTrainerGroupOrders", "confidence": "high"},
    {"addr": "23", "scope": "zp", "name": "zpSearchDistance", "meaning": "minimum destination row while scanning a group; distance accepted so far in the path search", "confidence": "high"},
    {"addr": "24", "scope": "zp", "name": "zpTargetRow", "meaning": "maximum destination column while scanning a group; the chosen target row afterwards", "confidence": "high"},
    {"addr": "25", "scope": "zp", "name": "zpGroupMaxDestRow", "meaning": "maximum ordered destination row of the group", "confidence": "high"},
    {"addr": "26", "scope": "zp", "name": "zpChosenDirection", "meaning": "compass direction 0..7 selected by the path search", "confidence": "high"},
    {"addr": "27", "scope": "zp", "name": "zpSearchOriginCol", "meaning": "column the direction search starts from (group centre or AI comcen)", "confidence": "high"},
    {"addr": "28", "scope": "zp", "name": "zpSearchOriginRow", "meaning": "row the direction search starts from", "confidence": "high"},
    {"addr": "29", "scope": "zp", "name": "zpReplanDelay", "meaning": "number of game-clock units before the group is reconsidered (minimum 4, capped by trainerReplanUrgency)", "confidence": "high"},
    {"addr": "2A", "scope": "zp", "name": "zpGroupDeltaCol", "meaning": "column offset added to every group member's ordered destination", "confidence": "high"},
    {"addr": "2B", "scope": "zp", "name": "zpGroupDeltaRow", "meaning": "row offset added to every group member's ordered destination", "confidence": "high"},
    {"addr": "92", "scope": "zp", "name": "lineCurrentRow", "meaning": "line-stepper current row (initLineStepper); the trainer preloads it with the group centre row", "confidence": "high"},
    {"addr": "93", "scope": "zp", "name": "lineCurrentCol", "meaning": "line-stepper current column; preloaded with the group centre column", "confidence": "high"},
    {"addr": "94", "scope": "zp", "name": "lineTargetRow", "meaning": "line-stepper target row", "confidence": "high"},
    {"addr": "95", "scope": "zp", "name": "lineTargetCol", "meaning": "line-stepper target column", "confidence": "high"},
    {"addr": "B1", "scope": "zp", "name": "gamePhase", "meaning": "non-zero during the setup/deployment phases; the trainer AI is completely idle then", "confidence": "high"},
    {"addr": "BE", "scope": "zp", "name": "pauseState", "meaning": "non-zero while the game is paused; suppresses the trainer's turn", "confidence": "high"},
    {"addr": "E011", "scope": "abs", "name": "modemAnswerOriginateFlag", "meaning": "0 = answer, 1 = originate, written by waitForAnswerOriginateKey ($0CDE); unused by the trainer build", "confidence": "high"},
    {"addr": "E013", "scope": "abs", "name": "keyboardScanVector", "meaning": "operand of the JMP at $E012; patched to scanKeyboard ($0DB7) by initCommModule", "confidence": "high"},
    {"addr": "E01D", "scope": "abs", "name": "packetExchangeStatus", "meaning": "bit 7 = busy, bit 6 = exchange complete. The trainer always writes $C0 so the game never blocks. Note the initial value $00 doubles as a BRK opcode in the game's BRK routing", "confidence": "high"},
    {"addr": "E01E", "scope": "abs", "name": "receivedPacketLength", "meaning": "number of valid bytes in receivedPacketBuffer; the trainer fills it from its own order queue", "confidence": "high"},
    {"addr": "E01F", "scope": "abs", "name": "outgoingPacketLength", "meaning": "length of the local player's packet, set by sendOutgoingPacket; cleared by request X=3 and by closing the link", "confidence": "high"},
    {"addr": "E020", "scope": "abs", "name": "receivedPacketBuffer", "meaning": "7-byte buffer $E020-$E026 holding the opponent's command bytes; in the trainer build it is written directly by the AI ($EAA6, $EDCC, $EE32, $EE99, ...)", "confidence": "high"},
    {"addr": "E027", "scope": "abs", "name": "outgoingPacketBuffer", "meaning": "7-byte buffer $E027-$E02D holding the local player's command bytes (filled by sendOutgoingPacket $568C)", "confidence": "high"},
    {"addr": "E030", "scope": "abs", "name": "commBuildId", "meaning": "$FF in this build; the game copies it to linkTypeFlag $0BA5 and tests bit 7 to decide whether the resident comm module is build 1 (track 35 = solo trainer) or build 2 (track 34 = modem)", "confidence": "high"},
    {"addr": "E031", "scope": "abs", "name": "commNmiVector", "meaning": "$E031/$E032 = the module's NMI vector, initially $E010 (an RTI); rewritten to irqRti ($1298) by initInterruptVectors", "confidence": "high"},
    {"addr": "E033", "scope": "abs", "name": "cia2IcrMask", "meaning": "$7F - the CIA2 interrupt mask that readFilmByte/copyPageUnderIo restore after banking the I/O area out", "confidence": "high"},
    {"addr": "E039", "scope": "abs", "name": "commSuspendRequest", "meaning": "$C0 = the game asks the module to pause (before disk access), 0 = resume; written by suspendCommModule/resumeCommModule", "confidence": "high"},
    {"addr": "E03A", "scope": "abs", "name": "commSuspendAck", "meaning": "acknowledge byte polled by the game; the trainer copies commSuspendRequest into it every frame so the handshake completes instantly", "confidence": "high"},
    {"addr": "E03B", "scope": "abs", "name": "isLinkActive", "meaning": "$FF while the (pretend) link is up; tested all over the game before touching the comm module", "confidence": "high"},
    {"addr": "E03C", "scope": "abs", "name": "commCarrierStatus", "meaning": "$FF while the link is up; bit 6 is the carrier test in pollMenuSession, bit 7 the hang-up test", "confidence": "high"},
    {"addr": "E03D", "scope": "abs", "name": "commLinkReady", "meaning": "$FF while the link is up; read at $1BAB when a session starts", "confidence": "high"},
    {"addr": "E2BC", "scope": "abs", "name": "trainerUnitScanIndex", "meaning": "loop counter of scanTrainerUnitsForOrders, stored as a data byte inside the code stream at $E2BC", "confidence": "high"},
    {"addr": "8800", "scope": "abs", "name": "trainerUnitReplanClock", "meaning": "50-byte table $8800-$8831 indexed by AI unit ordinal (absolute unit - 50): the value of the down-counting game clock $91CB at which the AI may reconsider that unit. $8831 is the AI comcen's own entry", "confidence": "high"},
    {"addr": "8832", "scope": "abs", "name": "trainerUnitGroup", "meaning": "50-byte table $8832-$8863 indexed by AI unit ordinal: the AI's internal group id ($FF = dead/none, 0 = the comcen's group)", "confidence": "high"},
    {"addr": "8864", "scope": "abs", "name": "trainerUnitPenetrated", "meaning": "50-byte table $8864-$8895 indexed by AI unit ordinal; bit 7 set by trainerUnitStepHook once the unit has scored both territory zones (unitTypeTable bits 3 and 4). $8895 is the comcen entry", "confidence": "medium"},
    {"addr": "8896", "scope": "abs", "name": "trainerCommandStage", "meaning": "$8896-$8899 stage one command before it is appended to the queue (command byte, unit, column, row); the same 8 bytes $8896-$889D are reused as the direction-preference list built by buildDirectionPreference and as the directional threat histogram of scanEnemyThreat", "confidence": "high"},
    {"addr": "88A0", "scope": "abs", "name": "trainerCommandQueue", "meaning": "50-byte FIFO $88A0-$88D1 of command bytes the AI has generated; flushTrainerCommandQueue moves whole commands from here into receivedPacketBuffer, at most 7 bytes per exchange", "confidence": "high"},
    {"addr": "88D2", "scope": "abs", "name": "trainerGroupTargetCol", "meaning": "per-group target column (12 entries $88D2-$88DD)", "confidence": "high"},
    {"addr": "88DE", "scope": "abs", "name": "trainerGroupTargetRow", "meaning": "per-group target row (12 entries $88DE-$88E9)", "confidence": "high"},
    {"addr": "88EA", "scope": "abs", "name": "trainerGroupCentreCol", "meaning": "per-group average current column", "confidence": "high"},
    {"addr": "88F6", "scope": "abs", "name": "trainerGroupCentreRow", "meaning": "per-group average current row", "confidence": "high"},
    {"addr": "8902", "scope": "abs", "name": "trainerGroupSize", "meaning": "per-group live member count (0 = empty)", "confidence": "high"},
    {"addr": "890E", "scope": "abs", "name": "trainerGroupBoxLeft", "meaning": "per-group distance from the average destination to the leftmost member destination", "confidence": "high"},
    {"addr": "891A", "scope": "abs", "name": "trainerGroupBoxTop", "meaning": "per-group distance from the average destination to the topmost member destination", "confidence": "high"},
    {"addr": "8926", "scope": "abs", "name": "trainerGroupBoxRight", "meaning": "per-group distance from the average destination to the rightmost member destination, plus 1", "confidence": "high"},
    {"addr": "8932", "scope": "abs", "name": "trainerGroupBoxBottom", "meaning": "per-group distance from the average destination to the bottom member destination, plus 1", "confidence": "high"},
    {"addr": "893E", "scope": "abs", "name": "trainerGroupFlags", "meaning": "per-group flag byte; bit 0 mirrors the direction preference, bit 5 = large group in contact, bit 6 = group is committed to the attack, bit 7 = group has seen the enemy comcen. The low nibble survives, the high nibble is cleared when the group is re-planned", "confidence": "low"},
    {"addr": "894A", "scope": "abs", "name": "directionPreferenceOffsetTable", "meaning": "8 bytes read by buildDirectionPreference ($E7F4) as the compass offsets of the eight search ranks; no traced code ever writes it, and it overlaps the already-consumed side-1 game-type defaults table in the $8800 strings overlay, so its value is 02 02 02 01 01 02 02 02", "confidence": "medium"},
    {"addr": "8952", "scope": "abs", "name": "trainerCommandQueueLength", "meaning": "number of bytes currently in trainerCommandQueue", "confidence": "high"},
    {"addr": "8953", "scope": "abs", "name": "trainerLastGroupId", "meaning": "highest group id in use; incremented when a newly spotted unit gets its own group ($EB46)", "confidence": "medium"},
    {"addr": "8954", "scope": "abs", "name": "trainerGroupCount", "meaning": "number of AI groups (trainerLastGroupId + 1), set by the trainer setup overlay at $ECC6", "confidence": "high"},
    {"addr": "8955", "scope": "abs", "name": "trainerCurrentGroup", "meaning": "group id the planner is currently working on", "confidence": "high"},
    {"addr": "8956", "scope": "abs", "name": "trainerGroupValueLo", "meaning": "16-bit combat value of the current group ($8956 low, $8957 high), summed from unitCombatValueTable", "confidence": "high"},
    {"addr": "8958", "scope": "abs", "name": "trainerGroupDeltaCol", "meaning": "column delta from the group's average destination to the clamped new target (copy of zp_2A)", "confidence": "medium"},
    {"addr": "895A", "scope": "abs", "name": "trainerGroupHasSlowUnits", "meaning": "$FF when the current group contains a GRUNT (type 0) or BOOMER (type 2); doubles the replan delay", "confidence": "medium"},
    {"addr": "895B", "scope": "abs", "name": "trainerAdvanceDistance", "meaning": "how far the path search may advance this turn (5 for the comcen group, otherwise the distance class to the target)", "confidence": "high"},
    {"addr": "895D", "scope": "abs", "name": "trainerAssaultGroup", "meaning": "id of the group the AI is currently attacking with; $FF = none, bit 7 set = pending/invalid. Chosen by $E83C", "confidence": "medium"},
    {"addr": "895E", "scope": "abs", "name": "trainerAssaultCol", "meaning": "average column of the assault group (or the AI comcen column when there is none)", "confidence": "high"},
    {"addr": "895F", "scope": "abs", "name": "trainerAssaultRow", "meaning": "average row of the assault group", "confidence": "high"},
    {"addr": "8960", "scope": "abs", "name": "trainerBestDirection", "meaning": "compass direction 0..7 returned by scanEnemyThreat ($EBEF) as the least threatened / most promising heading", "confidence": "high"},
    {"addr": "8961", "scope": "abs", "name": "trainerEnemyProximity", "meaning": "largest 'closeness' value found by scanEnemyThreat; 0 means no enemy unit is near", "confidence": "medium"},
    {"addr": "8962", "scope": "abs", "name": "trainerThreatScore", "meaning": "threat score from scanEnemyThreat, compared with trainerRecallThreshold to decide whether the assault group must come home", "confidence": "medium"},
    {"addr": "8963", "scope": "abs", "name": "trainerDefendMode", "meaning": "0 = attack this round, $FF = defend (enemies close and either no assault group or the comcen was just hit)", "confidence": "high"},
    {"addr": "8964", "scope": "abs", "name": "trainerHomeGroup", "meaning": "id of the group that always guards the AI comcen; picked randomly (or $FF) by the trainer setup overlay at $ECA3", "confidence": "medium"},
    {"addr": "8965", "scope": "abs", "name": "trainerActionBudget", "meaning": "how much the AI may do this round: the AI comcen's energy (unitEnergy[99] AND $3F), or a flat 50 when the DAMAGE option is off; below 20 the AI only repairs", "confidence": "medium"},
    {"addr": "8966", "scope": "abs", "name": "trainerScratchByte", "meaning": "scratch: holds the current group's flag byte during decideTrainerGroupAction, and is used as a negative 'a report command was already queued' marker by the drone/missile code", "confidence": "medium"},
    {"addr": "8968", "scope": "abs", "name": "trainerOrdersThisRound", "meaning": "number of command bytes the AI has generated this round; the planner stops at 4-5 to keep the packets small", "confidence": "high"},
    {"addr": "8969", "scope": "abs", "name": "trainerRallyCol", "meaning": "column of the current objective, a random cell near the human comcen", "confidence": "high"},
    {"addr": "896A", "scope": "abs", "name": "trainerRallyRow", "meaning": "row of the current objective", "confidence": "high"},
    {"addr": "896B", "scope": "abs", "name": "trainerRallyCountdown", "meaning": "rounds left before a new rally point is chosen (reloaded with 5)", "confidence": "high"},
    {"addr": "896C", "scope": "abs", "name": "trainerEnemyComcenSeen", "meaning": "latched copy of unitEnergy[49] once its bit 7 (spotted) is set; scales the random spread of the rally point", "confidence": "medium"},
    {"addr": "896D", "scope": "abs", "name": "trainerReplanUrgency", "meaning": "upper bound (in game-clock units) on how long the current group may go without being reconsidered: 5 normally, 3 when in contact, 2 for the home group", "confidence": "medium"},
    {"addr": "896E", "scope": "abs", "name": "trainerAlertClock", "meaning": "the game clock at the last defensive alert. Because the clock counts down, every later comparison $896E >= $91CB is true, so in practice this is a latch meaning 'the AI has been forced onto the defensive at least once'", "confidence": "medium"},
    {"addr": "8971", "scope": "abs", "name": "trainerComcenDamage", "meaning": "low nibble of unitStunTable[99] ($F9C3) = the AI comcen's damaged-systems count; $0F stops the AI completely for that round, 1-7 costs 16 budget points", "confidence": "medium"},
    {"addr": "8976", "scope": "abs", "name": "trainerDroneTarget", "meaning": "AI unit ordinal the drone is flying at; $FF = no drone mission", "confidence": "medium"},
    {"addr": "8977", "scope": "abs", "name": "trainerDroneTimer", "meaning": "drone state: bit 7 set means the track 29 s4-5 drone-flight sub-overlay is resident at $EE00; positive values are a countdown to the next drone action, 0 allows a launch", "confidence": "medium"},
    {"addr": "897A", "scope": "abs", "name": "trainerRecallThreshold", "meaning": "threat score above which the assault group is recalled to defend the comcen (initialised to 5 at $EC44)", "confidence": "medium"},
    {"addr": "897B", "scope": "abs", "name": "trainerComcenHit", "meaning": "$FF when unitLocalFlags[99] bit 1 showed a hit on the AI comcen during this pass", "confidence": "medium"},
    {"addr": "897C", "scope": "abs", "name": "trainerComcenHitRecent", "meaning": "sticky version of trainerComcenHit (this pass or the previous one); forces defend mode and makes the path search cautious", "confidence": "medium"},
    {"addr": "90FA", "scope": "abs", "name": "currentUnit", "meaning": "unit index processUnitStep is working on, read by trainerUnitStepHook", "confidence": "high"},
    {"addr": "91CB", "scope": "abs", "name": "gameClock", "meaning": "rounds remaining, counting down; the AI uses it as the time base for all its replan deadlines", "confidence": "high"},
    {"addr": "920D", "scope": "abs", "name": "droneStateFlags", "meaning": "bit 0 = a drone is in the air; buildTrainerReplyPacket uses it to swap the $EE00 sub-overlay in and out", "confidence": "high"},
    {"addr": "0B9B", "scope": "abs", "name": "filmPlaybackFlag", "meaning": "bit 7 set during WATCH GAME FILM; the trainer AI is disabled then", "confidence": "high"},
    {"addr": "0BA3", "scope": "abs", "name": "gameTypeOptions", "meaning": "bits 0-2 game type, bit 4 = DAMAGE option; without DAMAGE the AI ignores its comcen's energy", "confidence": "high"},
    {"addr": "0BA8", "scope": "abs", "name": "gameEndReason", "meaning": "non-zero once the game has ended; stops the AI", "confidence": "high"},
    {"addr": "F671", "scope": "abs", "name": "humanComcenCol", "meaning": "unitCol[49] = the human player's comcen column (unit 49 is side 0's comcen)", "confidence": "high"},
    {"addr": "F6D5", "scope": "abs", "name": "humanComcenRow", "meaning": "unitRow[49] = the human player's comcen row", "confidence": "high"},
    {"addr": "F6A3", "scope": "abs", "name": "trainerComcenCol", "meaning": "unitCol[99] = the AI comcen's column (unit 99 is side 1's comcen)", "confidence": "high"},
    {"addr": "F707", "scope": "abs", "name": "trainerComcenRow", "meaning": "unitRow[99] = the AI comcen's row", "confidence": "high"},
    {"addr": "F79E", "scope": "abs", "name": "trainerUnitType", "meaning": "unitTypeTable + 50, indexed by AI unit ordinal; bit 7 = dead, bits 0-2 = type", "confidence": "high"},
    {"addr": "F802", "scope": "abs", "name": "trainerUnitDestCol", "meaning": "unitDestCol + 50 (bits 0-5), indexed by AI unit ordinal", "confidence": "high"},
    {"addr": "F866", "scope": "abs", "name": "trainerUnitDestRow", "meaning": "unitDestRow + 50 (bits 0-5), indexed by AI unit ordinal", "confidence": "high"},
    {"addr": "F833", "scope": "abs", "name": "trainerComcenDestCol", "meaning": "unitDestCol[99], the AI comcen's ordered destination column", "confidence": "high"},
    {"addr": "F897", "scope": "abs", "name": "trainerComcenDestRow", "meaning": "unitDestRow[99]", "confidence": "high"},
    {"addr": "F73A", "scope": "abs", "name": "trainerUnitGroupByte", "meaning": "unitGroupByte + 50: the real in-game group bits of the AI's units (bit 6 = in a group, bits 2-5 = group id)", "confidence": "high"},
    {"addr": "F9C3", "scope": "abs", "name": "trainerComcenStatus", "meaning": "unitStunTable[99]; low nibble = damaged comcen systems", "confidence": "medium"},
    {"addr": "F9F5", "scope": "abs", "name": "humanComcenEnergy", "meaning": "unitEnergy[49]; bits 0-5 energy, bit 7 = spotted. The AI tests bit 7 to know whether it has located the human comcen", "confidence": "medium"},
    {"addr": "FA27", "scope": "abs", "name": "trainerComcenEnergy", "meaning": "unitEnergy[99]; bits 0-5 are the AI comcen's energy, used as the AI's per-round action budget", "confidence": "medium"},
    {"addr": "FBB7", "scope": "abs", "name": "trainerComcenLocalFlags", "meaning": "unitLocalFlags[99]; bit 1 is set by recordUnitHit when the comcen takes a hit", "confidence": "medium"}
  ],
  "dataBlocks": [
    {"addr": "E00F", "length": 2, "type": "byteTable", "name": "commRtsRtiStubs", "description": "$60 (RTS) and $40 (RTI). $E010 is the module's default NMI handler, which is what the initial value $E010 of commNmiVector ($E031/$E032) points at."},
    {"addr": "E018", "length": 5, "type": "unknown", "name": "unusedJumpSlotE018", "description": "Five $00 bytes. The modem build (T34) has a sixth jump-table entry here; in the trainer build nothing calls $E018 and executing it would BRK."},
    {"addr": "E01D", "length": 38, "type": "byteTable", "name": "commInterfaceBlock", "description": "$E01D-$E042: the fixed interface block shared by both comm-module builds - packetExchangeStatus, receivedPacketLength/Buffer, outgoingPacketLength/Buffer, commBuildId, commNmiVector, cia2IcrMask, the suspend handshake and the link flags. See the variables list for the individual bytes; $E034-$E038 and $E03E-$E042 are only used by the modem build."},
    {"addr": "E142", "length": 8, "type": "byteTable", "name": "randomCoordinateOffsetTable", "description": "$01,$FF,$01,$01,$FF,$FF,$02,$FE - signed cell offsets picked with nextGameRandom AND 7 by randomiseCoordinate; scaled by 1, 2 or 4 depending on whether the human comcen has been spotted."},
    {"addr": "E2B7", "length": 5, "type": "byteTable", "name": "unitCombatValueTable", "description": "$02,$02,$03,$00,$00 - combat weight per unit type (0 GRUNT 2, 1 RIDER 2, 2 BOOMER 3, 3 SPY 0, 4 COMCEN 0). Summed into trainerGroupValue by computeTrainerGroupStats and copied into the per-unit weight page $0464 by buildThreatWeights ($E7AF)."},
    {"addr": "E2BC", "length": 1, "type": "byteTable", "name": "trainerUnitScanIndex", "description": "Loop counter of scanTrainerUnitsForOrders, kept as a byte inside the code stream (written at $E2BF/$E2EB, read at $E2CE)."},
    {"addr": "E664", "length": 8, "type": "byteTable", "name": "maxAdvanceByRankTable", "description": "$05,$05,$05,$03,$03,$01,$01,$01 - maximum number of cells the path search may advance for direction-preference rank 0..7: the three best headings may be followed up to 5 cells, the worst three only 1."}
  ],
  "misclassified": [
    {"addr": "E00F", "length": 2, "actual": "code", "evidence": "$E00F is $60 (RTS) and $E010 is $40 (RTI); commNmiVector ($E031/$E032) is initialised to $E010, and the modem build reaches it with 'jmp (D_E031)' at $E682, so $E010 is executable NMI code rather than data."}
  ],
  "insights": [
    "Comm-module build 1 (track 35, selected when linkTypeFlag $0BA5 bit 7 is set) is NOT a null-modem variant - it is the SOLO TRAINER's computer opponent. handleMainMenuChoice sets $0BA5 = $FF for 'PRACTICE WITH SOLO TRAINER' and $0BA5 = 0 for 'COMPETE WITH MODEM OPPONENT' (build 2 = track 34). This corrects the guess recorded in knowledgeBase.md line 787.",
    "The trainer fakes the whole link: $E006 always answers 'no byte', $E009 discards the byte, $E00C does nothing, $E003 sets/clears $E03B/$E03C/$E03D, and every $E000 call other than X=1/X=3 stores $C0 into packetExchangeStatus and mirrors $E039 into $E03A. Consequently processCommandExchange never waits, the suspend/resume handshake around disk access always succeeds instantly, and chat is dead in solo mode.",
    "The AI is driven entirely from three game hooks: $E000 with X=1 (once per lock-step exchange, from sendOutgoingPacket) builds the 'received' packet; sub_E079 (from finishRound $4654) runs the once-per-round planner; sub_E0E8 (from processUnitStep $4526) is the per-unit observer. The AI's orders are ordinary command bytes ($80/$81 move, $8D/$8F/$92/$97 drone, $90 missile, $9A comcen energy, $9E comcen repair) placed straight into receivedPacketBuffer $E020 - the rest of the game cannot tell them from a modem opponent's.",
    "AI work RAM lives in the $8800 message-strings overlay, which has been consumed by the time the battle starts: $8800-$8831 replan clock per AI unit ordinal, $8832-$8863 AI group id, $8864-$8895 'has scored both zones' flags, $8896-$889F command staging / 8-way direction scratch, $88A0-$88D1 the 50-byte outgoing order queue, then twelve-entry per-group arrays at $88D2 (target col), $88DE (target row), $88EA/$88F6 (centre), $8902 (size), $890E/$891A/$8926/$8932 (formation box) and $893E (flags), and the scalar state $8952-$897C. The AI addresses its own units by ordinal 0-49 = absolute unit 50-99; queueTrainerMoveOrder ($E919) adds 50 and mirrors both coordinates with mirrorMapCoordinate so the command arrives in the same form a real opponent would send.",
    "Planner outline (sub_E170, once per round): budget = AI comcen energy ($FA27 AND $3F, or a flat 50 when the DAMAGE option $0BA3 bit 4 is off); a fully damaged comcen ($F9C3 AND $0F == $0F) skips the round entirely; a budget below 20 only issues repair ($9E) and energy ($9A) commands via $EB67. Otherwise scanEnemyThreat ($EBEF) around the AI comcen yields a best heading, an enemy-proximity value and a threat score, which decide attack ($8963 = 0, and consider launching a drone) versus defend ($8963 = $FF, comcen retreats and the assault group is recalled). Each group is then re-planned when its replan clock catches the down-counting game clock $91CB, at most four or five command bytes per round.",
    "Group movement uses a threat-aware search: buildThreatWeights ($E773) writes a per-unit reach into page $0400 and a per-unit combat weight into $0464 (both scratch pages the game also clears in finishRound); evaluateCellSafety ($E69C) expands the group's formation box around a candidate cell and accepts it only while (enemy weight - own weight) inside that box stays below the group's own combat value; findTrainerAdvanceCell ($E42A) walks outward in steps of two cells along the direction ranks in $8896, limited by maxAdvanceByRankTable ($E664); issueTrainerGroupOrders ($E49E) then moves every member by the same delta so the formation is preserved.",
    "The $EE00-$EFFF quarter of the module is hot-swapped at run time: buildTrainerReplyPacket loads track 29 sectors 4-5 there (the drone-flight AI, disassembled as game/ovl_EE00_T29) as soon as droneStateFlags $920D bit 0 goes up, and reloads track 35 sectors 14-15 (the native tail) when the drone is gone. $8977 bit 7 records which one is resident. Separately, $EC00-$EFFF holds three different overlays over the life of a trainer game: overlay A loads track 29 s0-3 ($EC0F, which initialises all the $88xx AI tables) during startGameSession, and main_0800 at $0B4F reloads track 35 s12-15 immediately afterwards so the AI's own tail is back in place before the battle starts.",
    "Possible original bug: buildDirectionPreference ($E7E3) reads its eight compass offsets from $894A, but no traced code ever writes that table - it still holds the already-consumed side-1 game-type defaults from the $8800 strings overlay, i.e. 02 02 02 01 01 02 02 02. All eight 'preference ranks' therefore resolve to base+2 or base+1, so the trainer's direction search effectively tries only two headings instead of spiralling out from the intended one."
  ]
}
