modemwars/survey/game_main_new_4987.json
2026-08-23 02:09:40 -05:00

368 lines
25 KiB
JSON
Generated

{
"chunk": "game_main_new_4987",
"unit": "game/main_0800",
"routines": [
{
"addr": "4987",
"name": "averageGroupMoveVector",
"summary": "Averages the outstanding movement of one group. Over all 100 units it sums (unitDestCol & $7F) - unitCol and (unitDestRow & $7F) - unitRow as two sign-extended 16-bit totals, counting only live units (unitTypeTable bit 7 clear) whose authoritative unitFlagsTable ($F708) has the member bit 6 set and whose group key (bits 2-5) equals cmdParam2, then divides both totals by the member count with divideSigned16by8 ($FCEC). Sole helper of followGroupMovement, so it runs when a unit joins a group and has to inherit the group's current heading.",
"inputs": "zp_75 = group key (F708 & $3C: group number in bits 2-4, side in bit 5); unit arrays $F640 col, $F6A4 row, $F708 flags, $F76C type, $F7D0/$F834 destination",
"outputs": "A = member count (Z=1 when the group is empty), X = average column delta, Y = average row delta; zp_18-zp_1C destroyed (zp_18/zp_19 also by the divide); self-modifies the sign-extension ADC operands at $49C6 and $49E3",
"confidence": "high"
},
{
"addr": "4A02",
"name": "followGroupMovement",
"summary": "Makes unit Y move with the group it is joining: averageGroupMoveVector supplies the group's average movement vector and the unit's new destination becomes its own position plus that vector, applied through the shared destination setter sub_34D5 (which clamps to 0-39 and sets the at-destination bit). If the group has no members the unit is left untouched. Called only from joinUnitToGroup (command $88).",
"inputs": "Y = unit index, zp_75 = group key, $F640/$F6A4 unit position",
"outputs": "zp_74/zp_75 = new destination column/row, unitDestCol/unitDestRow of the unit written by sub_34D5, unitDisplayFlags bit 7 refreshed there; zp_18/zp_19 = average dx/dy, zp_1D = saved unit index",
"confidence": "high"
},
{
"addr": "4A35",
"name": "sendGroupMoveToCursor",
"summary": "Fire-button action of the battlefield target cursor when a whole group is selected (runTargetCursorLoop $2218, taken when uiLevel D_9209 >= 2): queues command $81 with the selected unit as the group leader and the cursor cell (viewOriginCol+3, viewOriginRow+2) as the ordered position, then refreshes the leader's local at-destination flag. Unlike the single-unit order $347F ($80) it deliberately leaves the leader's member bit in $FB54 alone, so the unit stays in its group.",
"inputs": "D_90F8 selectedUnit, zp_A5/zp_A6 view origin",
"outputs": "zp_73/zp_74/zp_75 = unit, column, row; 4 bytes queued in the outgoing ring by queueCmd4 ($560A); $FB54[unit] bit 7 updated by updateUnitAtDestinationFlag; C=0 always",
"confidence": "high"
},
{
"addr": "4A52",
"name": "moveGroupKeepingFormation",
"summary": "Executor of command $81, run in lock step on both machines (tail of cmdMoveGroupRelative $501A). It computes the offset between the leader's current destination and the ordered cell and subtracts that offset from the destination of every live member of the leader's group, so the leader ends up exactly on the ordered cell and the relative formation is preserved. Membership comes from the authoritative unitFlagsTable: (F708 & $7C) == (leader's F708 & $3C) | $40.",
"inputs": "zp_73 = leader unit index, zp_74/zp_75 = ordered column/row (already mirrored for remote commands by mirrorCmdCoordsIfRemote $4FE4)",
"outputs": "unitDestCol/unitDestRow of every member set through sub_34D5 (clamped to the map, at-destination bit maintained); zp_73 overwritten with the group key, zp_4A = 99..0 loop counter; self-modifies the SBC operands at $4A8E (dx) and $4A98 (dy)",
"confidence": "high"
},
{
"addr": "4AA3",
"name": "addSelectedUnitToGroup",
"summary": "'ADDED' half of the membership editor, called from the JOIN GROUP menu ($61C6) and from the group membership editor ($6483). isPointWithinGroupRange rejects a unit that is more than 8 cells from the group's current bounding box: showTooFarMessage is displayed and C=1 is returned. Otherwise the local flag copy is updated at once for instant feedback (unitDisplayFlags = (old & $83) | groupKey | $40) and command $88 is queued so both machines perform the join.",
"inputs": "D_9206 selectedGroupId (group number bits 2-4 plus side bit 5), D_90F8 selectedUnit",
"outputs": "C=0 accepted: $FB54[unit] updated, zp_74/zp_75 = unit/group and 3 bytes ($88, unit, group) queued by queueCmd3 ($560F); C=1 rejected with the TOO FAR! message and sound 3",
"confidence": "high"
},
{
"addr": "4AD4",
"name": "joinUnitToGroup",
"summary": "Executor of command $88 (tail of $5185). It first gives the unit the group's current movement through followGroupMovement, then writes the group key and the member bit into the authoritative unitFlagsTable ($F708 = (old & $03) | groupKey | $40) and mirrors the new bits into unitDisplayFlags with syncLocalGroupFlags. Because only the two facing bits survive the mask, joining a group also clears the unit's pending first-strike bit 7 and takes the side bit from the command parameter.",
"inputs": "zp_74 = unit index, zp_75 = group key (both from readCmdArgs2)",
"outputs": "$F708 and $FB54 of the unit updated, destination set to the group's average movement; self-modifies the LDX operand at $4AE2 (unit index) and the ORA operand at $4AE9 (group key)",
"confidence": "high"
},
{
"addr": "4AF2",
"name": "removeSelectedUnitFromGroup",
"summary": "'REMOVED' half of the membership editor (called from $64B1). Clears the member bit 6 in the selected unit's local flag copy unitDisplayFlags straight away so the viewport highlight updates immediately, and queues the 2-byte command $89, whose executor cmdLeaveGroup ($519B) clears bit 6 in the authoritative unitFlagsTable on both machines and re-syncs $FB54.",
"inputs": "D_90F8 selectedUnit",
"outputs": "$FB54[unit] &= $BF, zp_73 = unit, 2 bytes queued by queueCmd2 ($561F)",
"confidence": "high"
},
{
"addr": "4B04",
"name": "isPointWithinGroupRange",
"summary": "Range test that keeps a group from spreading over more than 8 cells. It builds the group's bounding box with computeGroupBounds and then requires the tested point to be less than 9 cells away from each of the four box edges (which also bounds the box after the point is added). Y < $80 tests unit Y's map position against the members' positions (used when a unit joins); Y = $80 tests the picked order cell D_91D8/D_91D9 against the members' destinations (used when a group member is re-positioned). An empty group always passes.",
"inputs": "A = group key, Y = unit index or $80; D_91D8/D_91D9 when Y = $80",
"outputs": "C=0 within range, C=1 too far; zp_18-zp_1D clobbered (box and mode); self-modifies the LDY operand at $4B0F",
"confidence": "high"
},
{
"addr": "4B5E",
"name": "computeGroupBounds",
"summary": "Scans all 100 units and accumulates the member count and the bounding box of one group, using the local flag copy unitDisplayFlags for the membership test ((FB54 & $7C) == key | $40) and skipping dead units. zp_1D bit 7 (the Y register on entry) selects whether the box is built from the members' current positions ($F640/$F6A4) or from their destinations ($F7D0/$F834 & $7F). Used by isPointWithinGroupRange and by buildGroupFormationSprite ($30C5), which draws the group's formation ghost from the destination box.",
"inputs": "A = group key, Y = mode (bit 7 set = use destinations); $F76C, $FB54, $F640/$F6A4 or $F7D0/$F834",
"outputs": "A = zp_1A = member count, X = zp_18 = min column, Y = zp_19 = min row, zp_1B = max column, zp_1C = max row (min stays $FF and max 0 for an empty group); self-modifies the CMP operand at $4B80",
"confidence": "high"
},
{
"addr": "4BBF",
"name": "showTooFarMessage",
"summary": "Error feedback shared by the two group range checks: selects and clears the 14-column info panel window (columns 25-38, rows 12-21), plays sound 3 (the error beep) and prints 'TOO FAR!' ($CB31 in the $C000 overlay) on window row $10 in whatever colour the caller left in zp_3D.",
"inputs": "zp_3D text colour, zp_3E fill character",
"outputs": "info-panel window selected and cleared, sound 3 queued, message printed (tail call to printString)",
"confidence": "high"
},
{
"addr": "4BD3",
"name": "orderGroupNewPosition",
"summary": "NEW POSITION action of the custom-formation editor (called from $64AB). It highlights just the selected unit (highlightedUnit = unit, highlightedGroupId = $FF), redraws the viewport and runs the target cursor loop, which in this mode (cursorTargetMode < 0) only reports the picked cell in D_91D8/D_91D9. If the pick was not aborted and lies within 8 cells of the group's destination box it queues command $98 unit,col,row, refreshes the local at-destination flag, counts the order in the pending-order pair D_929D/D_929E and prints 'RE-POSITIONED' centred in colour 4; otherwise it shows TOO FAR! and re-centres the viewport. It ends by restoring the editor's cursor state: cursorTargetMode = $80 (the loop zeroes it on an abort) plus the square cursor sprite 0 and the centre box sprite 2.",
"inputs": "D_90F8 selectedUnit, D_9206 selectedGroupId, D_91D8/D_91D9 picked cell, checkUiContinue ($C451)",
"outputs": "command $98 queued (queueCmd4) or a message shown; D_9204/D_9205, D_929D/D_929E, zp_BB = $80, zp_3D/zp_42 = 4; self-modifies the string pointer operands at $4C46/$4C48",
"confidence": "high"
},
{
"addr": "4C59",
"name": "setGroupMemberDestination",
"summary": "Executor of command $98 (tail of cmdMoveKeepGroup $5470). If the unit's local group key matches the group remembered in D_929D it decrements the pending-order counter D_929E (this is the acknowledgement the formation editor waits for), then sets the unit's destination to the ordered cell through sub_34D5. Unlike command $80 it never touches the group bits, so the re-positioned unit stays a member.",
"inputs": "zp_73 = unit index, zp_74/zp_75 = column/row (mirrored for remote commands), D_929D",
"outputs": "unitDestCol/unitDestRow updated via sub_34D5, D_929E decremented when the group matches",
"confidence": "high"
},
{
"addr": "4C6B",
"name": "syncLocalGroupFlags",
"summary": "Copies the authoritative group bits of unit X (unitFlagsTable & $7C = group number, side bit and member bit) into the local flag copy unitDisplayFlags, keeping the latter's bits 0, 1 and 7 (its own per-unit UI flags and the at-destination bit). Called after every command that changes group membership ($34D0 in the move executor, joinUnitToGroup, cmdLeaveGroup $51A8) and when a unit is created/reset ($3DFA), so that the UI copy can never drift from the simulation.",
"inputs": "X = unit index",
"outputs": "$FB54[X] = (old & $83) | (F708[X] & $7C); self-modifies the ORA operand at $4C79",
"confidence": "high"
},
{
"addr": "4C84",
"name": "rotateGroupFormation",
"summary": "Executor of command $A3 (tail of cmdRotateFormation $5596), the SWEEP LEFT / SWEEP RIGHT / REVERSE items of the FORMATION menu. It rotates the whole formation about the leader's destination: for every live member the offset (dx,dy) from the leader's destination is transformed according to the mode using the two 3-byte rule sets at formationRotateRules (0: (dy,-dx) = 90 degrees counter-clockwise on screen, 1: (-dy,dx), 2: (-dx,-dy)), and the new destination leader+offset is applied with sub_34D5. During the setup phase (gamePhase != 0) the resulting row is clamped into the issuing player's half of the map: rows 20-39 for own commands, rows 0-19 for commands received from the opponent.",
"inputs": "zp_74 = leader unit index, zp_75 = rotation mode 0-2, zp_76 bit 7 = command came from the opponent, zp_B1 gamePhase",
"outputs": "unitDestCol/unitDestRow of every member of the leader's group updated; zp_74/zp_75 reused as the destination; ten self-modified immediates ($4CC4 loop index, $4CD0 group key, $4CDA/$4CFD leader column, $4CDC/$4CE5 dx rule, $4CEE/$4D04 leader row, $4CF1/$4CFA dy rule)",
"confidence": "high"
}
],
"variables": [
{
"addr": "18",
"scope": "zp",
"name": "tempByte18",
"meaning": "general scratch: low byte of the summed column delta in averageGroupMoveVector (then the average dx), min column of the box in computeGroupBounds",
"confidence": "high"
},
{
"addr": "19",
"scope": "zp",
"name": "tempByte19",
"meaning": "general scratch: high byte of the summed column delta (then the average dy in followGroupMovement), min row of the group box",
"confidence": "high"
},
{
"addr": "1A",
"scope": "zp",
"name": "tempByte1A",
"meaning": "general scratch: low byte of the summed row delta in averageGroupMoveVector, member count in computeGroupBounds/isPointWithinGroupRange",
"confidence": "high"
},
{
"addr": "1B",
"scope": "zp",
"name": "tempByte1B",
"meaning": "general scratch: high byte of the summed row delta, max column of the group box",
"confidence": "high"
},
{
"addr": "1C",
"scope": "zp",
"name": "tempByte1C",
"meaning": "general scratch: member count in averageGroupMoveVector (the divisor), max row of the group box",
"confidence": "high"
},
{
"addr": "1D",
"scope": "zp",
"name": "tempByte1D",
"meaning": "general scratch: bounds mode of computeGroupBounds (bit 7 set = use destinations instead of positions, taken from Y on entry); followGroupMovement parks the unit index here across the averaging call",
"confidence": "high"
},
{
"addr": "3D",
"scope": "zp",
"name": "textColour",
"meaning": "colour byte used by the $C000 text engine; set to 4 (together with zp_42) for the RE-POSITIONED message, left as the caller had it for TOO FAR!",
"confidence": "high"
},
{
"addr": "42",
"scope": "zp",
"name": "textPadColour",
"meaning": "colour the text engine installs in zp_3D while printing a centred/padded string (printStringCentred); set to 4 by orderGroupNewPosition",
"confidence": "medium"
},
{
"addr": "4A",
"scope": "zp",
"name": "tempByte4A",
"meaning": "unit loop counter (99..0) of moveGroupKeepingFormation; the other loops here use Y or a self-modified immediate instead",
"confidence": "high"
},
{
"addr": "73",
"scope": "zp",
"name": "cmdParam0",
"meaning": "first parameter of the command being queued or executed - the unit index for $80/$81/$98, the group key for $89/$9B",
"confidence": "high"
},
{
"addr": "74",
"scope": "zp",
"name": "cmdParam1",
"meaning": "second command parameter: map column for $81/$98, unit index for $88/$A3",
"confidence": "high"
},
{
"addr": "75",
"scope": "zp",
"name": "cmdParam2",
"meaning": "third command parameter: map row for $81/$98, group key for $88, rotation mode 0-2 for $A3",
"confidence": "high"
},
{
"addr": "76",
"scope": "zp",
"name": "cmdFromOpponent",
"meaning": "bit 7 set while a command received from the opponent is executed (its coordinates were mirrored with 39-v by $4FE4); rotateGroupFormation uses it to decide which half of the map the setup-phase clamp applies to",
"confidence": "high"
},
{
"addr": "A5",
"scope": "zp",
"name": "viewOriginCol",
"meaning": "map column of the left edge of the 7x5 battlefield view; the cursor cell is viewOriginCol+3",
"confidence": "high"
},
{
"addr": "A6",
"scope": "zp",
"name": "viewOriginRow",
"meaning": "map row of the top edge of the battlefield view; the cursor cell is viewOriginRow+2",
"confidence": "high"
},
{
"addr": "B1",
"scope": "zp",
"name": "gamePhase",
"meaning": "0 during the battle, non-zero during setup/side selection; rotateGroupFormation only clamps rows to the owner's half while it is non-zero, and sub_34D5 only applies its comcen special case when it is 0",
"confidence": "high"
},
{
"addr": "BB",
"scope": "zp",
"name": "cursorTargetMode",
"meaning": "mode of the target cursor loop: >0 attack/range cursor, 0 move order, <0 report the picked cell only. orderGroupNewPosition restores it to $80 on exit because the loop zeroes it when the pick is aborted",
"confidence": "high"
},
{
"addr": "90F8",
"scope": "abs",
"name": "selectedUnit",
"meaning": "unit index the player currently has selected (the group leader when a group is selected); $FF marks the repair-menu mode elsewhere",
"confidence": "high"
},
{
"addr": "9204",
"scope": "abs",
"name": "highlightedUnit",
"meaning": "unit drawn inverted in the viewport; orderGroupNewPosition sets it to the unit being re-positioned",
"confidence": "high"
},
{
"addr": "9205",
"scope": "abs",
"name": "highlightedGroupId",
"meaning": "group key whose members the viewport draws inverted, $FF = none; orderGroupNewPosition clears it so only the single unit is highlighted",
"confidence": "high"
},
{
"addr": "9206",
"scope": "abs",
"name": "selectedGroupId",
"meaning": "group key the group UI is working on: bits 2-4 group number 0-6 (ALPHA..GROVER), bit 5 side (set for unit indices >= 50). Every membership test in the game is (flags & $7C) == selectedGroupId | $40",
"confidence": "high"
},
{
"addr": "91D8",
"scope": "abs",
"name": "pickedCellX",
"meaning": "map column of the cell picked by the target cursor loop in report-only mode (cursorTargetMode < 0)",
"confidence": "high"
},
{
"addr": "91D9",
"scope": "abs",
"name": "pickedCellY",
"meaning": "map row of the picked cell",
"confidence": "high"
},
{
"addr": "929D",
"scope": "abs",
"name": "lastOrderedGroupBits",
"meaning": "group key of the most recent NEW POSITION order ($FF at game start); waitForGroupRepositionsDone ($65E7) only waits when the group it is about to edit matches this key",
"confidence": "high"
},
{
"addr": "929E",
"scope": "abs",
"name": "pendingGroupOrderCount",
"meaning": "number of $98 orders for lastOrderedGroupBits that have been queued locally but not yet executed by the lock-step exchange; incremented by orderGroupNewPosition and decremented by setGroupMemberDestination",
"confidence": "high"
},
{
"addr": "F640",
"scope": "abs",
"name": "unitCol",
"meaning": "map column of each of the 100 units (parallel arrays, stride $64 from $F640)",
"confidence": "high"
},
{
"addr": "F6A4",
"scope": "abs",
"name": "unitRow",
"meaning": "map row of each unit",
"confidence": "high"
},
{
"addr": "F708",
"scope": "abs",
"name": "unitFlagsTable",
"meaning": "authoritative per-unit flags: bits 0-1 facing, bits 2-4 group number, bit 5 side, bit 6 group member, bit 7 pending first-strike. Only command executors change the group bits, so both machines stay in step",
"confidence": "high"
},
{
"addr": "F76C",
"scope": "abs",
"name": "unitTypeTable",
"meaning": "unit type 0-4 with bit 7 set for a dead/unused unit; every loop in this chunk skips units with bit 7 set",
"confidence": "high"
},
{
"addr": "F7D0",
"scope": "abs",
"name": "unitDestCol",
"meaning": "destination column of each unit; bit 7 is the at-destination/stationary flag, so all readers mask with $7F",
"confidence": "high"
},
{
"addr": "F834",
"scope": "abs",
"name": "unitDestRow",
"meaning": "destination row of each unit (bit 7 used the same way)",
"confidence": "high"
},
{
"addr": "FB54",
"scope": "abs",
"name": "unitDisplayFlags",
"meaning": "local/UI copy of the group bits (2-6) that the menus change immediately for feedback, plus bit 7 = unit is at its destination and bits 0-1 other per-unit UI flags; re-synced from unitFlagsTable by syncLocalGroupFlags",
"confidence": "high"
}
],
"dataBlocks": [
{
"addr": "4C7E",
"length": 6,
"type": "byteTable",
"name": "formationRotateRules",
"description": "Two 3-byte rule sets indexed by the rotation mode of command $A3 ($4C7E transforms the member's dx, $4C81 its dy). Bit 7 of a rule = negate the component, bit 0 = store the result as the new row offset (zp_75) instead of the new column offset (zp_74); the code loads each rule into X so the LDX sets N for the negate test and uses the masked copy as the index of 'STA zp_74,X'. Values {$81,$01,$80} / {$00,$80,$81} give the three menu rotations: 0 SWEEP LEFT (dy,-dx), 1 SWEEP RIGHT (-dy,dx), 2 REVERSE (-dx,-dy)."
}
],
"misclassified": [],
"insights": [
"Group model (verified here and in runJoinGroupMenu $6174): a group key occupies bits 2-5 of the unit flag bytes - bits 2-4 are the group number 0-6 (ALPHA, BAKER, CHARLIE, DELTA, EASY, FOXTROT, GROVER, names at $C92C) and bit 5 is the side bit, which runJoinGroupMenu ORs in for unit indices >= $32 and which unitFlagsTable already carries. Bit 6 = 'is a member'. Every membership test in the game is (flags & $7C) == key | $40, so each side has its own seven groups and the two sides can never share one.",
"Two copies of the group bits exist by design: unitFlagsTable ($F708) is authoritative and is only changed by command executors that run identically on both machines, while unitDisplayFlags ($FB54) is a local copy the menus change immediately (ADDED / REMOVED appear before the command has travelled). syncLocalGroupFlags ($4C6B) copies $F708 & $7C back over $FB54 after every executor. Consequently the UI range checks in this chunk (computeGroupBounds, isPointWithinGroupRange) read $FB54, while the executors (averageGroupMoveVector, moveGroupKeepingFormation, rotateGroupFormation) read $F708.",
"Each group command is split into a UI half in this chunk that only queues bytes, and an executor half that is also in this chunk but is reached exclusively by JMP from the dispatch table $4D2A: $81 move group ($4A35 -> $4A52), $88 join ($4AA3 -> $4AD4), $89 leave ($4AF2 -> $519B), $98 re-position one member ($4BD3 -> $4C59), $A3 rotate formation ($65FF -> $4C84). The single-unit move $80 ($347F -> $34C6) is the odd one out: it clears the member bit, so ordering one unit of a group to a cell takes it out of the group, while $81 and $98 keep membership.",
"sub_34D5 (unnamed, entered four times from this chunk) is the shared 'set unit destination' tail: it snaps a comcen destination to its own cell in some setup cases, sets bit 7 of unitDestCol when the destination equals the position, clamps both coordinates to 0-39 with clampMapCoordOld ($355D) and copies the position into the low bits of the path position $F898/$F8FC. All the arithmetic in this chunk is therefore allowed to produce out-of-range offsets.",
"Pending-order handshake: orderGroupNewPosition remembers the group in lastOrderedGroupBits ($929D) and counts its queued $98 orders in pendingGroupOrderCount ($929E); setGroupMemberDestination decrements the counter when the command finally executes. waitForGroupRepositionsDone ($65E7), called by the GROUP and FORMATION screens, spins on that counter so the editor only ever reads destinations that both machines already agree on. This is the clearest example in the game of the UI waiting for the lock-step command stream.",
"rotateGroupFormation encodes the three rotations as data (formationRotateRules) rather than as code: each rule byte carries 'negate' in bit 7 and 'this component becomes the row offset' in bit 0, and the store is done with STA zp_74,X where X is the rule's bit 0. The rotation needs no coordinate mirroring for remote commands because the opponent's frame differs by a 180-degree rotation, which commutes with the 90-degree rotations applied to the offsets.",
"Setup-phase clamp in rotateGroupFormation ($4D09-$4D1F): while gamePhase != 0 a rotated destination row is forced into the issuing player's half - own commands (cmdFromOpponent bit 7 clear) are clamped up to row 20 and remote ones down to row 19. The existing block comment in the disassembly states this the wrong way round.",
"followGroupMovement contains a harmless coding slip: at $4A0F it does TYA / ORA zp_18 with Y holding the unit index, where the intent was clearly LDA zp_19 / ORA zp_18 (is the average vector zero?). Both branches compute position + (dx,dy) for a zero vector, so the result is identical for every unit except index 0, which stands still instead of following a purely vertical group movement.",
"The $88 handler at $5188-$5197 contains a dead register shuffle (74->73, 73->74, 75->73, 73->75) that leaves zp_74/zp_75 unchanged and only sets zp_73 = zp_75; it looks like a swap that was compiled away. joinUnitToGroup uses zp_74/zp_75, so the sequence has no effect.",
"joinUnitToGroup rebuilds unitFlagsTable as (old & $03) | key | $40, so besides the group bits it also clears bit 7 (the one-shot first-strike/energy-sacrifice flag consumed at $3910) and re-takes the side bit from the command parameter. A unit reset ($3DC3) instead preserves the group bits with AND #$5C, so group membership survives a unit being recycled.",
"Message strings used from here live in the $C000 text overlay: 'TOO FAR' $CB31, 'ADDED' $CB3B, 'REMOVED' $CB40, 'NOT IN GROUP' $CB8D, 'RE-POSITIONED' $CB99, 'NEW POSITION' $CBA6; the info panel is the 14-column window at columns 25-38, rows 12-21 (clearInfoPanel $261C) and printStringCentred ($C121) centres inside it.",
"isPointWithinGroupRange enforces the 8-cell rule with four absolute-difference tests against the box edges rather than a span test, which also caps the box after the new point is added; an empty group always passes, which is what makes the first ADDED to a fresh group work."
]
}