Skip to content

Add a live coop test harness (autonomous two-client testing)#30

Open
NonPolynomialTim wants to merge 10 commits into
xcomcoopdev:mainfrom
NonPolynomialTim:test/coop-harness-consolidated
Open

Add a live coop test harness (autonomous two-client testing)#30
NonPolynomialTim wants to merge 10 commits into
xcomcoopdev:mainfrom
NonPolynomialTim:test/coop-harness-consolidated

Conversation

@NonPolynomialTim

Copy link
Copy Markdown
Collaborator

Add a live coop test harness (autonomous two-client testing)

Why

There's currently no automated way to test coop end-to-end: every change to the netcode is validated by hand, two people clicking through a session. This PR adds a live test harness, an in-game command server plus Python drivers that spawn real game instances, drive them through menus/saves/lobby/geoscape/battle, and assert that host and client stay in sync. It runs unattended, needs no external mod and no pre-existing save, and builds on a stock checkout.

What the harness is

An in-game command server (src/CoopMod/TestServer.{h,cpp}, active only when the OXC_TEST_PORT env var is set, so zero footprint in normal play) that a Python driver talks to over newline-delimited JSON on 127.0.0.1:<port>. It lets a script read game state and drive a real running client, and lets those interactions be baked into automated functional tests across two live coop instances.

flowchart LR
  subgraph DRV["Python driver · tools/coop_test/"]
    direction TB
    GC["GameClient<br/>cmd() / ok() / wait_for()"]
    T["test_*.py<br/>baked cmds + asserts"]
  end
  subgraph HOST["HOST · OpenXcom.exe · OXC_TEST_PORT=47801"]
    direction TB
    IO["Socket IO thread (SDL_net)<br/>never touches game state"]
    Q["_inbox / _outbox<br/>(mutex)"]
    P["pump()<br/>main thread, per frame"]
    EX["execute()<br/>~45 handlers"]
    G["Real game objects<br/>Game · SavedGame<br/>State · BattlescapeGame"]
    IO --> Q --> P --> EX --> G
    G -.->|reply| Q
  end
  subgraph CLI["CLIENT · OpenXcom.exe · 47802"]
    direction TB
    G2["same internals<br/>IO → pump →<br/>execute → game"]
  end
  GC -->|"JSON + newline"| IO
  GC -->|"JSON + newline"| G2
  G <-->|"real coop sync<br/>connectionTCP :47900"| G2
Loading

Key property: the socket thread does I/O only; every command executes on the main thread via the per-frame pump(), so all state access is race-free. Handlers invoke the real State methods (Profile::buttonOK, BuildNewBaseState::placeAt, UnitWalkBState, …) rather than faking SDL input, so a command exercises the exact path a human click would. The two game instances sync to each other over the real connectionTCP coop link, which is the actual product under test; the tests assert host-vs-client agreement to catch replication desyncs.

What's in this PR

  • src/CoopMod/TestServer.{cpp,h}: the in-game command server (~45 command handlers covering session/introspection, lobby bootstrap, geoscape play, battlescape combat, transfer/UI).
  • tools/coop_test/: the Python drivers plus harness.py (the GameClient socket wrapper and hermetic make_user_dir).
  • Minimal wiring in existing files: a per-frame TestServer::pump() call in Game::run(), a read-only Game::getStates() accessor, CMake / vcxproj(.filters) registration, and a few public-ised State entry points so the harness can drive real handlers instead of synthesizing input.
  • .agents/docs/coop-test-harness.md (architecture and command catalog).

All of it is gated behind OXC_TEST_PORT; with the var unset the server never starts and the added surface is inert.

Builds cleanly on a stock checkout

A few handlers drive coop features that aren't in this repo yet (soldier ownership transfer, a multi-rendezvous server browser, a TX-queue drop counter). Rather than #ifdef the feature code or stub things out by hand, the harness detects each feature at compile time with C++17 __has_include. Detection lives entirely in TestServer.cpp; nothing is added to any other file:

Optional feature (not in this repo) Compile-time detector
soldier ownership transfer __has_include("TransferSoldierMenu.h" && "TransferNoticeState.h")
multi-rendezvous server browser __has_include("../Interface/DisableableComboBox.h")
TX-queue drop counter OXC_HARNESS_HAS_TX_DROP_COUNTER (harness macro, default 0), see note

Note: that counter is a namespace-scope global with no dedicated header, so __has_include can't see it and SFINAE can't probe it (referencing an undeclared name is a hard error, not a substitution failure), so it uses a harness-owned macro that is off by default.

When a feature is absent (as here), its handlers compile out and answer "<feature> not built"; the harness builds and runs. If such a feature later lands in this repo, __has_include re-fires at build time and the matching handlers switch on automatically, with no manual toggles and no feature flags in feature code.

Runs on any machine (hermetic)

make_user_dir() writes a fresh minimal options.cfg that pins the stock xcom1 master (no external mods), with intro/audio/mouse-capture off and a small 640×400 window (so it doesn't grab focus while running). OpenXcom defaults every unspecified key and resolves data (UFO/TFTD/standard/common) from the exe's own dir. No local config is read; no pre-existing save is required. The tests bootstrap a brand-new campaign each run.

Verification

  • Full serial MSBuild Release|x64 returns 0 Error(s); OpenXcom.exe links with no unresolved externals (confirms the gated feature symbols don't leak when compiled out).
  • boot_check on a fresh generated config brings up active mods [xcom1 v8.6.0] only, display 640×400, data loaded, 0 errors.
  • Full suite run from hermetic dirs; every test spawned instances and connected on both ports (isolation confirmed):
Test Result on a stock checkout
boot_check pass (single-instance smoke)
test_geoscape_sync runs fully; currently reports a geoscape host/client divergence, a real finding surfaced by the harness, worth triage
test_transfer_fresh / test_bug_fixes / test_transfer_rollback report soldier-transfer feature not built (feature not in this repo)
test_server_browser reports server-browser feature not built
test_txq_flood TX-drop counter compiled out

The four feature-gated results are expected on this repo; those tests light up when their feature is present. boot_check passes, and test_geoscape_sync already earns its keep by flagging a sync divergence.

How to run

# build once (serial; the project can exhaust the compiler heap with /m)
MSBuild src/OpenXcom.2010.sln /p:Configuration=Release /p:Platform=x64

# any test: spawns real windowed instances and tears them down
python tools/coop_test/test_geoscape_sync.py
python tools/coop_test/boot_check.py

Notes / limitations

  • The Python driver is Windows-only for now (SDL_net plus subprocess/STARTUPINFO window placement); the in-game server itself is portable.
  • Entirely opt-in: no behavior change unless OXC_TEST_PORT is set.
  • Feature-gated tests are included so they're ready when their features land; they no-op cleanly until then.

An in-game command server plus Python drivers that spawn real game
instances, drive them through menus/saves/lobby/geoscape/battle, read live
state, and assert host/client stay in sync. Runs unattended, needs no
external mod and no pre-existing save, and builds on a stock checkout.

- src/CoopMod/TestServer.{cpp,h}: in-game command server (~45 handlers),
  active only when OXC_TEST_PORT is set (inert in normal play). A socket IO
  thread does I/O only; a per-frame pump in Game::run() executes commands on
  the main thread against real game objects, so state access is race-free.
  Handlers invoke real State methods rather than faking SDL input.
- tools/coop_test/: harness.py (GameClient + hermetic make_user_dir), 7
  drivers, and a README.
- Wiring: TestServer::pump() call in Game::run(), read-only Game::getStates(),
  CMake / vcxproj(.filters) registration, a few public-ised State entry points,
  and Geoscape base-driving hooks.

Builds on a stock checkout: handlers for coop features not in this repo yet
(soldier transfer, multi-rendezvous server browser, TX-queue drop counter)
are detected at compile time with C++17 __has_include (plus a harness-owned
macro for the one bare global that can't be probed). Absent features compile
out and answer "<feature> not built"; they switch on automatically if the
feature later lands. No feature flags in feature code.

Hermetic: make_user_dir() writes a minimal options.cfg pinning the stock
xcom1 master, no external mods, intro/audio/mouse-capture off, 640x400
window; data resolves from the exe dir. No local config read, no save
needed - tests bootstrap a fresh campaign each run.

Verified: full serial MSBuild Release|x64 links clean (0 errors); boot_check
comes up on xcom1 v8.6.0 only; the suite spawns and connects both instances
from isolated dirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NonPolynomialTim

Copy link
Copy Markdown
Collaborator Author

@xcomcoopdev I've been developing this and using it to create automated bug repros, as well as to let Claude run through scenarios autonomously. It has been extremely handy and has saved me a ton of time, and I figured I'd open a PR here in case you wanted to have it available here too.

I also don't know if you saw my offer to set up automated builds here (because the PR was already closed Idk if it sends notifications or not): #20 (comment), but I also plan on building out a much wider and more robust set of tests to be able to catch regressions in my changes and I'd be happy to wire that into the automated builds as well to prevent regressions. An automated test suite like this would also help when syncing with the OXCE official repo to make sure that the merge didn't break anything, so you'd be able to sync more often and with less stress

@NonPolynomialTim

Copy link
Copy Markdown
Collaborator Author

@xcomcoopdev thank you for push access! Do you have any guidance on what sorts of PRs you'd prefer I didn't merge without you looking at it first? I'm thinking large, architectural changes like the soldier transfer PR (bc it touches the save architecture) and like the test harness PR I would leave until you had a chance to look at them, but bugfixes I would merge myself?

@xcomcoopdev

Copy link
Copy Markdown
Owner

Hi, I looked through those 4 pull requests, so you can approve them now. The "main" branch is protected, so I’m not completely sure what permissions you have there, but you can try. You should be able to create your own testing branch for nightly builds.

@xcomcoopdev

Copy link
Copy Markdown
Owner

Or I can just go ahead and approve those 4 pull requests now... 😅

@xcomcoopdev

Copy link
Copy Markdown
Owner

There are some merge conflicts in this pull request. Should I resolve them, or do you want to handle them?

@NonPolynomialTim

Copy link
Copy Markdown
Collaborator Author

I can handle those!

@xcomcoopdev

Copy link
Copy Markdown
Owner

@xcomcoopdev thank you for push access! Do you have any guidance on what sorts of PRs you'd prefer I didn't merge without you looking at it first? I'm thinking large, architectural changes like the soldier transfer PR (bc it touches the save architecture) and like the test harness PR I would leave until you had a chance to look at them, but bugfixes I would merge myself?

Hi, in my opinion you can merge PRs yourself as long as the changes are documented and the implementation works.

I’ll try to review your code changes weekly, and I’ll check and test everything before publishing anything to ModDB and/or mod.io.

NonPolynomialTim and others added 9 commits July 9, 2026 12:57
Soldier transfer and the multi-rendezvous server browser are now on
origin/main, so their __has_include gates enable those handlers. Resolved
the wiring conflicts by union (CMakeLists, vcxproj, Game.cpp include,
.gitignore); Game.h getStates() now comes from upstream.

Re-added the harness test accessors that upstream's feature merge did not
carry, so the now-enabled handlers compile:
- ServerList::getServerCombo()
- DisableableComboBox::getOptionCount() / getOptionLabel() (+ _labels)

Build: full serial MSBuild Release|x64, 0 errors; TestServer relinked
against the real feature API. TX-queue drop counter stays gated off (not
on origin/main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The optional coop features are now on main (soldier ownership transfer,
multi-rendezvous server browser) or completed by this PR (TX-queue drop
counter), so the __has_include / OXC_HARNESS_HAS_* conditional compilation
in TestServer.cpp is no longer needed. Handlers are now unconditional.

- TestServer.cpp: drop the feature-detection block and every #if/#else
  guard; include the feature headers directly.
- connectionTCP.{h,cpp}: add g_txDropCount, an atomic incremented at the
  two existing "TX queue full, dropping packet" sites, exposed for the
  coop_stats command (pairs with the conflation fix already on main).
- README: drop the feature-gated-tests section.

Build: full serial MSBuild Release|x64, 0 errors. Validated: test_transfer_fresh
passes end-to-end (soldier transfer host->client). test_server_browser now runs
the real handler but needs rendezvous config to assert (hermetic dirs have none).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_server_browser previously assumed a rendezvous.json with a reachable
"Official" server, which needs real server keys we don't commit. Rewrite it
to self-provision a throwaway config (via the OXC_RENDEZVOUS_CONFIG env
override) listing two UNREACHABLE localhost servers with placeholder public
keys (32 zero bytes), so it runs hermetically with no secrets and no live
server.

Asserts the offline/disabled path: combobox visible (>=2 servers), every
unreachable server shown "(offline)" and disabled. The happy path (a
reachable server showing enabled) needs real keys and is left to manual
per-release testing.

Validated: PASS - visible=True, both servers offline + disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New tools/coop_test/geo.py, importable by any test:
- wait_both_ready(host, client): gate until both players are settled on a live
  coop geoscape (no CoopState WAIT / map-download dialog), killing the post-load
  idle window before a test drives.
- skip_realtime(host, client, seconds, ...): advance N wall-clock seconds while
  keeping both on the geoscape and auto-closing any dialog (UFO/event/monthly).
- skip_ingame_time(host, client, minutes, ...): same, bounded by in-game time.
Both skips take an optional `interest` predicate (popup('MissionDetectedState'),
geo_when(lambda g: ...), or any callable(gc)) so they stop early when the event
you care about fires, leaving that dialog open. Plus drain_popups / on_geoscape
/ top_state / both_ready.

test_geoscape_sync: use wait_both_ready after bringup, and drain popups +
re-assert speed during the advance phase so a modal UFO/mission dialog can no
longer pause the clock and stall the run. Also drop the funds-gap from the
DESYNC verdict: coop funds are per-player (independent economies, never synced
peer-to-peer), so a gap is expected, not a desync. Now verdicts clean.

Remove test_txq_flood.py: it reproduces an already-fixed bug (conflation, PR
xcomcoopdev#26) and needs send-thread-throttle instrumentation that isn't (and shouldn't
be) upstream. The g_txDropCount telemetry stays. README updated.

Validated: test_geoscape_sync verdict clean, exit 0, both instances advanced,
0 desync events / crashes / rtt spikes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accidentally committed via `git add -A` in the previous commit. PR_DESCRIPTION.md
is a scratch file; the server_browser*.png are generated by test_server_browser
at runtime. Untrack both and gitignore them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
geo.py: add a TimeWatchdog that kills the sessions and raises StuckDialogError
if the in-game clock stalls (a dialog that can't be cleared / re-pushes),
wired into skip_realtime / skip_ingame_time / the month advance. No more
silent hangs.

TestServer dismiss_popup: handle UfoDetectedState (btnCancelClick = acknowledge
+ close, no intercept) and add a generic popState fallback for any other
unknown geoscape popup, while protecting the CoopState WAIT dialog. "Skip all
dialogs" is now robust to new popup types. New month_report command returns the
end-of-month score + per-country funding/activity from the SavedGame.

test_geoscape_sync phase B: advance 40 in-game days at the fastest speed,
capturing events deterministically while the clock is frozen on a detection
popup, and the month-end report via the monthsPassed transition. Cross-validate
that host and client agree on events seen, score, and per-country funding
changes (absolute funds are independent per player, so excluded). Phase A
trimmed to a short RTT baseline (idle heartbeat is FPS-bound, not speed-bound).

Finding: the run reveals host and client detect DIFFERENT UFOs/mission sites
(counts and types differ) -> a real coop geoscape-sync bug (UFO spawns are
meant to be shared). The test correctly fails on it; investigation to follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
geo_state now emits the coop cross-instance id (Ufo::getCoopUfoId /
MissionSite::getCoopMissionId, both ctor-randomized on the host and copied to
the client via the sync packet). test_geoscape_sync keys events by that id
instead of the local getId(), which differs per instance for the same synced
entity.

Exact baseline of the known spawn-sync bug: over 40 in-game days the host sees
7 UFO/mission events, the client only 2 (1 common). Confirms the conflated
target_positions snapshot drops nearly all transient spawns to the client.
Fix (reliable FIFO spawn/despawn lane) follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The geoscape heartbeat (target_positions) is sent via the last-write-wins
conflation slot (added to stop the TX-queue flood). Conflation elides
intermediate snapshots, so a UFO or mission site that appears and disappears
between two delivered snapshots was never reconstructed on the client -> host
and client geoscapes diverged (worst at high time-compression).

Fix: positions are conflatable, but membership (which UFOs/missions exist) is
not. GeoscapeState now also sends the snapshot on the reliable FIFO lane
(sendTCPPacketData) whenever the UFO/mission coop-id set changes, detected via
connectionTCP::geoMembershipChanged (tracks _lastGeoUfoIds/_lastGeoMissionIds).
Membership changes rarely, so this can't reintroduce the flood. The client
processes it through the existing target_positions handler (idempotent create
-by-coop-id), so no client change is needed.

Verified: at 5min/tick the host and client geo_state UFO coop-id sets matched
on 24/24 samples (0 mismatch); before the fix the client saw ~2 of the host's
7 spawns over 40 days.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event check compared detection events (captured at UfoDetectedState), but
detection depends on each player's radar/base coverage, so host and client
legitimately detect different UFOs even when the UFOs are perfectly in sync -
a false DESYNC.

Rewrite phase B to compare UFO/mission EXISTENCE: every poll, diff the coop-id
sets of all savegame UFOs / mission sites on both instances; an entity present
on only one side past a 4s lag tolerance is a real desync. Drop the radar-based
detection-lag check. Keep the end-of-month score/funding cross-check.

With the reliable-FIFO spawn/despawn fix in place this verdicts clean over 40
in-game days at the fastest speed (0 desync events); before the fix it flagged
the missing spawns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants