Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions .github/workflows/ci-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/ci-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/Battlescape/BattlescapeState.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
73 changes: 73 additions & 0 deletions src/CoopMod/SharedEcon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/CoopMod/TestServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 14 additions & 3 deletions tools/ci/run_coop_suite.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------
Expand Down
Loading
Loading