Skip to content

fix(coop): finish starting the next stage on the multi-stage host - #188

Merged
NonPolynomialTim merged 4 commits into
mainfrom
claude/multiplayer-alien-visibility-c838a3
Sep 8, 2026
Merged

NonPolynomialTim merged 4 commits into
mainfrom
claude/multiplayer-alien-visibility-c838a3

Conversation

@NonPolynomialTim

Copy link
Copy Markdown
Collaborator

Follow-up to #184. Both machines now reach stage 2 of a co-op multi-stage mission, but the host could not see any aliens there while the client could:

we were able to get to the next stage in the base attack, but it was not working correctly. The client was seeing aliens where I (the host) saw nothing. In fact, I (the host) wasn't seeing any aliens at all.

What was actually wrong

The two machines agreed on everything a census can measure — identical unit ids, identical positions, identical map fingerprint. The split was visibility.

BattlescapeGenerator::nextStage() ends in resetTurnCounter(), which parks the rebuilt save at turn 0 with _beforeGame = true. The only thing that clears that flag is startFirstTurn() / resetUnitTiles(), and vanilla reaches startFirstTurn() through BriefingState::btnOkClick → InventoryState → OK. Co-op shows neither screen on this path, so the host was left mid-initialisation:

  • _beforeGame stayed true, and TileEngine::calculateLineVoxel sets excludeAllUnits while it is ("don't start unit spotting before pre-game inventory stuff") — so no line-of-sight ray on the host could ever hit a unit and the host spotted nothing at all;
  • resetUnitTiles() never ran, so tiles were not matched up with units and the player's own units kept the setVisible(false) nextStage() gave them;
  • the turn counter stayed at 0, which also silences the host's own click_close and next_turn packets (NextTurnState::close gates both on turn >= 1).

The client never hit any of it because it enters the stage through SavedBattleGame::load(), whose tail is resetUnitTiles() + recalculateFOV().

A fresh co-op mission start is unaffected for the same reason: there the host re-loads its own "battlehost" blob via CoopState::loadWorld, so it goes through the loader too. The next-stage path is the only one that wires a BattlescapeState straight onto the in-memory save.

Fix

Call startFirstTurn() + recalculateFOV() right after bgen.nextStage() and before setupCoop()/sendMissionFile(), so the blob shipped to the client is the state the host actually plays.

Measured

On the player's own mid-battle save (the scrubbed fixture from #184):

host client
before beforeGame=true turn=0 spotted=[] discoveredFloor=242 beforeGame=false turn=0 spotted=[1000052] discoveredFloor=988
after beforeGame=false turn=1 spotted=[1000052] discoveredFloor=988 beforeGame=false turn=1 spotted=[1000052] discoveredFloor=988

Tests

tools/coop_test/test_coop_nextstage_census.py (new) asserts the stage-2 unit census, map fingerprint, fog and spotted-alien sets match on both machines. With NEXTSTAGE_FINISH=1 it also clears stage 2 and asserts both machines reach an identical debriefing (total 1714, matching rows and scores) and return to the geoscape — so the end-of-mission flow after a stage transition is covered too. Skips cleanly where TFTD data is not staged.

battle_state gains a beforeGame readout: this desync is invisible to a unit census and only readable there.

Green locally: boot_check, the new test (both legs), test_coop_nextstage_crash (#184), test_coop_debrief_sync, test_shared_autoend_crashsite, test_vote_abort_battle, test_shared_battle, test_coop_door_sync.

Follow-up to #184. Both machines now reach stage 2 of a co-op multi-stage
mission, but the host could not see any aliens there while the client could -
"the client was seeing aliens where I saw nothing".

The two machines agreed on everything a census can measure: identical unit ids,
identical positions, identical map fingerprint. The split was visibility.
BattlescapeGenerator::nextStage() ends in resetTurnCounter(), which parks the
rebuilt save at turn 0 with _beforeGame = true. Only startFirstTurn() /
resetUnitTiles() clears that flag, and vanilla reaches startFirstTurn() through
BriefingState::btnOkClick -> InventoryState -> OK. Co-op shows neither screen on
this path, so the host was left mid-initialisation:

  * _beforeGame stayed true, and TileEngine::calculateLineVoxel sets
    excludeAllUnits while it is ("don't start unit spotting before pre-game
    inventory stuff"), so no line-of-sight ray on the host could ever hit a unit
    and the host spotted nothing at all;
  * resetUnitTiles() never ran, so tiles were not matched up with units and the
    player's own units kept the setVisible(false) nextStage() gave them;
  * the turn counter stayed at 0, which also silences the host's own
    click_close and next_turn packets (NextTurnState::close gates both on
    turn >= 1).

The client never hit any of it because it enters the stage through
SavedBattleGame::load(), whose tail is resetUnitTiles() + recalculateFOV(). (A
fresh co-op mission start is unaffected for the same reason: there the host
re-loads its own "battlehost" blob via CoopState::loadWorld, so it goes through
the loader too. The next-stage path is the only one that wires a BattlescapeState
straight onto the in-memory save.)

Fix: call startFirstTurn() + recalculateFOV() right after bgen.nextStage() and
BEFORE setupCoop()/sendMissionFile(), so the blob shipped to the client is the
state the host actually plays.

Measured on the player's own mid-battle save (scrubbed fixture from #184):
  before  host beforeGame=true  turn=0 spotted=[]        discoveredFloor=242
          client beforeGame=false turn=0 spotted=[1000052] discoveredFloor=988
  after   both beforeGame=false turn=1 spotted=[1000052] discoveredFloor=988

Repro/regression: tools/coop_test/test_coop_nextstage_census.py asserts the
stage-2 unit census, map fingerprint, fog and spotted-alien sets match on both
machines, and with NEXTSTAGE_FINISH=1 also clears stage 2 and asserts both
machines reach an identical debriefing and return to the geoscape. Skips cleanly
where TFTD data is not staged. battle_state gains a "beforeGame" readout - the
desync is invisible to a unit census and only readable there.
@NonPolynomialTim
NonPolynomialTim enabled auto-merge (squash) September 7, 2026 02:14
Owner playtest of the previous commit hit a CO-OP DESYNC DETECTED dialog on BOTH
machines the moment the mission rolled into stage 2. The state was correct; the
comparison was not.

A multi-stage transition hands the peer a whole new battle: the host rebuilds the
stage, ships the blob (setupCoop -> sendMissionFile) and ENTERS it immediately,
while the peer still has to request, download and load that blob. A next_turn
closed inside that window carries the host's stage-2 terms and is verified
against the peer's stage-1 state, so chkBattleItemId, chkBattleCensus and
chkBattleUnits all differ - because they describe two different battles, not two
disagreeing copies of one. Both bundles from the playtest say so directly: the
client reported turn 13 / 44 units / itemId 351 (stage 1) while the host reported
turn 1 / 69 units / itemId 463 (stage 2), and 351 is exactly the stage-1 value a
harness probe measures immediately before the transition. Both machines converge
on identical terms the moment the load completes.

The window was previously unreachable: before the preceding commit the host sat
at turn 0 after a transition and NextTurnState::close gates click_close/next_turn
on turn >= 1, so the packet was never sent. Restoring startFirstTurn() is what
exposed it - a latent protocol race, not new drift.

Fix: attachBattleChecksum stamps chkBattleStage (getMissionType), and
verifyBattleChecksum returns early when the peer stamped a stage that differs
from this machine's. Same "not comparable in this window" discipline as the
neighbouring corpseReplayPendingAny() and rxPassDeferred() guards, and for the
same reason: the next stamp compares the two machines once the peer is on the
same stage. Additive - a peer that stamps no stage reads back as the empty string
and is compared exactly as before. PROTOCOL.md updated in the docs repo.

test_coop_nextstage_census.py now RACES the transition instead of waiting for
both machines (which is why the automated run never saw what a human hit): it
closes the host's stage-2 turn screen the instant it exists, then asserts zero
desync bundles on either machine. Measured on the identical forced window -
without the guard: 2 bundles, 0 skips; with it: 0 bundles, 1 skip log line.

ci: point build-linux's apt at archive.debian.org and drop bullseye-security

Unrelated to the co-op work, but it is what is currently failing this PR.
Debian 11 is end of life: bullseye-security stopped being published on 2026-08-31
(bookworm- and trixie-security are still published daily) and its Release file
expired at 2026-09-07 21:13 UTC, so apt-get update now fails outright in the
container. Before that it had begun 404ing pool files its own index still
advertised, as mirrors drifted with nothing republishing them. The existing retry
loop cannot help with either: a 404 and an expired Release are not mirror flakes.

Plain bullseye is frozen at its final point release (2025-08-09) and carries NO
Valid-Until, so it never expires, and it is already mirrored on
archive.debian.org where retired releases stay indefinitely. Every package the
job installs exists there (only 6 ever resolved to a newer security build), this
container is a throwaway build box, and none of these libraries ship inside the
AppImage - they are headers and build tooling.

Keeps the glibc 2.31 floor the container exists for. debian:12 would fix apt too
but raises the floor to 2.36 and drops Ubuntu 22.04 LTS (2.35), Mint 21 and
RHEL/Rocky 9 (2.34) - a player support decision, not an apt workaround.
@NonPolynomialTim
NonPolynomialTim enabled auto-merge (squash) September 8, 2026 03:11
… too

The PR gate runs .github/workflows/ci-validate.yml (pull_request_target), not
ci-main.yml, and it carries its own copy of build-linux on the same debian:11
container. The previous commit only fixed ci-main's, so the gate kept failing
with the same expired-Release error.

NOTE for whoever lands this: pull_request_target evaluates the workflow from the
BASE branch, so this file's fix cannot take effect on THIS pull request's own
checks - it applies to runs after it is on main. build-linux is a required check,
so #188 needs the CI fix on main first (or an admin merge).
…-proof the gate

Two follow-ups from validating the previous commit against CI.

1. chkBattleStage is now stamped ONLY for multi-stage missions.

A single-stage battle - every UFO/terror/base mission, and every parallel-turn
fixture - now puts no extra field on the wire at all, so its next_turn is
byte-identical to before the guard existed. The guard only ever mattered for a
stage transition.

The predicate is "is this ANY stage of a multi-stage chain": the deployment has a
nextStage, OR some deployment's nextStage names it. Both halves are needed, and
the second is the non-obvious one. The LAST stage of a chain carries no nextStage
of its own (STR_ALIEN_COLONY_P2, STR_TLETH_P3, STR_MARS_THE_FINAL_ASSAULT) - and
the host is exactly the machine sitting on that last stage while the peer is still
loading the previous one. A plain "has a nextStage" test would leave the HOST
unstamped precisely when the peer needs the marker, reinstating the false alarm.

attachBattleChecksum and verifyBattleChecksum have one caller each (NextTurnState
and the next_turn handler), so this runs once per turn boundary and the deployment
sweep needs no cache.

Regression unchanged and still green: test_coop_nextstage_census races the host's
stage-2 turn screen against the client's load and still logs the skip
("the peer is on stage 'STR_ALIEN_COLONY_P2', this machine is on
'STR_ALIEN_BASE_ASSAULT'") with zero desync bundles on either machine.

2. test_parallel_heavy_death_repro no longer gates the suite.

It is a REPRO TOOL, not a guard - its own docstring says so - and its exit codes
are the reverse of what a gate assumes:

  exit 0 = REPRO FIRED (the heavy-alien-death desync reproduced)
  exit 3 = NO REPRO    (every alien side stayed in census - i.e. clean)
  exit 2 = harness/setup error

The runner scores exit 0 as PASS, so gating on it counts the bug FIRING as success
and a clean run as failure. Every green run of it on main (30 Aug run 33331963166,
4 Sep run 33857882577, and #184's over-budget entry, which the runner only reports
when rc==0) was green because the drift fired - exactly the signal a green pipeline
hides. It has been this way since it landed in #166 on 2026-08-30; the exit codes
have never been modified.

It still reproduces intermittently because the #166 fixes sit behind a lever that
defaults off (g_wireOrderState) pending the battlescape rewrite, so this is the
known state and not a regression. Quarantined: it runs and prints its verdict but
does not gate. Its rc=0 deserves an alert of its own - it is currently the only
automated thing that notices the drift firing - but that is a separate change.

Also verified along the way: coop-tests and build-linux are BOTH red on unmodified
main (control dispatch of ci-validate on f8b0834), so the gate was not failing
because of this branch.
@NonPolynomialTim
NonPolynomialTim merged commit 2b88806 into main Sep 8, 2026
10 of 11 checks passed
NonPolynomialTim added a commit that referenced this pull request Sep 8, 2026
test_sync_check (PRD-I0 per-action sequenced sync-check) and test_parallel_soak
(PRD-P9 parallel-turns soak) both went red on main run 34232562811.

Neither is flaky and neither is slow - both found REAL divergence between the two
machines:

  test_sync_check     the `smoke` bucket disagreed after a smoke-heavy alien side
                      of turn 3. Smoke blocks line of sight, so the two machines
                      disagreed about who could see whom.
  test_parallel_soak  the PRD-P2 drift tripwire fired after the alien side of
                      turns 2 and 3 - the item/unit census stopped matching.

Both are detectors for the parallel battlescape, which is the subsystem currently
being rewritten, and both belong to the same family as the open reports #168,
#178, #179 and #182. Gating trunk on them blocks every unrelated change for the
duration of the rewrite, so they run and print their verdict but no longer gate.

This is a deliberate, temporary hole, not a clean bill of health: between now and
the rewrite landing, NOTHING in CI gates on battlescape drift. Remove both entries
when the rewrite lands. Their output is still in the shard logs - keep reading it.

Not caused by this branch: after the multi-stage guard was scoped to multi-stage
missions (#188), nothing in the coop diff executes at all in the single-stage
battles these two tests run.
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.

1 participant