diff --git a/docs/plans/2026/09/21/101-bot-agent-api/00-prior-art.md b/docs/plans/2026/09/21/101-bot-agent-api/00-prior-art.md new file mode 100644 index 00000000000..c22acdf1589 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/00-prior-art.md @@ -0,0 +1,129 @@ +# 00 — Prior art + +> Part of [overview.md](overview.md). Depends on: none. Research only; informs every other slice. + +Facts as of 2026-09-21. Each entry: transport · timing · observation · actions · fairness → **lesson for us**. + +## BWAPI — StarCraft: Brood War (2009–) + +Source read at `bwapi/bwapi@d727fed`. + +| Aspect | How | +|---|---| +| Hook | DLL injected into the closed 1.16.1 binary; `CodePatch.cpp` patches the frame loop (`_nextFrameHook` → `GameImpl::update()`) and `QueueCommand` (`CommandFilter`) | +| Orders | Built as the game's own network command packets via `QueueGameCommand` → go through normal lockstep turns (hence *latency frames*: SP 2, LAN 5, B.net 14–24). `UnitImpl::issueCommand` → `canIssueCommand` legality check → `CommandOptimizer` → select + execute | +| Timing | `AIModule::onFrame` runs **inside** the frame → a slow bot stalls the game. The out-of-process client is also blocking: server writes a token to `\\.\pipe\bwapi_pipe_`, waits in `ReadFile` for the client's reply | +| Bridge | `BWAPIClient`: Windows shared memory (fixed `GameData` struct: `units[10000]`, `commands[20000]`, per-tile grids) + named pipe as sync token. Client must match `CLIENT_VERSION` exactly → ABI lock-in | +| Fog | `Flag::CompleteMapInformation` and `Flag::UserInput` default **off**; can only be enabled in `onStart`; **all flags auto-on for replays** | +| Referee | `TournamentModule` DLL vetoes actions via `onAction` (flags, pause, speed, GUI, frame skip) | +| Rules (AIIDE/CoG) | Lose if ≥1 frame > 10 s, ≥10 frames > 1 s, or ≥320 frames > 55 ms. Game cap ~86k frames. `bwapi-data/read` / `write` for learning between games. No APM cap | +| Headless | OpenBW (open reimplementation) + its BWAPI fork run headless on Linux | + +**Lessons:** fog-respecting and no-human-input by default, locked after start · a referee layer vetoes fairness settings · +don't copy the raw shared-memory struct with an exact version match — use a versioned schema · don't block the frame on the bot by default. + +## Pluto — the 2026 BW self-play bot + +- One neural network plays the whole game, trained by self-play RL (PPO, no imitation) — . +- **Won the 2026 IEEE CoG StarCraft AI competition, 4–1 over PurpleWave in the final** — . +- Architecture: 32-bit BWAPI module (`pluto.dll`) launches a **separate 64-bit inference process** (`pluto_infer.exe`); + README says stdio pipes, the CoG build notes say shared memory. Paces itself by inference (one step every 6 frames). +- Reported wins over top human players (Larva, Iris, Ret, Paralyze) came from an **unauthorized** copy on the + StarCraft: Remastered ladder, which reportedly also ran a **maphack** — secondary sources only + (). + +**Lessons:** ZH is 32-bit too → ML inference must be out-of-process anyway · a strong bot plus hidden-information access +is exactly the abuse case, so fog enforcement belongs in the engine side of the bridge, not in the bot. + +## StarCraft II API — s2client-proto / PySC2 / python-sc2 / AI Arena + +| Aspect | How | +|---|---| +| Transport | Protobuf over WebSocket (`-listen 127.0.0.1 -port N`, path `/sc2api`); responses in request order; pipelining encouraged | +| Timing | **Step mode**: sim advances only when all players send `RequestStep{count}`. **Realtime mode**: 22.4 loops/s, no step | +| Observation | Raw / feature layers / rendered — same info, different encodings. `disable_fog` off by default; fogged units come back as `DisplayType Snapshot` | +| Actions | `ActionRawUnitCommand{ability_id, target, unit_tags[], queue}`; errors reported immediately *and* later in `action_errors` | +| Fairness | AI Arena's `rust-arenaclient` proxy sits between bot and game: strips debug requests, per-request `max_frame_time`, ties at max game time, hides the random race, saves the replay | + +**Lessons:** one protocol, two timing modes · report order failures twice (validation + execution) · fairness enforced by a +proxy/referee that the bot cannot configure · multiple observation encodings from one state. + +## 0 A.D. — RL interface (open-source RTS, closest in spirit) + +- `pyrogenesis --rl-interface=127.0.0.1:6000` → HTTP server: `/reset`, `/step` (lines of `playerID;commandJSON`), `/evaluate` (runs JS), `/templates`. +- Sim advances **only on `/step`**, one 200 ms turn; `--autostart-nonvisual` for headless. +- Returns the full (apparently unfogged) state; any player ID can be commanded — . + +**Lessons:** a small local step/reset server is easy to adopt · but "whole state + command any player + eval" is a research +tool, not a fair interface — keep god-mode a separate, explicit capability. + +## Spring / Recoil engine + +In-process Skirmish AI shared libraries (`init` / `handleEvent` / `release`), C interface wrapped for Java/Python. +Explicit cheat API (`Cheats_isEnabled`, `isOnlyPassive`). **Lesson:** make cheating a declared, queryable capability. + +## Gym-style interfaces + +- **Gymnasium** `reset(seed) → (obs, info)`, `step(a) → (obs, reward, terminated, truncated, info)`. +- **PettingZoo** AEC vs Parallel multi-agent; action masks in obs/info. +- **Gym-µRTS**: invalid-action masking is central — the key to RL in RTS at all (). +- **Lux AI S3**: JSON over stdin/stdout, hidden values = `-1` + `sensor_mask`, per-step limit **plus a time bank**. +- **AlphaStar**: human-like limits (camera view, ≤22 actions / 5 s, ~110 ms reaction) negotiated with a pro. + +**Lessons:** ship legal-action masks · time bank is kinder than a hard per-frame cutoff · human-like limits are a referee setting, not an engine rule. + +## NewShoes / Generals.zone — an existing Zero Hour agent bridge + +The most relevant prior art. EA ZH source compiled to WebAssembly, plus an optional Go bridge — + (umbrella issue #75), README mirror +. + +- Browser game connects **out** to the bridge; bridge serves REST on `127.0.0.1:18888`, Bearer token, protocol `cnc-agent/1`. +- Engine stays authoritative for snapshots, fog/stealth filtering, stable **opaque IDs** (assigned only once observable, + so counts don't leak), terminal results. Sugar (diffs, SSE events, filters) lives in Go. +- Modes fixed at launch: `global` (whole map, fog-safe) and `camera` (observation *and* orders limited to the tactical view). +- Orders: advertised command names, engine validates prerequisites/funds/placement, then posts the real game message; + group orders all-or-nothing unless `bestEffort`. +- Strictly opt-in, zero per-frame cost when off. + +**Lessons:** validates the whole shape on this exact engine · reuse its vocabulary where sensible and talk to its author +before inventing a second, incompatible protocol · unverified whether it supports lockstep stepping or native headless. + +## LLM agents in RTS + +| Project | Takeaway | +|---|---| +| TextStarCraft II () | Text observations + macro actions; beat built-in Harder AI but ~7 h per game → needs paused/step mode | +| LLM-PySC2 () | Async queries keep latency flat with many agents; invalid/hallucinated actions must be reported back | +| SwarmBrain () | LLM strategist + fast rule-based reflex layer | +| OpenRA-RL () | gRPC → Gymnasium → **MCP** (~50 tools). Observation channel holds only the newest state; actions buffered; game never waits → a 40 ms bot and a 2 s LLM drive the same engine | +| RTSGameBench () | Pauses between steps to measure decision quality, not reaction time | + +## Jev — fast typed decisions (TypeSafe AI, released 2026-09-21) + +- "System One model": **unstructured state in, typed probabilistic decisions out** — `choice` (≤255 options), `score`, + `noul` (yes/no); every answer carries probabilities. 70–500 ms end-to-end. Questions in one request are evaluated + **in parallel**, so adding questions barely changes latency. Doom demo at ~10 queries/s on structured (non-pixel) state. + · +- Intended split: LLM for open-ended reasoning, Jev for fast structured decisions along the way. + +**What it needs from us:** a compact JSON/text state · closed candidate lists (units, targets, build sites) small enough for a +`choice` · batch orders so N parallel answers become one submission · a ~10 Hz loop that doesn't block the game. +That is the middle tier in [overview.md](overview.md) §Architecture, and why [04](04-bridge-protocol.md) is batch-first. + +## World-model / pixel agents (later, not v1) + +V-JEPA 2-AC, Genie 3, SIMA 2 consume rendered frames + keyboard/mouse. Cheapest support here is not live pixels but +**replay → (frame, action) export**, since the simulation is deterministic. Noted in [02](02-observation.md) §Later. + +## Synthesis → decisions taken in this plan + +| Decision | From | +|---|---| +| Orders only as `GameMessage`, after UI-parity checks | BWAPI `canIssueCommand`, NewShoes, #3328 | +| Fog-respecting default; full info only for replays/analysis | BWAPI flags, SC2 `disable_fog`, Pluto maphack incident | +| Out-of-process, versioned schema, loopback | BWAPIClient ABI lock-in, Pluto 32/64-bit split, SC2, xezon | +| Realtime (never waits) + step (lockstep) modes | SC2, OpenRA-RL, 0 A.D. | +| Batch reads/writes, deltas, action masks | µRTS, Jev, LLM-PySC2, BWAPI round-trip cost over a bridge | +| Referee outside the engine | TournamentModule, rust-arenaclient | +| MCP + Gym as SDK layers, not engine features | OpenRA-RL, xezon | diff --git a/docs/plans/2026/09/21/101-bot-agent-api/01-engine-seams.md b/docs/plans/2026/09/21/101-bot-agent-api/01-engine-seams.md new file mode 100644 index 00000000000..a1d565dac64 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/01-engine-seams.md @@ -0,0 +1,109 @@ +# 01 — Engine seams (what exists today) + +> Part of [overview.md](overview.md). Depends on: none. Reference map for 02–06; changes nothing. + +All references verified on `main` @ `241f01926`. `Core/` is shared; `GeneralsMD/` is Zero Hour. + +## Command path + +| Fact | Where | +|---|---| +| `GameMessage`, types enum; network range `MSG_BEGIN_NETWORK_MESSAGES = 1000` … `MSG_END_NETWORK_MESSAGES = 1999` — only this range is sent over the network and recorded | `Core/GameEngine/Include/Common/MessageStream.h:96`, `:498`, `:618` | +| Selection: `MSG_CREATE_SELECTED_GROUP(Bool createNew, objectIDs…)`, `…_NO_SOUND`, `MSG_DESTROY_SELECTED_GROUP`, `MSG_REMOVE_FROM_SELECTED_GROUP` | `MessageStream.h:500-508` | +| Orders used as examples below: `MSG_QUEUE_UNIT_CREATE` `:553`, `MSG_DOZER_CONSTRUCT` `:555`, `MSG_DO_ATTACK_OBJECT` `:568`, `MSG_DO_MOVETO` `:577` | `MessageStream.h` | +| Every `GameMessage` is stamped with the **local** player index at construction | `Core/GameEngine/Source/Common/MessageStream.cpp:57` | +| Translators run in priority order; survivors moved to `TheCommandList` | `MessageStream.cpp:1075-1121` (`propagateMessages`) | +| Translator registration (Window, MetaEvent, HotKey, Place, GUICommand, Selection, LookAt, Command, HintSpy, final dispatcher) | `GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp:297-315` | +| Network takes network-range messages off `TheCommandList` and schedules them at `frame + runAhead` | `Core/GameEngine/Source/GameNetwork/Network.cpp:456-484` | +| On receive, the sender's player index comes from the **packet slot**, not the message → a client can only command its own player | `Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp:156` | +| Logic dispatch; `getMessagePlayer` | `Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp:357`, `:210-213` | +| Selection is **logic state per player** (`Player::m_currentSelection`) | `GeneralsMD/Code/GameEngine/Include/Common/Player.h:828`; `GameLogic::selectObject` `GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp:2720-2775` | + +**Consequence:** a bot orders specific units by sending `MSG_CREATE_SELECTED_GROUP(true, ids…)` then the order. +Production/building act on a single selected object (`getSingleObjectFromSelection`, `GameLogicDispatch.cpp:217`). +The SDK hides this pairing (see [03](03-commands-and-hardening.md)). + +## Frame loop + +| Fact | Where | +|---|---| +| 30 logic frames/s (`LOGICFRAMES_PER_SECOND = WWSyncPerSecond = 30`) | `Core/GameEngine/Include/Common/GameCommon.h:68`, `Core/Libraries/Source/WWVegas/WWLib/WWCommon.h:46` | +| `GameEngine::update`: client update → `propagateMessages()` `:910` → `TheNetwork->UPDATE()` `:914` → `TheGameLogic->UPDATE()` `:921` → `TheGameClient->step()` | `GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp:896-929` | +| In MP the logic advances only when `isFrameDataReady()`; in skirmish a time accumulator paces it | `GameEngine.cpp:816-893` | +| `GameLogic::update`: scripts → CRC → `TheRecorder->UPDATE()` `:3796` → **`processCommandList` `:3801` (orders for frame N run here, before object updates)** → objects, AI, partition/shroud → victory `:3917` → `m_frame++` `:3935` | `GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp:3687` | +| Run-ahead: initial 30, bounds `MIN_RUNAHEAD = 4`, `MAX_FRAMES_AHEAD = 128`, adjusted at runtime | `Network.cpp:330`, `:643-646`; `Core/GameEngine/Include/GameNetwork/NetworkDefs.h:38-39` | + +## Validation that exists + +| Guard | Where | +|---|---| +| Unknown player index rejected | `GameLogicDispatch.cpp:363-368` | +| Ownership filter on the selected group — **all-or-nothing**: one unowned member drops the whole order | `GameLogicDispatch.cpp:397-400`, `GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIGroup.cpp:295-316`, `:235-240` | +| Explicit owner checks only in some handlers (rally point, cancel unit, cancel construct) | `GameLogicDispatch.cpp:960-972`, `:1829-1830`, `:1956-1958` | +| Network: bad slots / stale frames / non-network types rejected | `Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp:529-560`, `Network.cpp:584-595` | + +**Gap (bobtista):** no shroud/visibility check on targets. `onDoAttackObject` only checks the target exists +(`GameLogicDispatch.cpp:1700-1715`); the UI only changes the cursor hint over shrouded objects +(`GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp:2862-2874`). A crafted message can do what the UI cannot. + +## Fog / visibility + +| Fact | Where | +|---|---| +| `CellShroudStatus {CLEAR, FOGGED, SHROUDED}`, `ObjectShroudStatus {…}` | `Core/GameEngine/Include/Common/GameCommon.h:157`, `:166` | +| `PartitionManager::getShroudStatusForPlayer(idx, x, y)` — **pure read** | `GeneralsMD/Code/GameEngine/Include/GameLogic/PartitionManager.h:1521-1522`, impl `PartitionManager.cpp:3121-3137` | +| `Object::getShroudedStatus(idx) const` → `PartitionData::getShroudedStatus` — **writes caches** (`m_shroudedness`, `m_everSeenByPlayer`, ghost snapshots). Rules: enemy units in fog = shrouded unless immobile and ever seen; mines in fog always shrouded | `GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp:1856-1866`, `GeneralsMD/Code/GameEngine/Source/GameLogic/Object/PartitionManager.cpp:1616-1722` | +| Ghost objects (remembered buildings) tracked for the local player only unless `m_enablePlayerObserver`; dummy in headless | `GeneralsMD/Code/GameEngine/Include/GameLogic/GhostObject.h:42`, `:102-123` | +| Radar per player (`isRadarHidden`, `isRadarForced`) | `Core/GameEngine/Include/Common/Radar.h:196`, `:202` | + +## Headless / replay / CRC + +| Fact | Where | +|---|---| +| `-headless`, `-replay`, `-jobs` | `Core/GameEngine/Source/Common/CommandLine.cpp:1170-1182` | +| Headless replay loop calls `TheGameLogic->UPDATE()` directly — **no `GameEngine::update`, no MessageStream, no Network** | `Core/GameEngine/Source/Common/ReplaySimulation.cpp:99` | +| Worker mode spawns child processes (`WorkerProcess`: stdout pipe + job object) | `ReplaySimulation.cpp:130-201`; `Core/GameEngine/Source/Common/WorkerProcess.cpp:77-120` | +| Recording writes every network-range message in `TheCommandList` | `GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp:466-517` | +| Playback **deletes locally injected network-range commands** (`cullBadCommands`) → a bot cannot act during replay playback, by design | `Recorder.cpp:1579-1605` | +| CRC every N frames; covers objects, partition cell shroud, players, AI groups, logic RNG seed | `GameLogic.cpp:3758-3789`, `:4142` | +| CI replay check: `generalszh.exe -jobs 4 -headless -replay *.rep`, exit code checked | `.github/workflows/reusable-check-replays.yml:176`, `:221` | +| Headless dummies: audio, particles, radar, ghost objects, mouse/keyboard, window manager, tactical view, movies, score screen | `GameEngine.cpp:534`, `:555`, `:617`; `GameLogic.cpp:394`; `GameClient.cpp:270`, `:325`, `:343`; `Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp:273` | + +## Match setup that exists + +| Fact | Where | +|---|---| +| Slot states `SLOT_OPEN/CLOSED/EASY_AI/MED_AI/BRUTAL_AI/PLAYER`; observer template | `Core/GameEngine/Include/GameNetwork/GameInfo.h:36-50` | +| `-map` sets `m_mapName`; `-file ` starts a single-player map via `MSG_NEW_GAME` | `CommandLine.cpp:400-408`; `GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp:712-730` | +| Skirmish setup flows through `TheSkirmishGameInfo` | `GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/SkirmishGameOptionsMenu.cpp:432` | +| LAN: `RequestGameCreate`, `RequestGameJoin`, `RequestGameJoinDirectConnect` | `Core/GameEngine/Include/GameNetwork/LANAPI.h:75-85` | +| Multi-instance on one machine | `Core/GameEngine/Source/GameClient/ClientInstance.cpp:55` | + +## Unsafe getters — an observer must never call + +| Call | Why | +|---|---| +| `Object::getShroudedStatus` | Writes partition caches and ghost snapshots | +| `WeaponTemplate::getDelayBetweenShots` | Advances the logic RNG (`GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Weapon.cpp:493-501`) | +| `GameLogicRandomVariable::getValue` | Advances the logic RNG (`Core/GameEngine/Source/Common/RandomValue.cpp:448-460`) | +| `TheAI->createGroup`, `Player::getCurrentSelectionAsAIGroup` | Increments the group ID counter; group list is CRC'd (`GeneralsMD/Code/GameEngine/Include/GameLogic/AI.h:298`) | +| `AIUpdate::getNextMoodTarget`, `getNextWaypoint` | Use logic RNG | + +Logic, client and audio RNG streams are separate (`RandomValue.cpp:50-60`). The observer uses **no** RNG. +[09](09-verification.md) adds a test that proves the snapshot writer is pure. + +## Placement (xezon on #3328) + +- New files → `Core/GameEngine/{Include,Source}/Common/BotBridge/` (name open). +- Hooks into files not yet in `Core` (`GameEngine.cpp`, `GameLogic.cpp`) → ZH (`GeneralsMD/`) first, then the Generals replica, before merge. +- Scripted `AIPlayer` / `AISkirmishPlayer` untouched. + +## Platforms (as of 2026-09) + +| Platform | Game binary today | Implication | +|---|---|---| +| Windows | Native 32-bit (VC6 and MSVC presets) | Winsock is available (`Core/GameEngine/Source/GameNetwork/Transport.cpp:94`) | +| Linux | Windows `.exe` under Wine (`scripts/docker-build.sh`); native build proposed (#2088) | Loopback TCP crosses the Wine boundary; a native Linux bot talks to a Wine game unchanged | +| macOS | Via Wine/CrossOver only; no native port | Same as Linux | + +Engine code stays C++98-compatible for VC6; no new vcpkg dependency (today: `zlib`, `stb`). diff --git a/docs/plans/2026/09/21/101-bot-agent-api/02-observation.md b/docs/plans/2026/09/21/101-bot-agent-api/02-observation.md new file mode 100644 index 00000000000..de426bd715f --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/02-observation.md @@ -0,0 +1,90 @@ +# 02 — Observation (Stage 1: read-only) + +> Part of [overview.md](overview.md). Depends on: [01](01-engine-seams.md). Wire transport in [04](04-bridge-protocol.md). + +## Goal + +Every logic frame, the bridge can publish a **snapshot of what one player may legitimately know**, built only from pure +reads, at a hook that fires in normal play, skirmish, network games and headless replay simulation. + +## Where + +| What | Where | Why | +|---|---|---| +| Hook | End of `GameLogic::update`, just before `m_frame++` (`GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp:3935`) | Logic is settled for frame N; also runs in headless replay, which skips `GameEngine::update` (`Core/GameEngine/Source/Common/ReplaySimulation.cpp:99`) | +| Snapshot writer | New: `Core/GameEngine/Source/Common/BotBridge/BotSnapshot.cpp` | Only file that reads logic for the bot | +| Cost when off | One `if (TheBotBridge)` branch per frame | Opt-in, like NewShoes | + +The bot observes **the end of frame N**. Orders it sends in response can execute at the earliest in frame N+1 +(skirmish) or N+1+runAhead (network). See [04](04-bridge-protocol.md) §Timing. + +## Visibility modes + +Fixed at launch, reported in the handshake, never changeable mid-game (BWAPI lesson). + +| Mode | Sees | Allowed in | +|---|---|---| +| `player` (**default**) | Own objects; others only where the player's shroud is `CLEAR`; fogged structures as last-seen snapshots marked `stale` | Everything | +| `camera` (xezon's suggestion) | `player` ∩ current tactical view; bot moves the view with the client-only `camera` request ([04](04-bridge-protocol.md)) | Everything; referee may require it | +| `full` | All objects, no fog | Replay observation, local analysis, single-player training. **Refused in any network game** | + +Replay observation defaults to `full` — as in BWAPI, where all flags turn on for replays — and can be switched to `player `. + +## Visibility rule (pure) + +- Cells: `ThePartitionManager->getShroudStatusForPlayer(botIdx, x, y)` — pure (`PartitionManager.cpp:3121-3137`). +- Objects: **do not call** `Object::getShroudedStatus` (mutates, see [01](01-engine-seams.md) §Unsafe getters). + Recompute the same rule from cell status: visible if any occupied cell is `CLEAR`; enemy mobile units in fog → hidden; + mines in fog → hidden; `KINDOF_ALWAYS_VISIBLE` → visible. +- "Ever seen" memory for fogged structures lives **in the bridge** (bot-side memory keyed by opaque ID), not in logic. +- Stealth: enemy objects with `OBJECT_STATUS_STEALTHED` and without `OBJECT_STATUS_DETECTED` + (`Core/GameEngine/Include/Common/ObjectStatusTypes.h:60-61`) are hidden even on `CLEAR` cells. Open: `DETECTED` is a + single status bit, not per player — in games with 3+ players, check how the client decides per viewer and mirror that. +- Disguises (e.g. GLA units disguised as another template): report the template the player would see, not the real one. + +## Snapshot content (v1) + +| Group | Fields | Notes | +|---|---|---| +| Frame | `frame`, `mode`, `player`, `game_state` (`loading/playing/ended`), `result` on end | | +| Self | money, power produced/used, rank, science points, sciences, upgrades, general powers + cooldowns | Only own | +| Objects | `id`, `template`, `owner`, `pos`, `angle`, `health/max`, `status` bits, `veterancy`, `contained_by`, `stale` | Own: + orders, production queue, weapons ready, ammo, `selectable` | +| Map (once) | name, size, heightmap, passability grid, start positions, supply/oil positions | Sent at game start, not per frame | +| Visibility | Shroud grid (`CLEAR/FOGGED/SHROUDED`) | Delta per frame | +| Catalog (once) | Templates: cost, build time, prerequisites, weapons, armor; buttons/commands per template | Lets bots and masks avoid hard-coding INI | +| Events | created, destroyed, damaged, attacked, construction done, upgrade done, EVA-style alerts | Since last read | + +**Opaque IDs.** Expose `ObjectID` for own objects; for others, the bridge maps to IDs assigned on first sighting so ID gaps +don't reveal enemy production (NewShoes lesson). Open question whether this is necessary in v1. + +## Encodings — one state, several views + +| Encoding | For | Shape | +|---|---|---| +| `records` | Scripted bots, LLMs, Jev | Object list + filters/fields (see [04](04-bridge-protocol.md) §Batch reads) | +| `planes` | **ML (CNN/transformer policies)** | Fixed `C × H × W` feature planes on a downsampled grid: own/enemy/neutral occupancy, unit class, health, shroud, height, passability. `float32`/`uint8`, little-endian | +| `entities` | **ML (set/transformer policies)** | Padded `N × F` array + mask; fixed column order published in the catalog | +| `text` | LLMs | Built **in the SDK**, not the engine ([07](07-sdk-gym-mcp.md)) | + +`planes` and `entities` are the ML path: fixed shapes, no parsing, copy straight into a tensor. They are computed in the +bridge from the same pure reads as `records` and sent binary ([04](04-bridge-protocol.md) §Encoding). +Legal-action masks come with them ([03](03-commands-and-hardening.md) §Masks). + +## Stage 1 steps + +1. Snapshot writer + `full` mode, used only from `-headless -replay`. +2. Dump to the bridge (or a file sink) per frame; external client reads it. +3. Add `player` mode; test for fog leaks ([09](09-verification.md)). +4. Same hook live in skirmish (still read-only; no orders yet). + +## Later (not v1) + +- Rendered-frame channel (world-model / pixel agents). Cheapest form: offline **replay → (frame, action) export**, + deterministic, no live pixels needed. +- Human-style input channel (camera/mouse/hotkeys) for SIMA-like agents. + +## Done when + +- Snapshots of a replay in `full` mode are byte-identical across two runs. +- `player` mode never contains an object in a `SHROUDED` cell for that player (fog-leak test). +- Replay CRC unchanged with the hook on versus off (the existing CI job). diff --git a/docs/plans/2026/09/21/101-bot-agent-api/03-commands-and-hardening.md b/docs/plans/2026/09/21/101-bot-agent-api/03-commands-and-hardening.md new file mode 100644 index 00000000000..1e3ea1a97a1 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/03-commands-and-hardening.md @@ -0,0 +1,99 @@ +# 03 — Commands and hardening (Stage 2) + +> Part of [overview.md](overview.md). Depends on: [01](01-engine-seams.md), [02](02-observation.md). + +## Goal + +A bot's orders become the **same `GameMessage`s the UI produces**, for the local player only, after the same checks the +UI would apply. The engine cannot tell a bot from a human → replays, CRC and network sync unchanged. + +## Injection point + +| Option | Where | Trade-off | +|---|---|---| +| **A (proposed)** | Append to `TheCommandList` right after `TheMessageStream->propagateMessages()` (`GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp:910`), before `TheNetwork->UPDATE()` | Bypasses UI translators (no cursor/selection side effects); same position translator output lands in; network picks it up normally (`Core/GameEngine/Source/GameNetwork/Network.cpp:456-475`) | +| B | `TheMessageStream->appendMessage` | Translators may rewrite or destroy bot messages; couples bot to UI state | + +Messages are constructed normally, so they carry the local player index (`Core/GameEngine/Source/Common/MessageStream.cpp:57`). +Only network-range types (`1000–1999`) are accepted from the bot; everything else is rejected at the bridge. + +During replay playback injected commands are culled by design (`GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp:1579-1605`) — the bridge refuses orders in replays. + +## Order vocabulary + +The bot never sends raw selection messages. It sends **orders**; the bridge expands each into the message sequence the UI would: + +| Order | Expands to | +|---|---| +| `move {units, to}` | `MSG_CREATE_SELECTED_GROUP(true, units…)`, `MSG_DO_MOVETO(to)` | +| `attack {units, target}` | select, `MSG_DO_ATTACK_OBJECT(target)` | +| `attack_move`, `guard`, `stop`, `scatter`, `force_attack`, waypoints | select + matching message | +| `train {factory, template}` | select factory alone, `MSG_QUEUE_UNIT_CREATE(template, productionID)` | +| `build {dozer, template, at, angle}` | select dozer alone, `MSG_DOZER_CONSTRUCT(template, at, angle)` | +| `upgrade`, `special_power`, `sell`, `repair`, `enter`, `evacuate`, `rally` | select + matching message | +| `raw {type, args}` | **Escape hatch**, off unless the referee allows it; still UI-parity checked | + +**Camera is not an order.** It is client-only view state, not a `GameMessage`: live camera movement goes through +`LookAtTranslator` (`Core/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp`) and ends in `View::lookAt` +(`Core/GameEngine/Include/GameClient/View.h:143`); the network-range `MSG_SET_REPLAY_CAMERA` is replay-only. So the +bridge exposes a separate **`camera {to, zoom?}` request** ([04](04-bridge-protocol.md) §Requests) that calls +`TheTacticalView->lookAt()` directly, on the client, outside `TheCommandList`. It never enters logic, the network or +the replay, so it cannot desync. In `camera` mode the referee may rate-limit it (a human cannot teleport the camera +every frame either; open question). + +Grouping: one batch of orders for a frame is expanded in order; consecutive orders on the same unit set reuse one selection. +Selection lives in logic per player (`GeneralsMD/Code/GameEngine/Include/Common/Player.h:828`), so the bot's selection +and a human's UI selection on the same player collide → **human input is disabled while a bot controls the player** +unless the launch flag `assist` is set ([05](05-match-setup-and-lobby.md) §Human + AI). + +## UI parity — check before sending (no CRC impact) + +Implemented in the bridge, **client-side**, before any message is created. Rejected orders never reach logic, so these +checks are free of retail-compatibility concerns. + +| Check | UI equivalent | +|---|---| +| Every unit is owned by the local player and selectable | Selection translator (`Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp`) | +| Target object is visible to the player in the current mode | Mouse picking skips shrouded drawables | +| Target location is on the map | Command translator | +| Command is on the unit's command set, prerequisites met, affordable | Control bar button state | +| Build site legal (reuse the placement check the UI uses) | Place-event translator | +| In `camera` mode, units and target are inside the tactical view | UI can only click what is on screen | + +Each rejection returns a code + reason per order (batch continues). Late failures — the unit died, target vanished — +come back as events the next frame (SC2 lesson: report both). + +## Logic-side hardening (separate, optional PRs) + +Client-side parity protects bots using the bridge; it does not protect against a hand-modified client — that is true +today already. Logic-side checks close that hole for everyone: + +| Candidate | Where | +|---|---| +| Reject `MSG_DO_ATTACK_OBJECT` / force-attack on a target shrouded for the sender | `Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp:1700-1717` | +| Explicit sender ownership in `onQueueUnitCreate`, `onDozerConstruct` | `GameLogicDispatch.cpp:1810-1837`, `:1873-1906` | + +These change logic outcomes → guarded like other `RETAIL_COMPATIBLE_CRC` fixes, each its own small PR, each with a replay proving the change. They are **not prerequisites** for the bot API. Maintainers decide (overview open question 4). + +## Masks (for ML) + +With `planes`/`entities` observations the bridge also returns **legal-action masks**, computed from the same parity checks: + +- per own unit: which order types are legal now; +- per factory: which templates are trainable now; +- a coarse build-site legality plane per buildable template on request (expensive → opt-in). + +Invalid-action masking is what made RL in µRTS tractable ([00](00-prior-art.md)). + +## Steps + +1. Order → message expansion for `move`, `attack`, `stop`, `train`, `build`. +2. UI-parity checks + per-order results. +3. Remaining vocabulary; masks. +4. (Optional, separate) logic-side hardening PRs. + +## Done when + +- A skirmish driven only by bot orders records a replay that `-headless -replay` re-simulates with zero mismatches. +- Every order in the vocabulary has a test that a UI-impossible variant is rejected. +- With the bridge compiled in but disabled, replay CRCs in CI are unchanged. diff --git a/docs/plans/2026/09/21/101-bot-agent-api/04-bridge-protocol.md b/docs/plans/2026/09/21/101-bot-agent-api/04-bridge-protocol.md new file mode 100644 index 00000000000..ba3f4000ae7 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/04-bridge-protocol.md @@ -0,0 +1,115 @@ +# 04 — Bridge protocol + +> Part of [overview.md](overview.md). Depends on: [02](02-observation.md), [03](03-commands-and-hardening.md). +> The engine side is in this repo; the protocol spec file and all clients live in the external repo ([07](07-sdk-gym-mcp.md)). + +## Goal + +One small, versioned, cross-platform protocol that serves a 33 ms reflex bot, a 10 Hz decision model and a +multi-second LLM without changes on the engine side. + +## Transport + +| Choice | Why | +|---|---| +| **TCP on `127.0.0.1`**, port from `-botapi ` | Same code on Windows, Linux (native or Wine) and macOS (Wine/CrossOver); crosses the Wine boundary to native bots; Winsock already linked (`Core/GameEngine/Source/GameNetwork/Transport.cpp:94`) | +| Game is the **server**; one client at a time | Mirrors SC2 `-listen`; the bot or a launcher can connect after start | +| **One port per game instance**; `-botapi 0` = OS picks a free port, reported on stdout (`BOTAPI_PORT=`) and in a port file in the instance's user dir | Many instances per host (training, bot vs bot) without collisions; the runner reads the port back instead of pre-allocating | +| **Every connection authenticates**, loopback included: first frame must carry the token | Loopback is not a trust boundary: any local process can find the port and race the intended client | +| **Token never on the command line or in a user-dir file.** The launcher passes it on an inherited channel: the first line of the game's stdin (default), or an inherited pipe handle. With no launcher (a human starting the game by hand), the engine generates a random 128-bit token and shows it once in the console/log for the user to paste into their bot | Command-line arguments and user-readable files are visible to other processes | +| **Trust boundary = the OS user account.** Processes running as the same user can already read game memory or inject a DLL, so the bridge does not try to defend against them. Untrusted bots (ladders, tournaments) run as a **different OS user or in a sandbox/container** ([08](08-referee-and-tournament.md)) | States what the token actually protects against: other users, other hosts and the connection race — not a malicious same-user process | +| First authenticated client owns the bridge for the whole match; later connections are refused; a dropped client may reconnect with the same token | No connection race, no hijack mid-match | +| Non-loopback bind only with `-botapiBind ` | Remote bots (e.g. a GPU box) are possible but never accidental | +| Non-blocking socket, polled once per engine tick | No threads in the engine; no locks around logic | + +Rejected: shared memory (BWAPI-style — OS-specific, doesn't cross Wine, fixed ABI); named pipes (Windows-only semantics); +HTTP server in engine (heavier; can be added by the SDK as a proxy, like NewShoes' Go bridge). + +## Seat scoping + +**One connection = one token = one seat.** The seat is the player that game instance controls; it is fixed by the engine +in `welcome` (`seat: {player_index, name, team}`) and cannot be chosen or changed by the client. + +- Every `observe` is that seat's view (its fog, its money, its production); every `act` is that seat's orders. +- A client cannot address another seat — there is no `player` field in `act` or `observe` to get wrong or abuse. +- Multi-seat (option B in [05](05-match-setup-and-lobby.md)) keeps the rule: one token per seat, even when the seats share a process. +- An agent driving several seats holds several connections; combining their knowledge happens in the agent, visibly, never in the engine. + +## Framing and encoding + +- Frame = `uint32 length (LE) | uint8 kind | payload`, where **`length` = number of bytes after the length field** + (`1 + payload bytes`). `length = 0` is invalid. +- **Receive limits:** the engine rejects frames above `max_frame_bytes` (default 1 MiB inbound — orders are small) and + advertises it in `welcome`; clients declare their own limit in `hello` (default 64 MiB, since observations with planes + are larger). A frame over the limit, an unknown `kind`, or invalid UTF-8/JSON → error frame, then close. Never allocate + from an unchecked length. +- `kind`: `1 = JSON`, `2 = BIN`, `3 = ERROR`; other values reserved. +- `kind = JSON` (UTF-8) for control, orders, records, events — debuggable with any language, written with a small + hand-rolled writer (C++98, no new dependency). +- `kind = BIN` for bulk numeric data (`planes`, `entities`, masks, shroud grids): header (`dtype`, `shape`, name) + raw + little-endian array. Zero-copy into NumPy/PyTorch (`np.frombuffer`) — the ML fast path. +- Schema: one JSON Schema file per message in the external repo is the contract; engine + SDKs are tested against it. + +## Handshake + +``` +C→S hello {protocol: "zh-bot/1", token: "", client: "", max_frame_bytes, want: {mode, encodings, timing}} +S→C welcome {protocol, game_version, player, mode, timing, capabilities[], limits{max_orders_per_frame, …}} +``` + +The server answers with what it **grants**, not what was asked (referee/launch flags win). Mode and timing never change +after `welcome`. Unknown major version → error + close. + +## Requests + +| Request | Does | +|---|---| +| `observe {encodings, filters?, fields?, since_frame?}` | **Batch read.** One snapshot or delta for the latest frame. Filters: owner, template/kind, area (rect/circle), status. `fields` projects columns. `since_frame` → only changes | +| `catalog {}` / `map {}` | Once per game: templates, commands, feature-plane layout; terrain, passability, start spots | +| `act {frame_hint?, orders:[…]}` | **Batch write.** Up to `max_orders_per_frame`; expanded and applied in one engine tick; result per order `{ok}` or `{error, reason}` | +| `step {frames}` | Step mode only: advance N frames, reply with an observation (see Timing) | +| `camera {to, zoom?}` | Move the tactical view (client-only, never in logic/replay; see [03](03-commands-and-hardening.md) §Order vocabulary) | +| `subscribe {events, every_n_frames?, encodings?}` | Server pushes observations/events; the default for realtime bots | +| `session {…}` | Lobby / match setup ([05](05-match-setup-and-lobby.md)) | +| `bye {}` | Leave; in a match = surrender unless the referee says otherwise | + +All requests carry an `id`; responses echo it. Clients may pipeline. + +## Timing — answers to bobtista's questions + +| Question | Realtime mode (default) | Step mode | +|---|---|---| +| Which frame does the bot observe? | End of the latest completed frame N ([02](02-observation.md) §Where) | Same | +| When do its actions execute? | Skirmish: frame N+1 if received before the next logic tick, else later. Network: `N + runAhead` (`Core/GameEngine/Source/GameNetwork/Network.cpp:477-484`), exactly like a human's | Next frame of the `step` | +| What if it stalls? | **Nothing** — the game never waits. The latest observation replaces the previous one (queue depth 1, as in OpenRA-RL); orders apply when they arrive; a missed frame means no orders | Game waits, up to the referee's budget ([08](08-referee-and-tournament.md)); then continues as a no-op frame or the bot forfeits | +| Where allowed? | Everywhere | Skirmish and headless only; **never** in network games (would stall other players) | + +Delivered frame numbers let the bot measure its own lag. `limits` in `welcome` include the current run-ahead. + +## Batch-first design (why) + +BWAPI's API is one call per unit per frame, cheap only because it is in-process. Over a process boundary, and certainly +for a model with 100 ms–seconds per decision, per-unit calls are the bottleneck. So: + +- one `observe` returns everything the bot asked for, filtered and projected; +- deltas (`since_frame`) keep 30 Hz subscriptions small; +- one `act` carries all orders for a tick — matches Jev's "many questions in parallel, one answer set" + and an RL policy's per-step action vector; +- a limit per frame is published, not hidden (referee may lower it for APM fairness). + +## Modes and safety + +| Rule | Why | +|---|---| +| Bridge is off unless `-botapi` is passed; compiled in behind a CMake option (open question 8) | Zero cost and zero surface by default | +| **Fairness limits are enforced in the engine**, from launch flags, not only by the referee: `-botapiMode player\|camera`, `-botapiMaxOrders `, `-botapiNoRaw`, `-botapiNoSession`. `hello` can only ask for *less* | A bot that reaches the port directly still cannot get more than the match allows; the referee is defence in depth, not the only gate | +| `full` visibility refused if the game is a network game | No maphack in multiplayer — the Pluto ladder incident | +| Orders only for the local player | Engine already enforces per-slot identity over the network (`Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp:156`) | +| Network games: bot flag announced + `[BOT]` name tag | [05](05-match-setup-and-lobby.md) | +| Bridge never touches logic state except by appending `GameMessage`s | No new desync surface | + +## Done when + +- A Python client (≈100 lines, stdlib only) connects on Windows and on Linux to a Wine-run game, observes and acts. +- Protocol schema in the external repo; engine responses validate against it in CI. +- A stalled client does not change frame time in realtime mode (measured). diff --git a/docs/plans/2026/09/21/101-bot-agent-api/05-match-setup-and-lobby.md b/docs/plans/2026/09/21/101-bot-agent-api/05-match-setup-and-lobby.md new file mode 100644 index 00000000000..e657849a89e --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/05-match-setup-and-lobby.md @@ -0,0 +1,117 @@ +# 05 — Match setup, lobbies, and playing with others + +> Part of [overview.md](overview.md). Depends on: [04](04-bridge-protocol.md). + +## Goal + +A bot can do what a player does before and around a match: start a skirmish, host or join a LAN / direct-connect game, +pick faction, colour, team and map, and play against humans, built-in AI, or **other people's bots on their own machines**. + +## Scenarios + +| # | Scenario | Machines | Works because | +|---|---|---|---| +| 1 | My bot vs built-in AI (skirmish) | 1 | Local player = bot; `SLOT_*_AI` for opponents (`Core/GameEngine/Include/GameNetwork/GameInfo.h:36-44`) | +| 2 | **Me + my AI vs another player + their AI** | 2+ | Each client has exactly one local player; lockstep sends only commands, never state; each bot sees only what its own client knows | +| 3 | Human + AI assistant on the same player (co-pilot) | 1 | Shared player; `assist` flag keeps human input on ([§Human + AI](#human--ai)) | +| 4 | My bot vs my other bot, locally | 1 | Two game instances in a LAN game on loopback, each with its own bridge **and its own port** (`-botapi 0`, [04](04-bridge-protocol.md) §Transport). Multi-instance must be on ([06](06-headless-and-step.md) §Throughput). To verify: LAN discovery between instances on one host | +| 5 | Bot vs human online | 2+ | Same as 2; community decides where it's allowed ([§Online](#online)) | +| 6 | **4 humans vs 4 agents** (8-player game) | 5+ | Every seat is an ordinary client. The 4 agent instances can share one host (multi-instance, one port each), joining the humans' LAN / direct-connect game | +| 7 | **1 human + 3 agents vs 4 agents** (mixed teams) | 2+ | Same as 6; the human's allied agents coordinate with them through chat and beacons ([§Team play with humans](#team-play-with-humans)) | + +Scenario 2 needs **no engine support for multiple bots**: every client is an ordinary client whose local player is driven +by its own bridge. That also answers bobtista's "more than one bot on a machine" concern — the answer is one bot per +game process, scale with processes. + +## Team play with humans + +For scenarios 6 and 7 an agent must be a usable teammate, not just an opponent. + +| Need | Engine path | Bridge surface | +|---|---|---| +| Read team/all chat | Chat already travels over the network (`NetworkInterface::sendChat(text, playerMask)`, `Core/GameEngine/Include/GameNetwork/NetworkInterface.h:71`) | `chat` events (sender, audience, text) | +| Write chat | Same `sendChat` the in-game chat box uses | `chat {to: team\|all, text}` request, rate-limited | +| Beacons / map pings | `MSG_PLACE_BEACON`, `MSG_REMOVE_BEACON`, `MSG_SET_BEACON_TEXT` (`Core/GameEngine/Include/Common/MessageStream.h:598-600`) — ordinary network messages | `beacon` order + `beacon` events | +| Allied vision | Whatever the game already shares with allies (to verify in the shroud code) | Nothing new; follows the `player` visibility rule, which reads the same shroud the human ally would see | + +The SDK turns chat into intents for LLM agents ("attack left", "need anti-air") and an MCP recipe covers +`docs://recipes/play-with-human-teammates`. + +Out-of-band coordination: agents on the same team can also talk to each other outside the game (one brain controlling +3 seats, option A below). Humans on voice chat do the same, so it is allowed by default; the referee can forbid it for a +match, but the engine cannot enforce it — it is a declared rule, like any tournament rule about communication. + +Pace: a network game advances at the speed of the slowest client (`Core/GameEngine/Source/GameNetwork/Network.cpp:686-732`), +so agent instances in a game with humans **must** run in realtime mode (never step mode) and must keep up. On one host with +4 agent instances, only the local player's view is rendered in each; headless instances are the goal, but that needs headless LAN join ([06](06-headless-and-step.md)). + +## One agent, several player slots + +Examples: one agent plays both allies in a 2v2, or controls both sides for self-play. + +| Option | How | Where it works | Cost | +|---|---|---|---| +| **A — one brain, many seats (v1)** | The agent opens one bridge connection per game instance; each instance controls its own local player (scenarios 2 and 4). The SDK presents them as one multi-agent env (PettingZoo Parallel) | Everywhere, including network games; no engine change | One game process per slot | +| B — multi-seat process (later) | One game process; the bridge stamps each order with the target player's index via `GameMessage::friend_setPlayerIndex` (`Core/GameEngine/Include/Common/MessageStream.h:672`); one snapshot per seat, each with its own fog | Skirmish/headless only: over the network the index is replaced by the sender's slot (`Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp:156`) | Engine work; see below | + +Option B would give the biggest self-play throughput (one simulation, two policies), but: +- the dispatcher accepts any valid player index (`Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp:363-368`) — *untested* that everything downstream is correct for a non-local sender; +- replays already store a player index per message (`GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp:758-759`), so a multi-seat game should still replay — must be proven by the round-trip test ([09](09-verification.md)); +- client-side code assumes one local player (UI, radar, ghost objects), so every seat except the local one is observation-only through the snapshot writer; +- it is a new capability for a bot that no single human has → skirmish/headless only, refused in network games, flagged in `welcome`. + +Fairness: one agent on two allied slots shares knowledge between them, like two humans on voice chat. Allowed, but every +controlled slot carries the `[BOT]` tag and the referee can forbid it per match. + +## Session API (`session` requests, see [04](04-bridge-protocol.md)) + +| Action | Engine path | +|---|---| +| `maps.list` | Map cache used by the skirmish/LAN menus | +| `skirmish.create {map, slots:[{type: me\|ai_easy\|ai_med\|ai_brutal\|closed, faction, color, team, start}], options}` | `TheSkirmishGameInfo` as filled by `GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/SkirmishGameOptionsMenu.cpp:432` | +| `lan.list` / `lan.host {name, map, options}` / `lan.join {game \| ip}` | `LANAPI::RequestGameCreate`, `RequestGameJoin`, `RequestGameJoinDirectConnect` (`Core/GameEngine/Include/GameNetwork/LANAPI.h:75-85`) | +| `lobby.set {faction, color, team, start}` / `lobby.ready` / `lobby.chat` | Same messages the LAN lobby menu sends | +| `lobby.start` (host) | Same as the host pressing Start | +| `game.leave` / `game.surrender` | Same as the menu | + +Rules: +- Drives the **same code paths as the menus** — no new lobby protocol, no new packets → retail peers see an ordinary player. +- Command-line equivalents for unattended runs: e.g. `-botapi 7777 -botSkirmish ` (exact flags open), + reusing the existing `-map` / `-file` start path (`Core/GameEngine/Source/Common/CommandLine.cpp:400-408`, + `GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp:712-730`). +- Headless + session: skirmish only in v1 (the LAN lobby UI is dummied in headless; see [06](06-headless-and-step.md)). + +## Disclosure — `[BOT]` in the name + +Like GitHub's `name[bot]` accounts: anyone in the lobby, the in-game scoreboard and the replay can see a player is +bot-controlled. + +| Rule | Detail | +|---|---| +| Engine appends `[BOT]` to the player name whenever the bridge controls that player; bot cannot remove it | Set once when the bridge attaches, before joining/hosting | +| Name budget | LAN names are max 12 chars (`Core/GameEngine/Include/GameNetwork/LANAPI.h:38`) → base name truncated to 7 + `[BOT]` | +| `assist` mode (human + AI) | Tag `[AI+]` instead (open: exact tag) | +| Why a name tag and not only a lobby flag | Visible to unmodified / retail clients too, and stored in every replay header for free | +| Future | A proper lobby flag once the project has its own lobby server (README "Multiplayer Improvements") | + +## Human + AI + +- Default: bridge attached → local human game input disabled (like BWAPI `UserInput` off), camera still free. +- `-botAssist`: human keeps full input; bot orders are interleaved. Selection collisions are expected (selection is + per-player logic state, [03](03-commands-and-hardening.md)); the SDK re-selects before each order, so a bot order may + change the human's selection — documented, not hidden. + +## Online + +- Nothing in v1 targets online matchmaking or ranked play. +- Private online games work mechanically the same as LAN (scenario 2). +- Policy (allowed? separate bot ladder?) is a community/maintainer decision; the `[BOT]` tag makes it enforceable socially + now and technically later. +- `full` visibility is always refused in network games ([04](04-bridge-protocol.md) §Modes). + +## Done when + +- Scenario 1 from a script: create skirmish, play, get result, exit. +- Scenario 2 on two machines (one Windows, one Linux/Wine): two bots, two owners, one LAN game; both replays re-simulate identically. +- Scenario 7 on a LAN: one human + 3 agents vs 4 agents, the 7 agent instances split across two hosts; the agents answer a human's team-chat request; the game keeps normal speed. +- `[BOT]` visible in lobby, in-game, and in the replay player list. diff --git a/docs/plans/2026/09/21/101-bot-agent-api/06-headless-and-step.md b/docs/plans/2026/09/21/101-bot-agent-api/06-headless-and-step.md new file mode 100644 index 00000000000..7e4052fc02e --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/06-headless-and-step.md @@ -0,0 +1,61 @@ +# 06 — Headless live matches, step mode, throughput + +> Part of [overview.md](overview.md). Depends on: [02](02-observation.md), [03](03-commands-and-hardening.md), [04](04-bridge-protocol.md), [05](05-match-setup-and-lobby.md). + +## Goal + +Run bot matches with no window, faster than real time, many at once — the training and tournament path (RL needs +millions of frames; BWAPI needed OpenBW for this). + +## Today + +- `-headless` exists for replay simulation only; that path calls `TheGameLogic->UPDATE()` in a loop + (`Core/GameEngine/Source/Common/ReplaySimulation.cpp:99`) and never runs `GameEngine::update`, the message stream or the network. +- Many subsystems already have headless dummies ([01](01-engine-seams.md) §Headless). + +## Headless live skirmish + +| Need | Approach | +|---|---| +| Start a skirmish without the shell | Session config via command line ([05](05-match-setup-and-lobby.md)) → `MSG_NEW_GAME` like `-file` (`GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp:712-730`) | +| Loop | Either (a) normal `GameEngine::update` with headless dummies, or (b) a `ReplaySimulation`-style loop that also drains the bridge and calls `processCommandList`. **Open:** (a) is less new code; (b) is faster | +| Uncapped speed | Skip frame pacing when headless + bridge (`GameEngine.cpp:816-893` accumulator) | +| Game end | Victory conditions still update headless (`GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp:3917`); score screen is skipped (`Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp:273`) → bridge reports result + stats and exits with a code | +| Ghost objects (bobtista) | Headless uses `GhostObjectManagerDummy` (`GeneralsMD/Code/GameEngine/Include/GameLogic/GhostObject.h:112-123`). Bridge fog memory is its own ([02](02-observation.md) §Visibility rule), so the dummy is fine — must be confirmed not to affect logic/CRC | +| Other dummies hit in live play (not replay) | Inventory needed: control bar, in-game UI, EVA, radar events. Each found gap = a small headless fix PR, like #2895 / #3030 / #3066 | + +## Step mode + +- `step {frames: n}` → run n logic frames with the bot's queued orders applied at the first, then return an observation. +- Skirmish/headless only ([04](04-bridge-protocol.md) §Timing). +- Gives deterministic `(obs, action) → obs'` transitions for Gymnasium. +- Seed: the session config fixes the game seed so episodes are reproducible. + +## Throughput + +| Lever | Note | +|---|---| +| Many processes | One bot per process, one bridge port per process (`-botapi 0`, [04](04-bridge-protocol.md) §Transport); multi-instance exists but is off by default — only `RTS_MULTI_INSTANCE` builds or `-replay` enable it (`Core/GameEngine/Source/GameClient/ClientInstance.cpp:27-31`, `Core/GameEngine/Source/Common/CommandLine.cpp:436`) → `-botapi` enables it the same way; a runner in the external repo spawns N (same idea as `-jobs`, `ReplaySimulation.cpp:130-201`) | +| Observation cost | Only compute encodings a client subscribed to; planes at reduced resolution | +| Multi-seat self-play | Later: one process running both sides ([05](05-match-setup-and-lobby.md) §One agent, several player slots, option B), roughly halving the cost of each self-play game | +| Frame skip | `every_n_frames` in `subscribe` (PySC2 `step_mul`, Pluto: one step / 6 frames) | +| Measure | Frames/s per core for headless skirmish with a no-op bot, published as a baseline | + +## Headless in network games + +Scenarios 6 and 7 in [05](05-match-setup-and-lobby.md) put several agent instances on one host. Rendering each is +wasteful, so headless instances should be able to host and join LAN / direct-connect games. Today the LAN lobby is +driven by UI code that is dummied in headless mode. **Open:** a headless session path that calls `LANAPI` directly +(`Core/GameEngine/Include/GameNetwork/LANAPI.h:75-85`) without the menus. Until then, agent instances run windowed +(minimized, low detail). + +## Bot vs bot locally + +One process per bot, LAN game over loopback ([05](05-match-setup-and-lobby.md) scenario 4). No change to the +single-local-player model. **To verify:** LAN discovery and hosting between two headless instances on one host. + +## Done when + +- `generalszh.exe -headless -botapi 7777 -botSkirmish cfg.json` plays a full match against built-in AI to a result, no window. +- Step mode with a fixed seed produces identical observations across two runs. +- The replay of that match passes `-headless -replay` in CI. diff --git a/docs/plans/2026/09/21/101-bot-agent-api/07-sdk-gym-mcp.md b/docs/plans/2026/09/21/101-bot-agent-api/07-sdk-gym-mcp.md new file mode 100644 index 00000000000..fd14f20fb52 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/07-sdk-gym-mcp.md @@ -0,0 +1,115 @@ +# 07 — SDKs, Gym env, MCP server, recipes (external repo) + +> Part of [overview.md](overview.md). Depends on: [04](04-bridge-protocol.md). **Edited from the external repo, not this one** (xezon on #3328). + +## Goal + +Make the protocol easy for three very different consumers — ML training code, fast decision models, and LLMs that have +never heard of Command & Conquer — without adding anything to the engine. + +## Layout (external repo) + +``` +protocol/ JSON Schemas + BIN layouts — the contract; versioned zh-bot/ +clients/python/ stdlib-only client; numpy optional +clients/rust/ client crate +clients/cpp/ header-only client (C++17) +gym/ Gymnasium + PettingZoo envs, vector env, wrappers +mcp/ MCP server (LLM agents) +recipes/ markdown recipes (served by mcp, readable by humans) +referee/ match runner / proxy (see 08) +examples/ reference bots: scripted rush, random-legal, RL baseline, LLM, Jev harness +``` + +## ML path (not LLMs) + +| Need | Provided by | +|---|---| +| Fixed-shape tensors | `planes` (C×H×W) and `entities` (N×F + mask) in BIN frames → `np.frombuffer`, no parsing ([02](02-observation.md) §Encodings) | +| Legal-action masks | Per unit / per factory masks ([03](03-commands-and-hardening.md) §Masks) | +| Gym API | `ZHEnv(Gymnasium)`: `reset(seed, options={map, faction, opponent})`, `step(action) → obs, reward, terminated, truncated, info` | +| Multi-agent | PettingZoo Parallel env for scenario 4 (two local processes) | +| Action space | `Dict`/`MultiDiscrete`: order type × unit slot × target (grid cell or entity slot) — masked | +| Reward | Pluggable: win/loss (default), plus shaped helpers (resources, army value, damage) from score stats in `info` | +| Throughput | `VectorEnv` over N headless processes; step mode; frame skip ([06](06-headless-and-step.md)) | +| Imitation data | Replay → `(obs, action)` dataset exporter using replay observation in `full` or `player` mode (Stage 1 is useful on its own here) | + +## Fast decision models (Jev and similar) + +Jev takes a state and many typed questions (`choice` ≤255 options, `score`, `noul`) and answers them in parallel in +70–500 ms. The harness in `examples/jev/`: + +1. `observe` (records, filtered) → compact state text. +2. Build one question per idle unit/group: "target?" (`choice` over visible enemies + "hold"), "retreat?" (`noul`), per factory "train what?" (`choice` over mask-legal templates). +3. One Jev call → all answers → one `act` batch. +4. Loop at ~10 Hz in realtime mode; an LLM (below) sets strategy every few seconds. + +That is why the protocol is batch-first and publishes legal choices: the candidate lists *are* Jev's options. + +## LLM path — MCP server + +Few parameterized tools, constant size regardless of how many orders/templates exist +(pattern: ): + +| Tool | Does | +|---|---| +| `docs({q?, uri?})` | Recipes and reference. `docs({})` = the map. Search inlines the best hit; a miss returns nearest matches, never empty | +| `list_resources({})` | Object kinds, order types, map info, what this match allows | +| `describe_resource({resource})` | Fields/enums for one order or object kind, loaded lazily | +| `query({filters, fields, area?, since_frame?, format: "text"\|"json"})` | Batch read; `text` = summaries sized for context windows | +| `act({orders:[…]})` | Batch orders; per-order result; every error carries a `docs://` URI to the recipe that fixes it | +| `session({…})` | Create/join games ([05](05-match-setup-and-lobby.md)) | +| `chat({to, text})` / `beacon({at, text})` | Talk to human teammates ([05](05-match-setup-and-lobby.md) §Team play with humans) | +| `step({frames})` / `wait({until_event?, max_ms})` | Step mode / realtime wake-up on events | + +**Seat-scoped.** One MCP server instance per seat by default: its tools only ever see and command that seat, and its +name says so (`zh-seat-2-usa`). An agent that plays several seats (a team, or self-play) uses the **multi-seat** server: +the same tools with a required `seat` argument, where each call is routed to that seat's own connection and token +([04](04-bridge-protocol.md) §Seat scoping). `list_resources({})` shows which seats this server controls. Scoping lives +in the connections, not in the prompt, so a model cannot "forget" and act for the wrong player. + +Text summaries are built here, not in the engine: counts by type, base status, visible threats, idle units, economy — a +"chain of summarization" as in TextStarCraft II. + +## Recipes — for models that know nothing about C&C + +The model may never have seen Generals. Recipes teach the game as well as the API. Plain markdown, one file per recipe, +served verbatim by `docs`, mirrored as MCP resources. + +| Kind | Examples | Source | +|---|---|---| +| **Game primer** | `docs://game/basics` (factions USA/China/GLA, generals, money from supply, power, tech tree, win condition) · `docs://game/` | Hand-written, short | +| **Facts** | `docs://ref/templates/` (cost, build time, prereqs, weapons, what it counters) | **Generated** from the `catalog` request (INI data), so never stale | +| **How-to recipes** | `docs://recipes/start-a-skirmish`, `…/set-up-economy`, `…/build-a-base-`, `…/scout-the-map`, `…/attack-a-target`, `…/defend-a-base`, `…/use-a-general-power`, `…/retreat-and-repair` | Hand-written; each with *use when*, literal `act` calls, `→` gives, one branch per step, *done when*, tips | +| **Gotchas** | "you can only target what you can see", "one dozer builds one thing at a time", "orders execute next frame (or after run-ahead online)", "low power slows production" | Hand-written; linked from error codes | + +Example shape: + +``` +docs://recipes/set-up-economy-usa — get money flowing as USA + +Use when the game just started and you have a Command Center and a Dozer. +Money comes from supply docks via supply trucks/chinooks; power plants keep production fast. + +1. query({filters:{owner:"me", kind:"dozer"}, fields:["id","pos"]}) → dozer.id +2. act({orders:[{order:"build", dozer:"", template:"AmericaPowerPlant", at:""}]}) + error BUILD_SITE_BLOCKED? pick another spot 60+ units away, retry once. +3. act({orders:[{order:"build", dozer:"", template:"AmericaSupplyCenter", at:""}]}) + → the Supply Center spawns a Chinook that harvests automatically. + +Done when query({filters:{owner:"me", template:"AmericaSupplyCenter"}}) returns one object. + +Tips +- Build power first: production is slow while power is negative. +- Never send the only Dozer across the map. +``` + +Template names above are illustrative; generated reference pages hold the real names. + +CI in the external repo replays every fenced call in every recipe against a headless skirmish, and link-checks every `docs://` URI. + +## Done when + +- `pip install` + 20 lines → a random-legal bot plays a full skirmish via Gym. +- An MCP client (e.g. Claude Code) with no C&C knowledge, given only the MCP server, builds a base and wins against Easy AI using recipes. +- Jev example runs at ≥5 decisions/s in realtime mode. diff --git a/docs/plans/2026/09/21/101-bot-agent-api/08-referee-and-tournament.md b/docs/plans/2026/09/21/101-bot-agent-api/08-referee-and-tournament.md new file mode 100644 index 00000000000..7e585394a31 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/08-referee-and-tournament.md @@ -0,0 +1,42 @@ +# 08 — Referee and tournaments (external repo) + +> Part of [overview.md](overview.md). Depends on: [04](04-bridge-protocol.md), [05](05-match-setup-and-lobby.md), [06](06-headless-and-step.md). **Edited from the external repo.** + +## Goal + +Fair, repeatable bot-vs-bot matches without trusting the bots — the role BWAPI's TournamentModule and AI Arena's +`rust-arenaclient` proxy play. + +## Shape + +The referee is a **proxy** between the bot and the game's bridge port. The bot connects to the referee; the referee +connects to the game. + +It is **not** the only enforcement layer (a local bot could try the game's port directly): +- the referee launches the game with the match limits as engine flags (`-botapiMode`, `-botapiMaxOrders`, `-botapiNoRaw`, + `-botapiNoSession`, [04](04-bridge-protocol.md) §Modes and safety), so the engine enforces them itself; +- the referee generates the per-match token, hands it to the game on stdin, and connects first; the bot never sees the token or the game's port; +- for public ladders, bot processes run as a different OS user or in a sandbox/container without access to the game's network namespace (the bridge trusts the OS user account, [04](04-bridge-protocol.md) §Transport). + +| Referee does | How | +|---|---| +| Pins visibility mode (`player` or `camera`) | Rewrites `hello.want`; rejects clients asking for more | +| Time budget | Step mode: per-step budget **plus a time bank** (Lux AI); realtime: nothing to enforce (the game doesn't wait) | +| Forfeit rules | BWAPI-style graded limits as a preset (≥1 step > 10 s, ≥10 > 1 s, ≥320 > 55 ms) | +| APM / order caps | Lowers `max_orders_per_frame`; optional AlphaStar-like caps as a preset | +| Denies escape hatches | `raw` orders, `assist`, session control mid-match | +| Game cap | Max frames → result by score | +| Artifacts | Replay, per-bot logs, result JSON (winner, frames, reason, score stats) | +| Persistent bot storage | `read/` and `write/` dirs between games (BWAPI `bwapi-data` convention) | + +## Match runner + +- Spawns two headless game instances in a LAN game ([05](05-match-setup-and-lobby.md) scenario 4) or skirmish vs built-in AI. +- Map pool, faction selection, seeds from a config file. +- Round-robin + Elo; results as JSON. +- Runs on Linux (Wine or native) and Windows; container recipe for CI and ladders. + +## Done when + +- Two example bots play a 10-game series unattended; every replay re-simulates cleanly. +- A bot requesting `full` visibility through the referee is refused. diff --git a/docs/plans/2026/09/21/101-bot-agent-api/09-verification.md b/docs/plans/2026/09/21/101-bot-agent-api/09-verification.md new file mode 100644 index 00000000000..9e21666e2cd --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/09-verification.md @@ -0,0 +1,39 @@ +# 09 — Verification + +> Part of [overview.md](overview.md). Runs alongside every stage. + +## Tests in this repo + +| Test | Proves | How | +|---|---|---| +| **Replay round-trip** | Bot games are ordinary games | Record a bot-driven skirmish, add the `.rep` to `GeneralsReplays/GeneralsZH/1.04`, run the existing `.github/workflows/reusable-check-replays.yml` (`-jobs 4 -headless -replay`) → zero mismatches | +| **Off = identical** | Zero cost when disabled | Existing replay CI job on a build with the bridge compiled in, flag off → CRCs unchanged; frame time within noise | +| **Observer purity** | Snapshot writer never mutates logic | Replay with snapshots every frame in `full` mode vs without → identical CRC at **every** frame (`-ReplayCRCInterval 1`, `Core/GameEngine/Source/Common/CommandLine.cpp:327-335`, debug CRC builds) | +| **Banned calls** | No mutating getter sneaks in later | Grep-based CI check on `BotBridge/` for the list in [01](01-engine-seams.md) §Unsafe getters (`getShroudedStatus`, `GameLogicRandomValue`, `createGroup`, …) | +| **Fog leak** | `player` mode never exposes shrouded objects | Replay in `player ` mode; for every frame, every non-own object in the snapshot sits on a cell `CLEAR` for n (or is a `stale` structure previously seen) | +| **Stealth / disguise leak** | Undetected stealthed enemies and disguised units never leak | Replays with GLA stealth and disguise: in `player` mode no snapshot contains an undetected stealthed enemy, and disguised units show the disguise template | +| **Bridge auth + limits** | Direct connections can't bypass the referee | Connect without / with wrong token → refused; second client → refused; `hello` asking more than launch flags → granted only the flags | +| **Seat scoping** | A connection can only see and command its own seat | Orders for units of another seat → rejected; observations only ever contain that seat's private data (money, queues) | +| **Framing** | Engine survives malformed peers | Fuzz: length 0, over limit, truncated, unknown kind, invalid JSON → error + close, no crash, game continues | +| **UI parity** | UI-impossible orders are rejected | One case per order type: unowned unit, shrouded target, off-map, unaffordable, illegal build site | +| **Multi-seat replay** (if option B lands) | Orders stamped for a non-local seat replay correctly | Skirmish with one agent on two seats → `-headless -replay` → zero mismatches | +| **Determinism of step mode** | Reproducible episodes | Same seed + same actions → identical observations | + +## Tests in the external repo + +- Protocol schema conformance of real engine output (a pinned game build in CI). +- Every recipe's fenced calls replayed against a headless skirmish; every `docs://` link resolves. +- Example bots finish a match. + +## Cross-platform matrix + +| Game binary | Bot runs on | Transport | +|---|---|---| +| Windows native | Windows | loopback TCP | +| Windows `.exe` under Wine on Linux | Linux native (Python/Rust) | loopback TCP across Wine | +| Native Linux (when #2088 lands) | Linux | loopback TCP | +| Wine/CrossOver on macOS | macOS native | loopback TCP | + +## Done when + +All rows above are automated or have a documented manual run with results linked from `status.yml` `evidence`. diff --git a/docs/plans/2026/09/21/101-bot-agent-api/overview.md b/docs/plans/2026/09/21/101-bot-agent-api/overview.md new file mode 100644 index 00000000000..d060eaeae9a --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/overview.md @@ -0,0 +1,153 @@ +# Bot / Agent API for Zero Hour — Overview + +> Spec only. No code is changed by this plan. Relates to #3328. +> Layout (`docs/plans///
/<1NN>-/`, one file per slice, `status.yml` tracker) follows the +> developerz-ai planning convention — see [Plan layout](#plan-layout). Move or reshape freely. + +## TL;DR + +- **What:** let an external program (scripted bot, RL agent, LLM, fast decision model) play a Zero Hour match as a + normal player — it *observes* what that player can see and *issues* the same `GameMessage`s the mouse and keyboard produce. +- **Why:** BWAPI turned Brood War into the standard RTS AI testbed (AIIDE, CoG, SSCAIT, 2026's self-play bot Pluto). + This repo already has what BWAPI had to reverse-engineer: source, a deterministic lockstep simulation, a headless + mode and a replay checker in CI. +- **How (shape):** + 1. Engine gets a small, **off-by-default** bridge in `Core/`: read-only snapshots + a command inbox. Nothing runs when disabled. + 2. Bot runs in **its own process**, any language, over **loopback TCP** — same on Windows, on Linux (native or under Wine) and macOS (via Wine/CrossOver today). + 3. Commands enter through the **existing `GameMessage` path** → replays, CRC and network sync are unchanged; a bot can do nothing a player can't. + 4. Observation is **fog-respecting by default**; full information only for replays/analysis, never for a ranked game. + 5. **Batch-first protocol:** one request reads a filtered snapshot or delta; one request submits many orders for one frame. Built for 10 Hz decision models and multi-second LLMs, not just C++ callbacks. + ML gets fixed-shape binary tensors + legal-action masks; LLMs get text summaries and recipes that teach the game. + 6. **SDKs, Gym env, MCP server, referee** live in a **separate repo** (per maintainer feedback on #3328), not here. +- **Play like a player:** create skirmishes, host/join LAN games — *my AI vs your AI*, each on its own machine — + with every bot visibly tagged `[BOT]` in its name. One bot per game instance, one port per instance. +- **Order of work:** agree on this spec first — a plan is far cheaper to change than code. Only then the first demo: + observe replays headlessly → one bot-driven skirmish whose replay `-headless -replay` re-simulates with + **zero CRC mismatches** in the existing CI job. +- **Ask of maintainers now:** the open questions at the bottom. No code exists and none is proposed in this PR. + +## Goal + +A stable, versioned, cross-platform contract for driving one Zero Hour player from an external process, which cannot +desync a game, cannot see through the shroud, and adds zero cost when unused. + +## What maintainers already said (#3328) → where this plan answers it + +| Feedback | Answered in | +|---|---| +| xezon: bot is a separate process; simple pipe to the client; bot code in a separate repo, any language | [04-bridge-protocol.md](04-bridge-protocol.md), [07-sdk-gym-mcp.md](07-sdk-gym-mcp.md) | +| xezon: new files go into `Core`; ZH first, then replicate to Generals | [01-engine-seams.md](01-engine-seams.md) §Placement | +| xezon: bot posts `GameMessage`s + observes → no logic change | [03-commands-and-hardening.md](03-commands-and-hardening.md) | +| xezon: no shroud; maybe camera-bound like a human | [02-observation.md](02-observation.md) §Visibility modes | +| xezon: scripted `AIPlayer` stays separate | Out of scope, below | +| bobtista: which frame does the bot observe, when do actions execute, what if it stalls | [04-bridge-protocol.md](04-bridge-protocol.md) §Timing | +| bobtista: headless gaps (ghost objects, game end) | [06-headless-and-step.md](06-headless-and-step.md) | +| bobtista: getters must not mutate state | [01-engine-seams.md](01-engine-seams.md) §Unsafe getters, [09-verification.md](09-verification.md) | +| bobtista: >1 bot per machine needs changes | [05-match-setup-and-lobby.md](05-match-setup-and-lobby.md) §Scenarios — one bot per game process | +| bobtista: `MSG_DO_ATTACK_OBJECT` on shrouded objects possible in code but not UI → harden | [03-commands-and-hardening.md](03-commands-and-hardening.md) §UI parity | +| bobtista: start with replay observation + minimal client; keep TSH diff minimal; demo | Stage 1 = [02](02-observation.md) + [04](04-bridge-protocol.md) read-only | + +## Architecture + +``` + ┌──────────────── game process (generalszh.exe, 32-bit) ────────────────┐ + │ │ + │ GameEngine::update │ + │ GameClient → MessageStream::propagateMessages ──┐ │ + │ BotBridge inbox ─┤ (2) orders as │ + │ ▼ GameMessages │ + │ Network::update ─── TheCommandList ──► GameLogic::update │ + │ processCommandList │ + │ ... object/AI updates │ + │ (1) end of frame N: │ + │ BotBridge snapshot ───┼──┐ + └────────────────────────────────────────────────────────────────────────┘ │ + loopback TCP, length-prefixed, versioned │ + ┌──────────────── bot process (any language, any bitness) ──────────────┐ │ + │ client SDK (C++ / Python / Rust …) ◄─────────────────────────────────┼──┘ + │ ├─ raw bot (BWAPI-style callbacks) │ + │ ├─ Gymnasium / PettingZoo env (RL) │ + │ ├─ MCP server (LLM agents: docs · query · act · step) │ + │ └─ referee / match runner (timeouts, capability veto, results) │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +Three consumer tiers, one wire protocol: + +| Tier | Latency budget | Example | Needs | +|---|---|---|---| +| Reflex (scripted / RL) | ≤ 33 ms (one logic frame) | BWAPI-style bot, PPO policy | deltas, masks, step mode | +| Fast decisions | 70–500 ms (~10 Hz) | TypeSafe **Jev** (state in → typed choices out, questions evaluated in parallel) | compact text/JSON state, closed choice lists, batch orders | +| Strategy (LLM) | seconds | Claude / GPT via MCP | summaries, macro orders, docs, paused/step mode | + +## Plan files, in execution order + +| # | File | Hook | +|---|---|---| +| 00 | [00-prior-art.md](00-prior-art.md) | BWAPI, SC2 API, 0 A.D., Spring, OpenRA-RL, NewShoes (existing ZH agent bridge), Pluto, Jev, LLM-RTS — and the lesson each gives us | +| 01 | [01-engine-seams.md](01-engine-seams.md) | Where commands, frames, fog, headless, CRC and match setup live today (`file:line`); what an observer must never call; platforms | +| 02 | [02-observation.md](02-observation.md) | **Stage 1.** Read-only snapshot, fog-respecting; replay observation first; records / planes / entities encodings | +| 03 | [03-commands-and-hardening.md](03-commands-and-hardening.md) | **Stage 2.** Orders → `GameMessage`; UI-parity validation; action masks; optional logic hardening | +| 04 | [04-bridge-protocol.md](04-bridge-protocol.md) | Loopback TCP, JSON + binary frames, handshake, batch `observe`/`act`, realtime vs step timing, stalls | +| 05 | [05-match-setup-and-lobby.md](05-match-setup-and-lobby.md) | Skirmish / LAN host & join; **my AI vs your AI on separate machines**; 8-player mixed human/agent teams with chat and beacons; one agent on several slots; human + AI co-pilot; `[BOT]` name tag | +| 06 | [06-headless-and-step.md](06-headless-and-step.md) | Headless live match, uncapped speed, step mode, throughput, bot vs bot locally | +| 07 | [07-sdk-gym-mcp.md](07-sdk-gym-mcp.md) | **Separate repo.** SDKs, Gymnasium/PettingZoo (ML), Jev harness, MCP server, recipes for models that don't know C&C | +| 08 | [08-referee-and-tournament.md](08-referee-and-tournament.md) | **Separate repo.** Referee proxy, time budgets, capability veto, match runner | +| 09 | [09-verification.md](09-verification.md) | Replay round-trip in CI, observer purity, fog-leak, UI parity, cross-platform matrix | + +Stages map to #3328: Stage 1 = 01+02+04 (read-only) · Stage 2 = 03 · Stage 3 = 04 full + 05 + 07 · Stage 4 = 06+08. 09 runs across all. + +## Done when + +- A bot process on Windows, Linux and macOS (latter two via Wine where the game is not native) connects to a running + Zero Hour skirmish, observes only what its player sees, issues orders, and wins or loses a match. +- That match's replay re-simulates with `-headless -replay` with zero CRC mismatches, in the existing + `reusable-check-replays.yml` job. +- With the bridge disabled (default), CRC, replays and frame time are identical to a build without it. +- Two people, each with their own bot on their own machine, play one LAN game; both bots are tagged `[BOT]`. +- An 8-player LAN game with mixed teams (1 human + 3 agents vs 4 agents) runs at normal speed, and agents respond to team chat. +- An MCP client (LLM) with no prior C&C knowledge and a Gym env (ML) both drive a match through the same protocol. +- ZH first; Generals replica of any non-`Core` change follows per `CONTRIBUTING.md`. + +## Out of scope (v1) + +- Changing `AIPlayer` / `AISkirmishPlayer` (stays separate — xezon). +- Online matchmaking / ranked play for bots — a community policy decision. LAN, direct-connect and private games between + people who each run their own bot **are** in scope ([05](05-match-setup-and-lobby.md)). +- Pixel / rendered-frame observation and mouse-level input (world-model agents like SIMA / Genie). Noted as a later + channel in [02](02-observation.md) §Later. +- Several bots inside one game process (multi-seat is a later, skirmish-only option; see [05](05-match-setup-and-lobby.md)). + +## Risks + +| Risk | Mitigation | +|---|---| +| Observer mutates logic state → desync | Snapshot built only from pure reads; banned-call list in [01](01-engine-seams.md); purity test in [09](09-verification.md) | +| Bot sees through shroud (maphack) — the reported Pluto ladder incident is exactly this | Fog-respecting default; full-info only in replay/analysis mode, refused in network games | +| Bot sends commands the UI can't (shrouded attack) | UI-parity checks client-side in the bridge first (no CRC impact); logic-side fixes guarded like other `RETAIL_COMPATIBLE_CRC` changes | +| Slow bot freezes the game | Realtime mode never waits; step mode only in skirmish/headless, with a referee time budget | +| Diff too big to review | Hooks in engine ≈ a few small files; everything else external | + +## Open questions for maintainers + +1. **Where do orders enter?** Inject into `TheCommandList` right after `propagateMessages()` (`GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp:910`) — bypassing UI translators — or into `TheMessageStream` so translators see them? Plan assumes the former. +2. **Observation hook:** end of `GameLogic::update` just before `m_frame++` (`GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp:3935`) so it also fires in headless replay simulation, which never calls `GameEngine::update`. OK? +3. **Object visibility:** `Object::getShroudedStatus` mutates `PartitionData` caches. Reimplement its rules as a pure read, or accept calling it from logic in fixed order? Plan prefers pure. +4. **UI-parity hardening in logic** (reject attacks on shrouded targets etc.) — separate PRs behind `RETAIL_COMPATIBLE_CRC`, or bridge-side only until retail compatibility can be broken? +5. **Camera-bound mode** as xezon suggested — required, optional, or later? +6. **Wire format:** length-prefixed JSON first (C++98/VC6-friendly, no new deps) with a binary encoding later — acceptable? +7. **Separate repo name/ownership** under the org, or a personal repo until it proves itself? +8. **Build gating:** CMake option (off by default) + command-line flag, or command-line flag only? +9. **Disclosure:** `[BOT]` suffix in the player name (visible to retail clients and in replays) — OK, or another form? +10. **One agent, several slots:** is a skirmish/headless-only multi-seat mode (one process, orders stamped per seat) acceptable later, or should it always be one process per slot? See [05](05-match-setup-and-lobby.md) §One agent, several player slots. +11. **Plan location:** keep `docs/plans/…` in-tree, move to the wiki, or keep only in the external repo? + +## Plan layout + +This directory uses the planning convention we use at developerz-ai for AI-assisted work: dated dir, `overview.md` +as the map, one independently executable slice per separable area, `status.yml` as the only tracker, `file:line` +references instead of pasted code. Background: +([workflow-commands.md](https://github.com/developerz-ai/gold-standards-in-ai/blob/main/docs/writing-for-agents/workflow-commands.md)). +If the project prefers a different shape or place, move it — the content matters, not the layout. + +All `file:line` references verified against `main` at `241f01926` (2026-09-21). diff --git a/docs/plans/2026/09/21/101-bot-agent-api/status.yml b/docs/plans/2026/09/21/101-bot-agent-api/status.yml new file mode 100644 index 00000000000..73ecb73c763 --- /dev/null +++ b/docs/plans/2026/09/21/101-bot-agent-api/status.yml @@ -0,0 +1,21 @@ +plan: 101-bot-agent-api +title: Bot / Agent API for Zero Hour +status: not_started # not_started | in_progress | blocked | complete | superseded +created_by: sebi +worked_by: "" +owner: sebi +percent: 0 +current_focus: "spec review with maintainers (#3328) — open questions in overview.md" +slices: + - { file: 00-prior-art.md, status: complete, percent: 100 } + - { file: 01-engine-seams.md, status: complete, percent: 100 } + - { file: 02-observation.md, status: not_started, percent: 0 } + - { file: 03-commands-and-hardening.md, status: not_started, percent: 0 } + - { file: 04-bridge-protocol.md, status: not_started, percent: 0 } + - { file: 05-match-setup-and-lobby.md, status: not_started, percent: 0 } + - { file: 06-headless-and-step.md, status: not_started, percent: 0 } + - { file: 07-sdk-gym-mcp.md, status: not_started, percent: 0 } + - { file: 08-referee-and-tournament.md, status: not_started, percent: 0 } + - { file: 09-verification.md, status: not_started, percent: 0 } +evidence: ["#3328", "#3338"] +last_updated: 2026-09-21