Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"worldbuilder": {
"command": "uv",
"args": ["run", "--directory", "scripts/worldbuilder-mcp", "server.py"]
},
"game": {
"command": "uv",
"args": ["run", "--directory", "scripts/worldbuilder-mcp", "game_server.py"]
}
}
}
40 changes: 39 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Zero Hour targets are prefixed `z_` (Generals uses `g_`):

### Key build options (`cmake/config-build.cmake`)

`RTS_BUILD_ZEROHOUR` (default ON), `RTS_BUILD_GENERALS` (turn OFF to skip original Generals), `RTS_BUILD_OPTION_DEBUG`, `RTS_BUILD_OPTION_PROFILE`, `RTS_DEBUG_LOGGING`, `RTS_DEBUG_CRASHING`, `RTS_DEBUG_STACKTRACE`, `RTS_BUILD_OPTION_FFMPEG`. Debug defines: `RTS_DEBUG WWDEBUG DEBUG`; Release: `RTS_RELEASE NDEBUG`.
`RTS_BUILD_ZEROHOUR` (default ON), `RTS_BUILD_GENERALS` (turn OFF to skip original Generals), `RTS_BUILD_OPTION_DEBUG`, `RTS_BUILD_OPTION_PROFILE`, `RTS_DEBUG_LOGGING`, `RTS_DEBUG_CRASHING`, `RTS_DEBUG_STACKTRACE`, `RTS_BUILD_OPTION_FFMPEG`, `RTS_BUILD_OPTION_GAME_MCP` (default ON; the game MCP bridge, see below). Debug defines: `RTS_DEBUG WWDEBUG DEBUG`; Release: `RTS_RELEASE NDEBUG`.

## Architecture

Expand Down Expand Up @@ -155,3 +155,41 @@ The pieces build on each other; files live in `worldbuilder_factions\` next to t
- Server tools: `scripts/worldbuilder-mcp/server.py`; logic that needs no bridge is in `faction.py`, `framework.py`, `scaffold.py`, `aidata.py`, `match.py`.
- AI matches are an engine feature (`GameLogic/AIMatch.h`): `generalszh.exe -aiMatch <map> -aiPlayers Faction:difficulty,... -aiMatchLog <file> [-headless]`.
- After a change: build `z_worldbuilder`, copy `WorldBuilderZH.exe` to the mod folder, restart it with `-mcp -ignoreAsserts`, run `python scripts/worldbuilder-mcp/smoke_test.py` (it replaces the open map with a test map) and extend that test for new commands.

## Game MCP

A second MCP server drives the running game itself, so features can be tested by playing: start a skirmish as the local player (alone in a sandbox or against AI), pause, step frames, inspect every object and player, give orders, spawn and change things, run scripts, take screenshots. Use its tools (`mcp__game__*`) for that. Full reference: the game section of `scripts/worldbuilder-mcp/README.md`.

```
Claude <-- stdio --> scripts/worldbuilder-mcp/game_server.py <-- TCP 127.0.0.1:47801 --> generalszh.exe -mcp
```

### Running it

- The bridge exists only in builds with `RTS_BUILD_OPTION_GAME_MCP` and only listens when the game is started with `-mcp`. Without the flag nothing changes for normal play.
- `game_launch` uses `generalszh.exe` in the mod folder (`GAME_DIR`, default `F:\Operation-Firestorm-2.0`; `GAME_EXE` picks another exe name). That exe must be an MCP build: if the game starts but the bridge never answers, the installed exe is older than the feature. After changing engine code, rebuild `z_generals` and copy it there (ask first, keep a `.bak` of the user's exe).
- One game at a time: `game_launch` fails while a game with the bridge runs; `game_quit` it first. If a tool says "Cannot reach the game", no game runs with `-mcp` (or it hangs in a dialog).
- A game started by hand works too: `generalszh.exe -mcp -mcpGame "<map>" -mcpPlayers FactionAmerica,FactionChina:hard -mcpStartPaused -win -quickstart -ignoreAsserts`, run from the mod folder.

### Testing a feature

1. **Start**: `game_launch(map, faction, opponents)`. `map` is a multiplayer map name or part of its path (`"armed neighbors"` is known to work). `faction` is a player template (`FactionAmerica`, `FactionChina`, `FactionGLA`, generals such as `FactionAmericaAirForceGeneral`, or `Observer`). `opponents` are AI players as `"FactionChina:hard"`; none = a **sandbox** where nobody wins or loses. It returns paused at frame 1, before anything has happened. Use `headless=true` when no picture is needed (much faster, no `view_*`); `seed` repeats a game exactly.
2. **Find things**: `players_list` gives internal player names (`player0` is usually the local player; `"local"` and `"neutral"` work everywhere). `objects_list` filters by `player`, `template` (substring), `kindof`, `status` and area; `objects_templates(contains=...)` gives exact template names. Never guess template, upgrade, science or special power names: look them up (`objects_templates`, `objects_get` sections `upgrades` / `special_powers`, or the INIs in `F:\Operation-Firestorm-2.0\Data\INI`).
3. **Set up the situation**: `objects_spawn(template, x, y, player=..., count=...)` puts units or buildings anywhere, for any player (an AI player's name makes them enemies). `objects_set` changes health, position, veterancy, gives object upgrades, sets status flags. `players_set` gives money, sciences, player upgrades, `instant_build`, and `special_power_delays=false` for instant recharge.
4. **Act**: `orders_*` (move, attack, guard, stop, build, produce, upgrade, special power, buy science, sell) as the owning player would click. Orders are queued and carried out on the **next logic frame**, so give them while paused and then step.
5. **Advance time**: `game_step(frames=N)` or `game_step(seconds=S)` runs that long and pauses again (30 frames = 1 second). `game_set_speed(0)` before long steps makes them run as fast as possible. `game_resume` lets it run freely; `game_pause` stops it.
6. **Check the result**: `objects_get(ids, sections=[...])` for health, damage state, status/disabled flags, weapons (status, ammo, range, reload frames), AI state and goal, upgrades, special powers (ready, percent), production queue, passengers, behavior modules with their INI tags, model conditions. `players_get` for money, power, sciences, upgrades, score, objects by type. `game_messages` for what scripts and the AI reported (pass `next_serial` as `since` to get only new ones). Compare numbers before and after a step to measure damage, speed, timing.
7. **Look** (windowed only): `view_screenshot(id=...)` centers on an object and returns the picture; `view_camera` moves or zooms.
8. **End**: always `game_quit` when done, so the next session can launch.

- Coordinates are world units (10 per cell, (0,0) lower left of the playable area, z = terrain height when left out); angles are degrees, counterclockwise, 0 = +x. Take positions from the units in `objects_list` (e.g. the local command center) to place things near the base.
- `objects_spawn` / `objects_set` / `players_set` change the game directly and are not recorded in the replay; orders are.
- Scripts: `scripts_run`, `scripts_set_active`, `scripts_flags` / `scripts_counters` (read, or set with `set={...}`).
- Windowed games must not be minimized (the engine sleeps while iconic); an unfocused window is fine.
- `game_command(cmd, args)` runs any bridge command; `game_command("list_commands")` lists them with their arguments.

### Working on the game MCP

- C++: `GeneralsMD/Code/GameEngine/Source/Common/Mcp/` (`GameMcp.cpp` command line and main-thread side, `GameMcpServer.cpp` socket thread, `GameMcpCmd*.cpp` commands registered with `gameMcpRegisterCommand`, failing with `gameMcpFail`). Every hook in other files is inside `#ifdef RTS_GAME_MCP`. Handlers run on the main thread between logic frames (polled in `GameEngine::update`, or in `GameMcp::runHeadless`).
- Skirmish setup without the menu is shared with AI matches: `GameLogic/SkirmishLaunch.h`.
- After a change: build `z_generals`, copy `generalszh.exe` to the mod folder under another name and run `python scripts/worldbuilder-mcp/game_smoke_test.py --exe <name>` (add `--windowed` for the view commands); extend it for new commands.
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ static PoolSizeRec PoolSizes[] =
{ "RadiusDecalBehavior", 32, 32 },
{ "SpecialPowerDesignatorUpdate", 32, 32 },
{ "BattlePlanUpdate", 32, 32 },
{ "AllBattlePlansUpdate", 32, 32 },
{ "LifetimeUpdate", 32, 32 },
{ "LocomotorSetUpgrade", 512, 128 },
{ "LockWeaponCreate", 64, 128 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@

void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality);

#ifdef RTS_GAME_MCP
// Writes the current back buffer to a file right away, for the game's MCP bridge.
bool W3D_WriteScreenshotFile(const char* pathname, ScreenshotFormat format, Int jpegQuality);
#endif

// Called once per frame on the main thread to show messages for screenshots
// that the screenshot thread has finished writing.
void W3D_UpdateScreenshotMessages();
Expand Down
93 changes: 64 additions & 29 deletions Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,9 @@ struct ScreenshotWrittenMessage
};
static MPSCIntrusiveQueue<ScreenshotWrittenMessage> s_screenshotWrittenQueue;

static DWORD WINAPI screenshotThreadFunc(LPVOID param)
// Converts the captured pixels and writes them to a file. Returns non-zero on success.
static int writeScreenshotImage(const ScreenshotThreadData* data, const char* pathname)
{
ScreenshotThreadData* data = static_cast<ScreenshotThreadData*>(param);

// TheSuperHackers @feature bobtista 08/07/2026 Save screenshots into a Screenshots subfolder
// to keep the user data root folder tidy.
char pathname[_MAX_PATH];
strlcpy(pathname, data->userDataDirectory, ARRAY_SIZE(pathname));
strlcat(pathname, "Screenshots\\", ARRAY_SIZE(pathname));
CreateDirectory(pathname, nullptr);
strlcat(pathname, data->leafname, ARRAY_SIZE(pathname));

const unsigned int width = data->width;
const unsigned int height = data->height;

Expand Down Expand Up @@ -121,6 +112,25 @@ static DWORD WINAPI screenshotThreadFunc(LPVOID param)
break;
}

delete[] image;

return success;
}

static DWORD WINAPI screenshotThreadFunc(LPVOID param)
{
ScreenshotThreadData* data = static_cast<ScreenshotThreadData*>(param);

// TheSuperHackers @feature bobtista 08/07/2026 Save screenshots into a Screenshots subfolder
// to keep the user data root folder tidy.
char pathname[_MAX_PATH];
strlcpy(pathname, data->userDataDirectory, ARRAY_SIZE(pathname));
strlcat(pathname, "Screenshots\\", ARRAY_SIZE(pathname));
CreateDirectory(pathname, nullptr);
strlcat(pathname, data->leafname, ARRAY_SIZE(pathname));

const int success = writeScreenshotImage(data, pathname);

if (success)
{
ScreenshotWrittenMessage* message = new ScreenshotWrittenMessage;
Expand All @@ -132,7 +142,6 @@ static DWORD WINAPI screenshotThreadFunc(LPVOID param)
DEBUG_LOG(("Failed to write screenshot %s", pathname));
}

delete[] image;
delete data;

return success;
Expand All @@ -152,20 +161,9 @@ void W3D_UpdateScreenshotMessages()
}
}

void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
// Copies the back buffer. Returns null if its format is not supported.
static ScreenshotThreadData* captureBackBuffer(ScreenshotFormat format, Int jpegQuality)
{
static constexpr const char* const ScreenshotFormatExtensions[] = { "jpg", "png" };
static_assert(ARRAY_SIZE(ScreenshotFormatExtensions) == SCREENSHOT_FORMAT_COUNT, "Incorrect array size");

// The filename is created here so the timestamp matches the capture time.
char leafname[_MAX_FNAME];
const char* extension = ScreenshotFormatExtensions[format];

SYSTEMTIME st;
GetLocalTime(&st);
sprintf(leafname, "sshot_%04d%02d%02d_%02d%02d%02d_%03d.%s",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, extension);

// TheSuperHackers @bugfix xezon 21/05/2025 Get the back buffer and create a copy of the surface.
// Originally this code took the front buffer and tried to lock it. This does not work when the
// render view clips outside the desktop boundaries. It crashed the game.
Expand All @@ -182,7 +180,7 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
{
DEBUG_LOG(("Screenshot does not support back buffer format %d", (int)surfaceDesc.Format));
surface->Release_Ref();
return;
return nullptr;
}

SurfaceClass* surfaceCopy = NEW_REF(SurfaceClass, (DX8Wrapper::_Create_DX8_Surface(surfaceDesc.Width, surfaceDesc.Height, surfaceDesc.Format)));
Expand All @@ -201,7 +199,7 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
if (lrect.pBits == nullptr)
{
surfaceCopy->Release_Ref();
return;
return nullptr;
}

ScreenshotThreadData* threadData = new ScreenshotThreadData();
Expand All @@ -211,8 +209,6 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
threadData->is16Bit = is16Bit;
threadData->quality = jpegQuality;
threadData->format = format;
strlcpy(threadData->userDataDirectory, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(threadData->userDataDirectory));
strlcpy(threadData->leafname, leafname, ARRAY_SIZE(threadData->leafname));

// Copy the locked surface with a single memcpy, including any row padding. The pixel
// conversion and all file operations are done on the screenshot thread to keep the
Expand All @@ -224,6 +220,31 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
surfaceCopy->Release_Ref();
surfaceCopy = nullptr;

return threadData;
}

void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
{
static constexpr const char* const ScreenshotFormatExtensions[] = { "jpg", "png" };
static_assert(ARRAY_SIZE(ScreenshotFormatExtensions) == SCREENSHOT_FORMAT_COUNT, "Incorrect array size");

// The filename is created here so the timestamp matches the capture time.
char leafname[_MAX_FNAME];
const char* extension = ScreenshotFormatExtensions[format];

SYSTEMTIME st;
GetLocalTime(&st);
sprintf(leafname, "sshot_%04d%02d%02d_%02d%02d%02d_%03d.%s",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, extension);

ScreenshotThreadData* threadData = captureBackBuffer(format, jpegQuality);
if (threadData == nullptr)
{
return;
}
strlcpy(threadData->userDataDirectory, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(threadData->userDataDirectory));
strlcpy(threadData->leafname, leafname, ARRAY_SIZE(threadData->leafname));

const HANDLE hThread = CreateThread(nullptr, 0, screenshotThreadFunc, threadData, 0, nullptr);
if (hThread)
{
Expand All @@ -234,3 +255,17 @@ void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
delete threadData;
}
}

#ifdef RTS_GAME_MCP
bool W3D_WriteScreenshotFile(const char* pathname, ScreenshotFormat format, Int jpegQuality)
{
ScreenshotThreadData* data = captureBackBuffer(format, jpegQuality);
if (data == nullptr)
{
return false;
}
const int success = writeScreenshotImage(data, pathname);
delete data;
return success != 0;
}
#endif
27 changes: 27 additions & 0 deletions GeneralsMD/Code/GameEngine/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ set(GAMEENGINE_SRC
# Include/Common/LocalFileSystem.h
# Include/Common/MapObject.h
Include/Common/MapReaderWriterInfo.h
Include/Common/McpJson.h
# Include/Common/MessageStream.h
# Include/Common/MiniLog.h
# Include/Common/MiscAudio.h
Expand Down Expand Up @@ -263,6 +264,7 @@ set(GAMEENGINE_SRC
Include/GameLogic/Module/BaikonurLaunchPower.h
Include/GameLogic/Module/BaseRegenerateUpdate.h
Include/GameLogic/Module/BattleBusSlowDeathBehavior.h
Include/GameLogic/Module/AllBattlePlansUpdate.h
Include/GameLogic/Module/BattlePlanUpdate.h
Include/GameLogic/Module/BehaviorModule.h
Include/GameLogic/Module/BodyModule.h
Expand Down Expand Up @@ -487,6 +489,7 @@ set(GAMEENGINE_SRC
Include/GameLogic/ScriptEngine.h
Include/GameLogic/Scripts.h
Include/GameLogic/SidesList.h
Include/GameLogic/SkirmishLaunch.h
Include/GameLogic/Squad.h
Include/GameLogic/TerrainLogic.h
Include/GameLogic/TurretAI.h
Expand Down Expand Up @@ -571,6 +574,7 @@ set(GAMEENGINE_SRC
Source/Common/GameLOD.cpp
Source/Common/GameMain.cpp
Source/Common/GlobalData.cpp
Source/Common/McpJson.cpp
# Source/Common/INI/INI.cpp
# Source/Common/INI/INIAiData.cpp
# Source/Common/INI/INIAnimation.cpp
Expand Down Expand Up @@ -985,6 +989,7 @@ set(GAMEENGINE_SRC
Source/GameLogic/Object/Update/AutoDepositUpdate.cpp
Source/GameLogic/Object/Update/AutoFindHealingUpdate.cpp
Source/GameLogic/Object/Update/BaseRenerateUpdate.cpp
Source/GameLogic/Object/Update/AllBattlePlansUpdate.cpp
Source/GameLogic/Object/Update/BattlePlanUpdate.cpp
Source/GameLogic/Object/Update/BoneFXUpdate.cpp
Source/GameLogic/Object/Update/CheckpointUpdate.cpp
Expand Down Expand Up @@ -1084,6 +1089,7 @@ set(GAMEENGINE_SRC
Source/GameLogic/System/GameLogic.cpp
# Source/GameLogic/System/GameLogicDispatch.cpp
# Source/GameLogic/System/RankInfo.cpp
Source/GameLogic/System/SkirmishLaunch.cpp
# Source/GameNetwork/Connection.cpp
# Source/GameNetwork/ConnectionManager.cpp
# Source/GameNetwork/DisconnectManager.cpp
Expand Down Expand Up @@ -1250,6 +1256,23 @@ else()
endif()


if(RTS_BUILD_OPTION_GAME_MCP)
list(APPEND GAMEENGINE_SRC
Include/Common/GameMcp.h
Source/Common/Mcp/GameMcp.cpp
Source/Common/Mcp/GameMcpCmdCheats.cpp
Source/Common/Mcp/GameMcpCmdObjects.cpp
Source/Common/Mcp/GameMcpCmdOrders.cpp
Source/Common/Mcp/GameMcpCmdPlayers.cpp
Source/Common/Mcp/GameMcpCmdScripts.cpp
Source/Common/Mcp/GameMcpCmdState.cpp
Source/Common/Mcp/GameMcpCmdView.cpp
Source/Common/Mcp/GameMcpCommands.cpp
Source/Common/Mcp/GameMcpCommands.h
Source/Common/Mcp/GameMcpServer.cpp
)
endif()

add_library(z_gameengine STATIC)

target_sources(z_gameengine PRIVATE ${GAMEENGINE_SRC})
Expand All @@ -1272,6 +1295,10 @@ target_link_libraries(z_gameengine PUBLIC
z_wwvegas
)

if(RTS_BUILD_OPTION_GAME_MCP)
target_link_libraries(z_gameengine PUBLIC ws2_32)
endif()

target_precompile_headers(z_gameengine PRIVATE
[["Utility/CppMacros.h"]] # Must be first, to be removed when abandoning VC6
# Include/Precompiled/PreRTS.h
Expand Down
Loading
Loading