diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 597935959..dbdcdd4ff 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -706,13 +706,36 @@ jobs: shell: bash steps: - name: Install deps - # Same mirror-flake retry as build-winxp - see the comment there. No sudo - # (root in the container); git must land BEFORE checkout, which the + # No sudo (root in the container); git must land BEFORE checkout, which the # `submodules: recursive` step needs and the bare container lacks. + # + # Debian 11 is END OF LIFE, so this step no longer uses deb.debian.org. + # 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, which makes `apt-get update` fail outright here. + # Before that it had already begun 404ing pool files its own index still + # advertised, as the mirrors drifted with nothing republishing them. The retry + # loop below cannot help with either: a 404 and an expired Release are not the + # mirror flakes it was written for. + # + # 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. Point there and + # drop the dead security suite: every package installed below exists in plain + # bullseye (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. + # + # This deliberately KEEPS the glibc 2.31 floor the container exists for (see + # the job comment). 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. env: DEBIAN_FRONTEND: noninteractive run: | set -eu + echo 'deb http://archive.debian.org/debian bullseye main' > /etc/apt/sources.list + rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources || true apt_get() { apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 "$@"; } ok=0 for n in 1 2 3 4; do diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 10400928b..870120007 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -385,13 +385,23 @@ jobs: shell: bash steps: - name: Install deps - # Same mirror-flake retry as build-winxp - see the comment there. No sudo - # (root in the container); git must land BEFORE checkout, which the + # No sudo (root in the container); git must land BEFORE checkout, which the # `submodules: recursive` step needs and the bare container lacks. + # + # Debian 11 is END OF LIFE - same fix and same reasoning as ci-main.yml's + # build-linux, which see. bullseye-security stopped being published on + # 2026-08-31 and its Release file expired at 2026-09-07 21:13 UTC, so + # `apt-get update` fails outright here; the retry loop cannot help, because + # an expired Release and a 404 are not the mirror flakes it was written for. + # Plain bullseye is frozen, carries no Valid-Until and is already mirrored on + # archive.debian.org, so point there and drop the dead security suite. Keeps + # the glibc 2.31 floor (see ci-main). env: DEBIAN_FRONTEND: noninteractive run: | set -eu + echo 'deb http://archive.debian.org/debian bullseye main' > /etc/apt/sources.list + rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources || true apt_get() { apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 "$@"; } ok=0 for n in 1 2 3 4; do diff --git a/src/Battlescape/BattlescapeState.cpp b/src/Battlescape/BattlescapeState.cpp index 4be859871..e9a65adc0 100644 --- a/src/Battlescape/BattlescapeState.cpp +++ b/src/Battlescape/BattlescapeState.cpp @@ -6390,6 +6390,28 @@ void BattlescapeState::finishBattle(bool abort, int inExitArea) BattlescapeGenerator bgen = BattlescapeGenerator(_game); bgen.nextStage(); + // FINISH STARTING the rebuilt stage before it is shipped, so the blob the + // client loads is the state the host itself plays. + // + // nextStage() ends in resetTurnCounter(), which parks the save at turn 0 with + // _beforeGame = true. Vanilla clears that in SavedBattleGame::startFirstTurn(), + // reached through BriefingState::btnOkClick -> InventoryState -> OK. Co-op shows + // neither screen on this path, so the host used to be left mid-initialisation: + // * _beforeGame stayed true, and TileEngine::calculateLineVoxel excludes EVERY + // unit from line-of-sight while it is ("don't start unit spotting before + // pre-game inventory stuff") - so the host could never spot a single alien; + // * 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 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 through SavedBattleGame::load(), + // whose tail is resetUnitTiles() + recalculateFOV() - hence the player report that + // the client saw aliens where the host saw nothing at all. recalculateFOV() here + // mirrors that same load tail so both machines compute visibility from the same + // positions (nextStage() already did the ambient lighting pass). + _save->startFirstTurn(); + _save->getTileEngine()->recalculateFOV(); + // tag coop units + ship the rebuilt stage to the client. As at every // other coop mission-start site (ConfirmLandingState, GeoscapeState, // NewBattleState, ...), the briefing is only a vehicle for setupCoop() diff --git a/src/CoopMod/SharedEcon.cpp b/src/CoopMod/SharedEcon.cpp index 6d578cd71..10b19c80a 100644 --- a/src/CoopMod/SharedEcon.cpp +++ b/src/CoopMod/SharedEcon.cpp @@ -3728,6 +3728,40 @@ static bool coopWireOn(); static bool coopBoundaryPersistShouldAlarm(const char* bucket, const std::string& kind, std::uint32_t bseq); static void coopBoundaryPersistHeal(const char* bucket, const std::string& kind, std::uint32_t bseq); +/** + * Is `type` any stage of a MULTI-STAGE mission? + * + * 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 therefore leave the HOST unstamped precisely when the peer + * needs the marker, reinstating the false alarm chkBattleStage exists to prevent. + * + * Scoped this way so a SINGLE-stage battle - every UFO/terror/base mission, and every + * parallel-turn fixture - puts no extra field on the wire at all: its next_turn is + * byte-identical to before the guard existed. attachBattleChecksum/verifyBattleChecksum + * have exactly one caller each (NextTurnState / the next_turn handler), so this runs + * once per turn boundary and the deployment sweep needs no cache. + */ +static bool coopMissionIsMultiStage(const Game* game, const std::string& type) +{ + if (type.empty() || !game) return false; + const Mod* mod = game->getMod(); + if (!mod) return false; + + if (const AlienDeployment* dep = mod->getDeployment(type)) + { + if (!dep->getNextStage().empty()) return true; // an earlier stage + } + for (const std::string& name : mod->getDeploymentsList()) + { + const AlienDeployment* d = mod->getDeployment(name); + if (d && d->getNextStage() == type) return true; // a later stage + } + return false; +} + void attachBattleChecksum(Game* game, Json::Value& msg) { // coop (#151): PvP (gamemodes 2/3) runs a ROLE-AWARE sim where the two machines @@ -3743,6 +3777,19 @@ void attachBattleChecksum(Game* game, Json::Value& msg) msg["chkBattleItemId"] = Json::Value::Int64(itemIdCounter); msg["chkBattleCensus"] = Json::Value::Int64(census); msg["chkBattleUnits"] = Json::Value::Int64(units); + // coop (#188): WHICH battle these terms describe. In a multi-stage mission the + // host rebuilds the next stage, ships it, and enters it - so between the ship and + // the peer finishing its load the two machines legitimately hold DIFFERENT + // battles, and every term above differs for that reason alone. Stamping the + // mission type lets the receiver tell "we disagree about this battle" (a real + // desync) from "we are not talking about the same battle yet" (a transition). + if (const SavedBattleGame* battle = game->getSavedGame()->getSavedBattle()) + { + if (coopMissionIsMultiStage(game, battle->getMissionType())) + { + msg["chkBattleStage"] = battle->getMissionType(); + } + } } void verifyBattleChecksum(Game* game, const Json::Value& msg, const std::string& context) @@ -3760,6 +3807,32 @@ void verifyBattleChecksum(Game* game, const Json::Value& msg, const std::string& const int64_t peerUnits = msg.get("chkBattleUnits", -1).asInt64(); if (peerItemId < 0 && peerCensus < 0 && peerUnits < 0) return; // old peer / no battle + // coop (#188): NOT COMPARABLE ACROSS A STAGE TRANSITION. A multi-stage mission + // hands the peer a whole new battle: the host rebuilds the stage, ships the blob, + // and enters it immediately, while the peer still has to request, download and + // load that blob. A next_turn that lands inside that window carries the HOST's + // stage-2 terms and is checked against the peer's stage-1 state - the item ids, + // the census and the unit set all differ, because they describe two different + // battles rather than two disagreeing copies of one. That fired the desync + // dialog on both machines at every transition (owner report, stage 1 of an alien + // colony: peer itemId 351/turn 13/44 units vs host 463/turn 1/69 units), even + // though both converge on identical terms the moment the load completes. + // + // Skipping is right rather than merely quiet, exactly as for the death-replay + // window below: 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. + const std::string peerStage = msg.get("chkBattleStage", "").asString(); + const SavedBattleGame* const stageBattle = game->getSavedGame()->getSavedBattle(); + if (!peerStage.empty() && stageBattle && peerStage != stageBattle->getMissionType()) + { + Log(LOG_INFO) << "[COOP] battle checksum on " << context + << " skipped - the peer is on stage '" << peerStage + << "', this machine is on '" << stageBattle->getMissionType() + << "' (multi-stage transition in flight)"; + return; + } + int64_t myItemId, myCensus, myUnits; if (!battleChecksumTerms(game, myItemId, myCensus, myUnits)) return; // no battle here diff --git a/src/CoopMod/TestServer.cpp b/src/CoopMod/TestServer.cpp index 55ed605a9..6fbefada7 100644 --- a/src/CoopMod/TestServer.cpp +++ b/src/CoopMod/TestServer.cpp @@ -6166,6 +6166,12 @@ std::string TestServer::execute(const std::string& line) // invisible until its collapse ends). Empty except during a client ghost. resp["hiddenItemIds"] = bg->getBattleGame() ? bg->getBattleGame()->coopHiddenItemIdsJson() : Json::Value(Json::arrayValue); resp["isPreview"] = bg->isPreview(); + // SavedBattleGame::_beforeGame - the pre-inventory flag nextStage()/ + // resetTurnCounter() raises and startFirstTurn()/resetUnitTiles() clears. + // While it is true TileEngine::calculateLineVoxel excludes every unit from + // line-of-sight, so this machine can spot nothing at all - a "sees no + // aliens" desync is invisible in a unit census and only readable here. + resp["beforeGame"] = bg->isBeforeGame(); resp["clientPanicHandle"] = _game->getCoopMod()->_clientPanicHandle; resp["serverOwner"] = connectionTCP::getServerOwner(); resp["saveOwnerId"] = connectionTCP::coop_save_owner_player_id; diff --git a/tools/ci/run_coop_suite.ps1 b/tools/ci/run_coop_suite.ps1 index 310c7de86..af819987e 100644 --- a/tools/ci/run_coop_suite.ps1 +++ b/tools/ci/run_coop_suite.ps1 @@ -60,12 +60,23 @@ if ($PlanFile) { if ($ListOnly) { $tests; exit 0 } # to stdout, so callers can diff the shard split -# Known-broken on main (real failures, not flakes) - run but do not gate. +# Known-broken on main, or not gate-shaped - run but do not gate. # Add entries here if a test regresses; remove them as they are fixed so they gate # again. Empty = the whole suite gates (all green as of 2026-07-15). $quarantine = @( - "test_pvp_campaign_month", # issue #171: month-roll geoscape assert can't drain MissionDetectedState/SaveGameState - "test_crash_reporter" # issue #172: marker-bundle 60s timeout, intermittent + "test_pvp_campaign_month", # issue #171: month-roll geoscape assert can't drain MissionDetectedState/SaveGameState + "test_crash_reporter", # issue #172: marker-bundle 60s timeout, intermittent + # 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 = "the heavy-alien-death desync + # REPRODUCED", exit 3 = "every alien side stayed in census" (i.e. clean). Gating on + # it therefore scores the bug FIRING as success and a clean run as failure - every + # green run of it on main (30 Aug, 4 Sep) was green because the drift fired, which + # is precisely the signal a green pipeline hides. The fixes from #166 still sit + # behind a lever that defaults off (g_wireOrderState) pending the battlescape + # rewrite, so it keeps reproducing intermittently (~1 in 3-4 per its docstring). + # Run it and print the verdict; do not gate on it. Its rc=0 is worth alerting on + # separately - it is currently the only automated thing that notices the drift. + "test_parallel_heavy_death_repro" ) # --- Per-test time budgets ------------------------------------------------------ diff --git a/tools/coop_test/test_coop_nextstage_census.py b/tools/coop_test/test_coop_nextstage_census.py new file mode 100644 index 000000000..92406d7e3 --- /dev/null +++ b/tools/coop_test/test_coop_nextstage_census.py @@ -0,0 +1,363 @@ +"""Coop multi-stage (next-stage) transition: the two machines must land in the +SAME stage-2 battle - same map, same units, same positions. + +Follow-up to test_coop_nextstage_crash.py (#184, which only proved both +machines reach STR_ALIEN_COLONY_P2 without crashing). Player report on the +2.0.34 nightly: + + "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." + +So the discriminator here is a CENSUS COMPARE after the transition, not merely +"both are in stage 2": + + * live hostile count on host == on client + * every unit id/faction/position identical on both + * map fingerprint identical on both + +Run: python tools/coop_test/test_coop_nextstage_census.py +Exit 0 = pass; 2 = failure (census mismatch / crash / did not advance). + +Env: + NEXTSTAGE_DUMP=