M5.2 — The C# / C++ split¶
What you'll learn
- The division of labor: C# orchestrates and judges; C++ acts inside the game.
- Why the two halves communicate through markers/state, not direct calls.
- How to decide which half a given piece of test logic belongs in.
How it applies (QA)
A test split wrong is a test that's hard to maintain and hard to debug: game logic stuffed into the C# harness can't see game state; harness logic pushed into C++ can't make the final call or manage the session. Getting the boundary right is what makes a multi-step test legible to the next owner — often you, months later.
Concepts¶
Two processes, two languages, two jobs¶
A controller-driven Gauntlet test spans a process boundary:
C# — UnrealTestNode |
C++ — TestController |
|
|---|---|---|
| Runs in | the UAT/Gauntlet harness (outside the game) | the game process (inside the engine) |
| Owns | session launch, roles, devices, the verdict, reading logs/artifacts | in-game actions, reading live game state, emitting markers |
| Sees | logs, exit codes, /Saved, the run context |
the world, actors, gameplay, frame timing |
| Can't | reach into the running game's memory directly | decide the run's pass/fail or manage the session |
The harness is the director; the controller is the actor. The director never walks on stage; the actor never decides whether the show passed review.
They talk through markers, not calls¶
The C# node and the C++ controller are in different processes, so there's no direct method call between them. They communicate through a thin, observable channel:
- The controller emits signals outward — log markers (a unique string in the game log), state
the harness can poll, or files written to
/Saved. - The harness observes that channel from outside — parsing the role's log for markers, checking state/artifacts — and folds it into the verdict.
This indirection is a feature: the channel is exactly what ends up in your artifacts, so a failure is already documented in the logs you collect (M2.4). It also keeps the controller ignorant of the verdict — it reports facts ("step 3 done," "damage applied"); the harness interprets them.
Deciding which half¶
Ask where the information or action lives:
- Needs to happen in the world (move, spawn, load, input) → C++ controller.
- Needs live game state the logs don't already expose → C++ controller (which then emits it).
- Needs to launch/sequence roles, read logs/artifacts, or set the verdict → C# node.
- Is already in the log → no controller; the C# node just reads it.
A clean test often has a small controller (drive + emit a few markers) and a small node (launch + watch for those markers + judge). Bloat on either side usually means logic is on the wrong half.
Pitfall: putting the verdict in C++
It's tempting to have the controller decide "passed" and just print it. But the controller can't see crashes on other roles, session-level timeouts, or artifact-level problems — only the harness has that whole-run view. Let the controller report observations; keep the verdict in the C# node, where the full picture lives. A controller that self-certifies will call a run green while another role is on fire.
Worked example — sorting a host-migration test¶
For the M3.2 host-migration scenario driven with a controller, sort the logic:
| Piece of logic | Half | Why |
|---|---|---|
| Launch 1 server + 3 clients on devices | C# node | session/role/device orchestration |
| Kill the server at the defined mid-match moment | C# node (session control) | it's a session/process action, not an in-world one |
| Detect "I was promoted to host" in a client | C++ controller → marker | live game state, then emitted |
| Confirm match continued for survivors | C++ controller → markers | in-game observation per client |
| Decide the run passed | C# node | needs all roles' markers + no crashes anywhere |
Note the kill sits on the session side (C#) because it's process lifecycle, while the reaction to it is observed in-game (C++) and then judged back in C#.
Lab — Split your M4 boot test's 'reached playable' extension
Take the "reached first playable" test from the M4.3 lab. Now you must also confirm the player can open the pause menu once playable. Draw the two-column split: list every action/observation and put it under C# node or C++ controller, then write the one marker the node waits on to pass. Flag any item you're unsure about.
Exercise 1 — Wrong half
A test's TestController parses all the role logs and prints FINAL: PASS, and the C# node just
forwards that. Name two whole-run failures this design will miss.
Exercise 2 — Channel
Why can't the C++ controller simply call a method on the C# UnrealTestNode to report a step?
What does it do instead?
Self-check — answers
Lab: C++ controller — possess pawn / confirm playable, open the pause menu, confirm it's
open, emit PauseMenuShown. C# node — launch the role(s), set MaxDuration, watch for the
PauseMenuShown marker (+ the earlier playable marker), judge pass/fail, fold in crash detection.
The node passes on seeing PauseMenuShown with no crash/timeout. "How the player input is
injected to open the menu" is a good verify on a real build flag.
Exercise 1: It will miss (a) crashes/fatals on roles other than the one the controller runs
in, and (b) session-level problems the controller can't see — a role that never launched, a
timeout, an artifact//Saved issue. The controller has a single-process view; the verdict needs
the whole-run view.
Exercise 2: They're in different processes, so there's no shared call stack to invoke. The
controller emits an observable signal — a log marker, polled state, or a /Saved file — and the
C# node reads that channel from outside. The indirection also means the signal is captured in the
artifacts automatically.
Done when
- [ ] You can state which half owns orchestration/verdict vs. in-game action/state.
- [ ] You can explain why the halves communicate through markers/state across a process boundary.
- [ ] You can sort pieces of a test into C# node vs. C++ controller.
- [ ] You can explain why the verdict must stay in the C# node.