Skip to content

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.

Next: M5.3 — Assertions, markers, capture.