- name
- glue-live-game-testing
- description
- Testing GlueControl's embedded runtime (CommandReceiver, GlueControlManager) against a real running game process. Triggers: LiveGameProcess, live edit runtime behavior, ScreenManager.Start vs MoveToScreen, testing Embedded/*.cs, Category=LiveGame.
# Glue Live Game Testing
`GameCommunicationPlugin/GlueControl/Embedded/*.cs` (CommandReceiver, GlueControlManager, EditingManager,
...) is `<Compile Remove>`d from `GameCommunicationPlugin.csproj` — it only exists inside a compiled,
running game process, never inside Glue itself. [glue-unit-test-bootstrap](../glue-unit-test-bootstrap/SKILL.md)'s
`GoldProjectCompileTests` prove that closure *compiles*; they never run it. If a bug is in what
`CommandReceiver.HandleDto` actually *does* at runtime (screen transitions, entity selection, edit-mode
state), a compile-only test cannot catch it — you need a real running game process.
## The harness
`GlueUnitTests/TestSupport/LiveGameProcess.cs` builds a gold project, launches its real built `.exe`, and
drives it through Glue's actual `CommandSender`/`GameJsonCommunicationPlugin.Common.GameConnectionManager`
socket protocol — the same wire protocol production Glue uses, not a stand-in.
```csharp
[Trait("Category", "LiveGame")]
[StaFact]
public async Task MyTest()
{
GlueTestBootstrap.EnsureGameProjectPluginsRegistered();
using var game = await LiveGameProcess.StartAsync(
"Samples/EditorTest1",
csprojRelativeToProjectRoot: "EditorTest1/EditorTest1.csproj",
exeRelativeToProjectRoot: "EditorTest1/bin/Debug/net9.0/EditorTest1.exe");
var screenName = await game.GetCurrentScreenName(); // "" if no screen loaded
var response = await game.SelectEntity("Entities\\Entity1");
}
```
`StartAsync` (default `refreshLiveEditCodeFromSource: true`) loads the copied project into Glue and calls
`GoldProject.EmbedLiveEditCode()` before building, so the test exercises the CURRENT branch's
`Embedded/*.cs`, not whatever was checked in. See `LiveGameProcessTests.cs` for worked examples.
The `afterBuildBeforeLaunch` hook changes the project after the build, for tests where the running game
is behind Glue (see `EditorTest1_SharedVariableChangedAfterBuild_IsAppliedByConnectSweep`).
## Adding a new drive/observe method
Follow `GetCurrentScreenName()`/`SelectEntity()`: build the real DTO
(`GameCommunicationPlugin.GlueControl.Dtos`) and call `CommandSender.Self.Send(dto)` directly.
**Landmine — do not set `GlueState.Self.CurrentEntitySave`/`CurrentScreenSave`/`CurrentNamedObjectSave` to
drive selection.** Their setters route through `GlueState.Find.TreeNodeByTag(value)`, which needs a real,
populated WPF tree view. There isn't one in this headless host, so the assignment silently no-ops
(`CurrentElement` stays null) and `RefreshManager.PushGlueSelectionToGame` sends nothing — no exception, no
signal, just a test that mysteriously never sees the effect. Skip Glue's UI-bound selection state entirely
and build the DTO by hand.
## Only `Samples/EditorTest1` works as a target project today
`LiveGameProcess` needs a project whose `Game1.Generated.cs` already constructs
`GlueControlManager`/`GameConnectionManager` — i.e. one that went through a real "enable live edit" Glue
session. That wiring comes from `MainCompilerPlugin.HandleGluxLoaded` (`Game1GlueControlGenerator`), and
`MainCompilerPlugin` cannot run in the test host (it builds real WPF tabs and opens sockets on
registration — same reason `GoldProject.EmbedLiveEditCode()` calls `EmbeddedCodeManager.EmbedAll` directly
instead of going through it). So `Game1.Generated.cs` can't be regenerated headlessly; `LiveGameProcess`
preserves it exactly as checked in (only patching its port) rather than deleting/regenerating it.
`Samples/EditorTest1` is checked in with its `Generated.cs` committed - a `.gitignore` exception like
`BeefballKni`'s, since `*.Generated.cs` is gitignored repo-wide otherwise. To add another target project:
turn on live edit for real in a real Glue session, copy the project in the same way (exclude `bin`/`obj`,
keep the sibling `.sln` - `ProjectLoader` needs it), add the same two-line `.gitignore` exception.
## Wire protocol, if you need to touch it
Game connects OUT to Glue (Glue listens). Two separate TCP sockets, one per direction, each opened with a
1-byte handshake (`1` = glue→game, `2` = game→glue) — see `GameCommunicationPlugin/Common/GameConnectionManager.cs`
(Glue-side, server) and `GlueControl/Embedded/GameConnectionManager.cs` (game-side, client, namespace
`GlueCommunication`). Port is baked into `Game1.Generated.cs` as a literal int at two call sites (was
`8846` in the checked-in fixture) - `LiveGameProcess` text-patches both before building, to a fresh port
per run so it never collides with a real Glue instance on the dev machine.
The actual DTO dispatch is `GlueControlManager.ProcessMessage` (`Embedded/GlueControlManager.cs`) -
`"GetCurrentScreen"` is a raw-string command handled specially; everything else is `"{DtoTypeName}:{json}"`
routed to `CommandReceiver.Receive`/`HandleDto(SelectObjectDto)` etc.
## CI
Tagged `Category=LiveGame` and run by its own step in `pr-tests.yml`, ahead of the slower build smoke test.
`glue.yml` still excludes it, since that workflow publishes releases and should not wait on a game window.
Locally: `dotnet test ... --filter "Category=LiveGame"`.
**Landmine — the runners have no GPU.** `opengl32.dll` resolves to Windows' generic OpenGL 1.1, which has
no framebuffer objects, so MonoGame's `GraphicsDevice` throws `NoSuitableGraphicsDeviceException` and the
game dies before it can connect. `pr-tests.yml`'s "Install Mesa llvmpipe" step downloads a software GL and
points `FRB_LIVE_GAME_TEST_GL_RUNTIME` at it; `LiveGameProcess` copies those DLLs next to each game, since
Windows resolves `opengl32.dll` from the exe's own directory ahead of System32. Every game runs from its
own temp directory, so CI cannot stage them at a fixed path.
Voir sur GitHub