From 2b552b2202d60c4569038acc1a59a1ae5f6b7601 Mon Sep 17 00:00:00 2001 From: Bentley Davis <10065854+NonPolynomialTim@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:25:57 -0400 Subject: [PATCH] fix(coop): null-base arrival crash + owner labels SHARED "ordered soldiers arrive -> 2nd player crashes": the arrival popup (ItemsArrivingState) reached the client via hostAlert with a null _base (a replica's own transfers are frozen), and "Go to Base" built BasescapeState(nullptr), whose coop ctor block dereferenced it (0xC0000005 read at [null+0x1d0]). A: guard the BasescapeState ctor's early _base dereferences. B: ItemsArrivingState "Go to Base" falls back to a real base when _base is unresolved. C: both players now see the full arrival list; soldiers not owned by the viewing player are prefixed with the owner's username, e.g. "[HostPlayer] Sergey Moronova". The host builds authoritative rows and ships them on the existing alert lane; the client rebuilds the popup from them via a display-only ctor. A rows-less arrival alert no longer pops (no empty phantom popup on either machine). Tests (all green): test_shared_arrival_owner_labels.py (new), test_shared_soldier_arrival_crash.py (new), test_shared_alerts.py updated for the rows-based ItemsArrivingState. Co-Authored-By: Claude Opus 4.8 --- src/Basescape/BasescapeState.cpp | 8 +- src/CoopMod/SharedEcon.cpp | 22 ++- src/CoopMod/SharedEcon.h | 3 +- src/CoopMod/TestServer.cpp | 44 +++++- src/Geoscape/GeoscapeState.cpp | 24 ++- src/Geoscape/ItemsArrivingState.cpp | 97 +++++++++++- src/Geoscape/ItemsArrivingState.h | 24 +++ tools/coop_test/test_shared_alerts.py | 7 +- .../test_shared_arrival_owner_labels.py | 139 ++++++++++++++++ .../test_shared_soldier_arrival_crash.py | 148 ++++++++++++++++++ 10 files changed, 501 insertions(+), 15 deletions(-) create mode 100644 tools/coop_test/test_shared_arrival_owner_labels.py create mode 100644 tools/coop_test/test_shared_soldier_arrival_crash.py diff --git a/src/Basescape/BasescapeState.cpp b/src/Basescape/BasescapeState.cpp index 7558fd8e2..7cbb18b50 100644 --- a/src/Basescape/BasescapeState.cpp +++ b/src/Basescape/BasescapeState.cpp @@ -83,7 +83,7 @@ BasescapeState::BasescapeState(Base *base, Globe *globe) : _base(base), _globe(g // while browsing, restored on exit in btnGeoscapeClick. PRD-J07: fenced in // SHARED - every base in _bases is real and fully browsable by any player, so // no entry filtering / old_bases juggling.) - if (_game->getCoopMod()->getCoopStatic() == true && !_game->getCoopMod()->isSharedCampaign() && _base->_coopBase == false && _game->getCoopMod()->getCoopCampaign() == true) + if (_game->getCoopMod()->getCoopStatic() == true && !_game->getCoopMod()->isSharedCampaign() && _base && _base->_coopBase == false && _game->getCoopMod()->getCoopCampaign() == true) { // coop @@ -225,7 +225,11 @@ BasescapeState::BasescapeState(Base *base, Globe *globe) : _base(base), _globe(g // COOP - if (_base->_coopBase == true) + // _base can be null here: vanilla allows BasescapeState(nullptr, ...) (e.g. + // GeoscapeState's no-base path, or a SHARED replica's ItemsArrivingState + // "Go to Base" whose _base was never resolved). init()->setBase() normalizes + // a null base to a real one, but this ctor block runs first, so guard it. + if (_base && _base->_coopBase == true) { _btnNewBase->setVisible(false); _btnFacilities->setVisible(false); diff --git a/src/CoopMod/SharedEcon.cpp b/src/CoopMod/SharedEcon.cpp index 033576271..fc50f1e2c 100644 --- a/src/CoopMod/SharedEcon.cpp +++ b/src/CoopMod/SharedEcon.cpp @@ -2278,7 +2278,23 @@ void alertApply(Game* game, Json::Value& payload, Base* base, int /*seat*/) } else if (cls == "ItemsArrivingState") { - gs->popup(new ItemsArrivingState(gs)); + const Json::Value& jr = payload["rows"]; + if (jr.isArray() && !jr.empty()) + { + std::vector rows; + for (Json::ArrayIndex i = 0; i < jr.size(); ++i) + { + ArrivalRow row; + row.type = jr[i].get("type", 0).asInt(); + row.name = jr[i].get("name", "").asString(); + row.qty = jr[i].get("qty", 0).asInt(); + row.base = jr[i].get("base", "").asString(); + row.baseIdx = jr[i].get("baseIdx", -1).asInt(); + row.ownerSeat = jr[i].get("ownerSeat", -1).asInt(); + rows.push_back(row); + } + gs->popup(new ItemsArrivingState(gs, rows)); + } } else if (cls == "ResearchRequiredState") { @@ -3337,7 +3353,7 @@ void hostAlienBaseFound(Game* game, AlienBase* alienBase) void hostAlert(Game* game, const std::string& cls, const std::string& msg, Base* base, int craftId, const std::vector& names, - const std::vector& ids, bool flag) + const std::vector& ids, bool flag, const Json::Value& rows) { if (!sharedHost(game)) return; Json::Value p; @@ -3351,6 +3367,8 @@ void hostAlert(Game* game, const std::string& cls, const std::string& msg, Json::Value ji(Json::arrayValue); for (int i : ids) ji.append(i); p["ids"] = ji; + if (rows.isArray() && !rows.empty()) + p["rows"] = rows; submitLocalCmd(game, "alert", base ? baseIndex(game, base) : 0, p); } diff --git a/src/CoopMod/SharedEcon.h b/src/CoopMod/SharedEcon.h index 94b44f604..a9108d2e4 100644 --- a/src/CoopMod/SharedEcon.h +++ b/src/CoopMod/SharedEcon.h @@ -337,7 +337,8 @@ void hostAlienBaseFound(Game* game, AlienBase* alienBase); void hostAlert(Game* game, const std::string& cls, const std::string& msg = "", Base* base = nullptr, int craftId = -1, const std::vector& names = {}, - const std::vector& ids = {}, bool flag = false); + const std::vector& ids = {}, bool flag = false, + const Json::Value& rows = Json::Value()); /// Playtest: host broadcasts that a craft's landing decision is resolved, so every /// other seat's broker ConfirmLandingState closes itself. void broadcastLandClose(Game* game, Craft* craft); diff --git a/src/CoopMod/TestServer.cpp b/src/CoopMod/TestServer.cpp index 021fd7c84..70eeb9541 100644 --- a/src/CoopMod/TestServer.cpp +++ b/src/CoopMod/TestServer.cpp @@ -34,6 +34,7 @@ #include "../Engine/Options.h" #include "../Engine/State.h" #include "../Geoscape/GeoscapeState.h" +#include "../Geoscape/ItemsArrivingState.h" #include "../Geoscape/GeoscapeCraftState.h" #include "../Geoscape/GeoscapeEventState.h" #include "../Geoscape/MonthlyReportState.h" @@ -2905,9 +2906,50 @@ bool TestServer::executeShared11(const std::string& cmd, const Json::Value& req, SharedEcon::hostAlert(_game, req.get("cls", "").asString(), req.get("msg", "").asString(), alertBase, req.get("craft_id", -1).asInt(), alertNames, alertIds, - req.get("flag", false).asBool()); + req.get("flag", false).asBool(), req.get("rows", Json::Value())); resp["ok"] = true; } + else if (cmd == "items_arriving_goto") + { + // Repro for the SHARED "ordered soldiers arrive" crash (host orders + // soldiers in a shared base -> the 2nd player crashes on arrival). On a + // replica the ItemsArrivingState is raised via SharedEcon::hostAlert and its + // _base is null (the replica's own transfers are frozen, so the ctor finds + // no hour-0 transfer). Fire the REAL "Go to Base" path; pre-fix this built + // BasescapeState(nullptr) and faulted in the ctor's coop block. + if (auto* ia = topState(_game)) + { + ia->harnessGotoBase(); + resp["ok"] = true; + } + else + { + resp["error"] = "ItemsArrivingState not on top"; + } + } + else if (cmd == "items_arriving_rows") + { + // Read the arrival popup's displayed row labels (owner-prefixed) plus this + // machine's seat and the shared player roster, for the arrival owner-label test. + if (auto* ia = topState(_game)) + { + Json::Value arr(Json::arrayValue); + for (const auto& s : ia->harnessRows()) + arr.append(s); + resp["rows"] = arr; + resp["localSeat"] = connectionTCP::localSeat(); + Json::Value players(Json::arrayValue); + if (_game->getSavedGame()) + for (const auto& p : _game->getSavedGame()->getCoopPlayers()) + players.append(p); + resp["coopPlayers"] = players; + resp["ok"] = true; + } + else + { + resp["error"] = "ItemsArrivingState not on top"; + } + } else if (cmd == "spawn_craft") { // Add a fully-armed craft (e.g. STR_INTERCEPTOR) to the first own base. diff --git a/src/Geoscape/GeoscapeState.cpp b/src/Geoscape/GeoscapeState.cpp index ef96f88b5..3e0ed772b 100644 --- a/src/Geoscape/GeoscapeState.cpp +++ b/src/Geoscape/GeoscapeState.cpp @@ -3893,8 +3893,28 @@ void GeoscapeState::time1Hour() } if (window) { - popup(new ItemsArrivingState(this)); - SharedEcon::hostAlert(_game, "ItemsArrivingState"); + ItemsArrivingState* ia = new ItemsArrivingState(this); + if (ia->getRows().empty()) + { + delete ia; + } + else + { + popup(ia); + Json::Value rowsJson(Json::arrayValue); + for (const auto& r : ia->getRows()) + { + Json::Value j; + j["type"] = r.type; + j["name"] = r.name; + j["qty"] = r.qty; + j["base"] = r.base; + j["baseIdx"] = r.baseIdx; + j["ownerSeat"] = r.ownerSeat; + rowsJson.append(j); + } + SharedEcon::hostAlert(_game, "ItemsArrivingState", "", nullptr, -1, {}, {}, false, rowsJson); + } } // Handle Production for (auto* xbase : *_game->getSavedGame()->getBases()) diff --git a/src/Geoscape/ItemsArrivingState.cpp b/src/Geoscape/ItemsArrivingState.cpp index 0129e4e4c..aecdca795 100644 --- a/src/Geoscape/ItemsArrivingState.cpp +++ b/src/Geoscape/ItemsArrivingState.cpp @@ -29,20 +29,32 @@ #include "../Savegame/Base.h" #include "../Savegame/Transfer.h" #include "../Savegame/Craft.h" +#include "../Savegame/Soldier.h" #include "../Mod/RuleItem.h" #include "GeoscapeState.h" #include "../Engine/Options.h" #include "../Basescape/BasescapeState.h" +#include "../CoopMod/connectionTCP.h" namespace OpenXcom { +static std::string formatRow(const ArrivalRow& r) +{ + if (r.type == TRANSFER_SOLDIER && r.ownerSeat >= 0 + && r.ownerSeat != connectionTCP::localSeat()) + { + std::string owner = connectionTCP::seatName(r.ownerSeat); + if (!owner.empty()) + return "[" + owner + "] " + r.name; + } + return r.name; +} + /** - * Initializes all the elements in the Items Arriving window. - * @param game Pointer to the core game. - * @param state Pointer to the Geoscape state. + * Builds the shared window widgets. */ -ItemsArrivingState::ItemsArrivingState(GeoscapeState *state) : _state(state), _base(0) +void ItemsArrivingState::buildUI() { _screen = false; @@ -95,7 +107,18 @@ ItemsArrivingState::ItemsArrivingState(GeoscapeState *state) : _state(state), _b _lstTransfers->setSelectable(true); _lstTransfers->setBackground(_window); _lstTransfers->setMargin(2); +} +/** + * Initializes all the elements in the Items Arriving window. + * @param game Pointer to the core game. + * @param state Pointer to the Geoscape state. + */ +ItemsArrivingState::ItemsArrivingState(GeoscapeState *state) : _state(state), _base(0) +{ + buildUI(); + + int baseIdx = 0; for (auto* xbase : *_game->getSavedGame()->getBases()) { for (auto transferIt = xbase->getTransfers()->begin(); transferIt != xbase->getTransfers()->end();) @@ -121,7 +144,16 @@ ItemsArrivingState::ItemsArrivingState(GeoscapeState *state) : _state(state), _b // Remove transfer std::ostringstream ss; ss << transfer->getQuantity(); - _lstTransfers->addRow(3, transfer->getName(_game->getLanguage()).c_str(), ss.str().c_str(), xbase->getName().c_str()); + ArrivalRow row; + row.type = transfer->getType(); + row.name = transfer->getName(_game->getLanguage()); + row.qty = transfer->getQuantity(); + row.base = xbase->getName(); + row.baseIdx = baseIdx; + row.ownerSeat = (transfer->getType() == TRANSFER_SOLDIER && transfer->getSoldier()) + ? transfer->getSoldier()->getOwnerPlayerId() : -1; + _rows.push_back(row); + _lstTransfers->addRow(3, formatRow(row).c_str(), ss.str().c_str(), xbase->getName().c_str()); delete transfer; transferIt = xbase->getTransfers()->erase(transferIt); } @@ -130,6 +162,34 @@ ItemsArrivingState::ItemsArrivingState(GeoscapeState *state) : _state(state), _b ++transferIt; } } + ++baseIdx; + } +} + +/** + * Initializes the window from a network-supplied row list (SHARED replica), + * without scanning or deleting any transfers. + * @param state Pointer to the Geoscape state. + * @param rows Arrival rows to display. + */ +ItemsArrivingState::ItemsArrivingState(GeoscapeState *state, const std::vector& rows) : _state(state), _base(0) +{ + buildUI(); + + for (const ArrivalRow& r : rows) + { + _rows.push_back(r); + std::ostringstream ss; + ss << r.qty; + _lstTransfers->addRow(3, formatRow(r).c_str(), ss.str().c_str(), r.base.c_str()); + } + + if (!rows.empty()) + { + auto* bases = _game->getSavedGame()->getBases(); + int idx = rows.front().baseIdx; + if (idx >= 0 && idx < (int)bases->size()) + _base = bases->at(idx); } } @@ -158,7 +218,32 @@ void ItemsArrivingState::btnGotoBaseClick(Action *) { _state->timerReset(); _game->popState(); - _game->pushState(new BasescapeState(_base, _state->getGlobe())); + // A SHARED replica's ItemsArrivingState is raised via SharedEcon::hostAlert + // with no hour-0 transfer of its own, so _base is null here; fall back to a + // real base (the BasescapeState ctor's coop block dereferences it). + // getSelectedBase() never returns null when any base exists. + Base *target = _base; + if (!target && !_game->getSavedGame()->getBases()->empty()) + target = _game->getSavedGame()->getSelectedBase(); + _game->pushState(new BasescapeState(target, _state->getGlobe())); +} + +/** + * Test automation: fire the real "Go to Base" path (drives btnGotoBaseClick). + * Used by the SHARED "ordered soldiers arrive" crash repro, where a replica's + * hostAlert-raised popup carries a null _base. + */ +void ItemsArrivingState::harnessGotoBase() +{ + btnGotoBaseClick(nullptr); +} + +std::vector ItemsArrivingState::harnessRows() const +{ + std::vector out; + for (const auto& r : _rows) + out.push_back(formatRow(r)); + return out; } } diff --git a/src/Geoscape/ItemsArrivingState.h b/src/Geoscape/ItemsArrivingState.h index f0a4daab7..837b8049a 100644 --- a/src/Geoscape/ItemsArrivingState.h +++ b/src/Geoscape/ItemsArrivingState.h @@ -18,6 +18,8 @@ * along with OpenXcom. If not, see . */ #include "../Engine/State.h" +#include +#include namespace OpenXcom { @@ -29,6 +31,16 @@ class TextList; class GeoscapeState; class Base; +struct ArrivalRow +{ + int type; + std::string name; + int qty; + std::string base; + int baseIdx; + int ownerSeat; +}; + /** * Items Arriving window that displays all * the items that have arrived at bases. @@ -42,15 +54,27 @@ class ItemsArrivingState : public State Window *_window; Text *_txtTitle, *_txtItem, *_txtQuantity, *_txtDestination; TextList *_lstTransfers; + std::vector _rows; + /// Builds the shared window widgets. + void buildUI(); public: /// Creates the ItemsArriving state. ItemsArrivingState(GeoscapeState *state); + /// Creates the ItemsArriving state from a network-supplied row list. + ItemsArrivingState(GeoscapeState *state, const std::vector& rows); /// Cleans up the ItemsArriving state. ~ItemsArrivingState(); /// Handler for clicking the OK button. void btnOkClick(Action *action); /// Handler for clicking the Go To Base button. void btnGotoBaseClick(Action *action); + /// Test automation: fire the real "Go to Base" path (issue repro: a replica's + /// hostAlert-raised popup has a null _base). + void harnessGotoBase(); + /// Gets the arrival rows backing this popup. + const std::vector& getRows() const { return _rows; } + /// Test automation: the formatted first-column labels. + std::vector harnessRows() const; }; } diff --git a/tools/coop_test/test_shared_alerts.py b/tools/coop_test/test_shared_alerts.py index 835e9b0be..acc6fc6f9 100644 --- a/tools/coop_test/test_shared_alerts.py +++ b/tools/coop_test/test_shared_alerts.py @@ -83,7 +83,12 @@ def main(): _check(host, client, "LowFuelState", {"craft_id": craft_id}, "low fuel") - _check(host, client, "ItemsArrivingState", {}, "items arriving") + # ItemsArrivingState now requires a rows payload (an empty arrival popup no + # longer pops); pass one synthetic arrival so the client rebuilds + pops it. + _check(host, client, "ItemsArrivingState", + {"rows": [{"type": 0, "name": "STR_TEST_ARRIVAL", "qty": 1, + "base": "HostBase", "baseIdx": 0, "ownerSeat": -1}]}, + "items arriving") # New-possibility family (research/manufacture/purchase/craft/facility). _check(host, client, "NewPossibleResearchState", diff --git a/tools/coop_test/test_shared_arrival_owner_labels.py b/tools/coop_test/test_shared_arrival_owner_labels.py new file mode 100644 index 000000000..5483b2d9d --- /dev/null +++ b/tools/coop_test/test_shared_arrival_owner_labels.py @@ -0,0 +1,139 @@ +"""SHARED arrival popup shows OWNER-PREFIXED row labels on the peer machine. + +When ordered personnel arrive in a SHARED campaign, the ItemsArrivingState popup +lists each incoming line item. The owner of the arrival sees a bare label +("Sergey Moronova"); every OTHER machine sees the same label prefixed with the +owner's player name ("[HostPlayer] Sergey Moronova"), so a player can tell whose +soldiers just landed in the shared base. + +This drives the real end-to-end path: hire a soldier, advance the clock until the +arrival popup fires on both machines (leaving it open), then read the popup's +displayed row labels via the items_arriving_rows harness command and assert the +owner sees an unprefixed row while the peer sees the owner-prefixed form. + + Scenario A HOST hires -> host row bare, client row "[] ...". + Scenario B CLIENT hires -> client row bare, host row "[] ...". + +The player names are read from the coopPlayers roster the command returns (seat 0 += host, seat 1 = client), never hardcoded. + +Run: python tools/coop_test/test_shared_arrival_owner_labels.py +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import shared_fixture +import geo + +SOLDIER = "STR_SOLDIER" + + +def _wait_popup(gc, name, timeout=20): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + last = geo.top_state(gc) + if name in last: + return last + time.sleep(0.3) + raise AssertionError(f"{gc.name}: {name} never appeared (top={last!r})") + + +def _incoming_soldiers(gc): + return gc.ok({"cmd": "incoming_transfers"}).get("soldiers", 0) + + +def _rows(gc): + """Read the arrival popup's displayed row labels + seat + roster.""" + return gc.ok({"cmd": "items_arriving_rows"}) + + +def hire_and_wait(buyer, host, client): + """Hire one soldier on `buyer`, advance the clock until the arrival popup fires + on BOTH machines (leaving it open), and leave both sitting on it.""" + # Stop the world first: a prior scenario's skip_ingame_time leaves the clock at + # speed 5 (one tick = a game day), so the hire + en-route wait would otherwise + # race a free-running sim and the arrival could fire before the deliberate skip. + geo.slow_clock(host, client) + for gc in (host, client): + geo.drain_popups(gc) + + r = buyer.ok({"cmd": "buy", "item": SOLDIER, "count": 1, "kind": "soldier"}) + assert r.get("sent"), f"{buyer.name} hire not sent: {r}" + host.wait_for("host hire en route", + lambda: (_incoming_soldiers(host) == 1) or None, timeout=30, interval=0.5) + client.wait_for("client hire en route", + lambda: (_incoming_soldiers(client) == 1) or None, timeout=30, interval=0.5) + print("PASS hire: 1 soldier en route on both machines") + + # Advance past the personnel transfer time; stop the moment the arrival popup + # appears (interest leaves it OPEN instead of auto-dismissing it). + res = geo.skip_ingame_time(host, client, minutes=60 * 24 * 6, speed_idx=5, + interest=geo.popup("ItemsArrivingState"), + real_timeout=220) + assert res["hit"], f"arrival popup never appeared while advancing time: {res}" + _wait_popup(host, "ItemsArrivingState", timeout=20) + _wait_popup(client, "ItemsArrivingState", timeout=20) + print("PASS arrival: ItemsArrivingState popped on both machines") + + +def scenario_host_hires(host, client): + """HOST hires: the host (owner) sees a bare row; the client sees it prefixed + with the host player's name.""" + hire_and_wait(host, host, client) + + h = _rows(host) + assert h["ok"], f"host items_arriving_rows not ok: {h}" + assert len(h["rows"]) == 1, f"host expected exactly 1 row: {h['rows']}" + assert not h["rows"][0].startswith("["), \ + f"host owns the soldier, its row must be unprefixed: {h['rows'][0]!r}" + + c = _rows(client) + assert len(c["rows"]) == 1, f"client expected exactly 1 row: {c['rows']}" + host_owner_name = c["coopPlayers"][0] + assert c["rows"][0].startswith("[" + host_owner_name + "] "), \ + f"client row not owner-prefixed for host: {c['rows'][0]!r} (owner {host_owner_name!r})" + print(f"PASS host-hires: host row bare, client row '[{host_owner_name}] ...'") + + for gc in (host, client): + geo.drain_popups(gc) + + +def scenario_client_hires(host, client): + """CLIENT hires: the client (owner) sees a bare row; the host sees it prefixed + with the client player's name.""" + hire_and_wait(client, host, client) + + c = _rows(client) + assert len(c["rows"]) == 1, f"client expected exactly 1 row: {c['rows']}" + assert not c["rows"][0].startswith("["), \ + f"client owns the soldier, its row must be unprefixed: {c['rows'][0]!r}" + + h = _rows(host) + assert len(h["rows"]) == 1, f"host expected exactly 1 row: {h['rows']}" + client_owner_name = h["coopPlayers"][1] + assert h["rows"][0].startswith("[" + client_owner_name + "] "), \ + f"host row not owner-prefixed for client: {h['rows'][0]!r} (owner {client_owner_name!r})" + print(f"PASS client-hires: client row bare, host row '[{client_owner_name}] ...'") + + for gc in (host, client): + geo.drain_popups(gc) + + +def main(): + js = shared_fixture.bring_up("jarrlabel", (48934, 48935, 48234)) + host, client = js.host, js.client + try: + scenario_host_hires(host, client) + scenario_client_hires(host, client) + js.finish() + print("SHARED ARRIVAL OWNER-LABEL TEST PASSED") + finally: + js.shutdown() + + +if __name__ == "__main__": + main() diff --git a/tools/coop_test/test_shared_soldier_arrival_crash.py b/tools/coop_test/test_shared_soldier_arrival_crash.py new file mode 100644 index 000000000..2597c9ad6 --- /dev/null +++ b/tools/coop_test/test_shared_soldier_arrival_crash.py @@ -0,0 +1,148 @@ +"""Regression: the 2nd player (a SHARED replica) crashes when host-ordered +soldiers ARRIVE and the player opens the base from the arrival popup. + +Reported: "When I order soldiers in a shared base as the host, the 2nd player +crashes on arrival of those ordered soldiers." + +Root cause (crash dump crash_20260730_224017): the arrival raises +ItemsArrivingState on the replica through SharedEcon::hostAlert (GeoscapeState +does `popup(new ItemsArrivingState); hostAlert("ItemsArrivingState")` when a +transfer lands). On a replica the ItemsArrivingState ctor finds NO hour-0 +transfer - a replica's own transfers are frozen (PRD-J04) and the matching one +was already force-delivered + deleted by transfer_arrived - so its `_base` stays +null. Pressing "Go to Base" (bound to keyOk) then does +`new BasescapeState(_base=nullptr, ...)`, and the coop mod's ctor block +`if (_base->_coopBase == true)` dereferences the null base -> 0xC0000005 read at +[null+0x1d0]. + +Two scenarios, both firing the REAL "Go to Base" handler on the client: + 1) END-TO-END (the reported path): the HOST hires a soldier, time is advanced + until it arrives, and the client opens the base from the arrival popup. + 2) DETERMINISTIC: raise the arrival popup directly via the hostAlert lane + (no clock advance) and open the base - the same null-_base popup, isolated. + +Pre-fix the client process crashes (its command socket drops); post-fix it +survives and lands on a real base screen. + +Run: python tools/coop_test/test_shared_soldier_arrival_crash.py +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import shared_fixture +import geo + +SOLDIER = "STR_SOLDIER" + + +def _wait_popup(gc, name, timeout=20): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + last = geo.top_state(gc) + if name in last: + return last + time.sleep(0.3) + raise AssertionError(f"{gc.name}: {name} never appeared (top={last!r})") + + +def _alive(gc): + """True iff the instance is still responsive (survived the go-to-base).""" + try: + return bool(gc.cmd({"cmd": "ping"}).get("pong")) + except (ConnectionError, OSError): + return False + + +def _incoming_soldiers(gc): + return gc.ok({"cmd": "incoming_transfers"}).get("soldiers", 0) + + +def _drive_goto_base(host, client, label): + """Fire the real "Go to Base" handler on the client's arrival popup and assert + it neither crashes nor no-ops, then return the client to the geoscape.""" + crashed = False + try: + r = client.cmd({"cmd": "items_arriving_goto"}) + except (ConnectionError, OSError) as e: + crashed = True + r = {"error": str(e)} + + if crashed or not _alive(client): + raise AssertionError( + f"CLIENT CRASHED [{label}] opening the base from the arrival popup " + f"(null-base BasescapeState ctor deref): {r.get('error')}") + + assert r.get("ok"), f"[{label}] items_arriving_goto did not run: {r}" + top = _wait_popup(client, "BasescapeState", timeout=10) + print(f"PASS [{label}] no-crash: client survived and opened the base screen ({top})") + + # Back to the geoscape so the next scenario / world-equality check is clean. + geo.drain_popups(client) + try: + client.cmd({"cmd": "close_screens"}) + except (ConnectionError, OSError): + pass + geo.drain_popups(host) + + +def scenario_hire_arrival(host, client): + """The reported path end-to-end: HOST hires a soldier, it ARRIVES, the client + opens the base from the arrival popup.""" + for gc in (host, client): + geo.drain_popups(gc) + + r = host.ok({"cmd": "buy", "item": SOLDIER, "count": 1, "kind": "soldier"}) + assert r.get("sent"), f"host hire not sent: {r}" + host.wait_for("host hire en route", + lambda: (_incoming_soldiers(host) == 1) or None, timeout=30, interval=0.5) + client.wait_for("client hire en route", + lambda: (_incoming_soldiers(client) == 1) or None, timeout=30, interval=0.5) + print("PASS hire: 1 soldier en route on both machines") + + # Advance past the personnel transfer time; stop the moment the arrival popup + # appears (interest leaves it OPEN instead of auto-dismissing it). + res = geo.skip_ingame_time(host, client, minutes=60 * 24 * 6, speed_idx=5, + interest=geo.popup("ItemsArrivingState"), + real_timeout=220) + assert res["hit"], f"arrival popup never appeared while advancing time: {res}" + _wait_popup(client, "ItemsArrivingState", timeout=20) + print("PASS arrival: soldier arrived, ItemsArrivingState popped on the client") + + _drive_goto_base(host, client, "hire-arrival") + + +def scenario_alert(host, client): + """Deterministic isolation of the null-_base popup: raise ItemsArrivingState on + the client through the real hostAlert lane with a row whose base index is + out of range, so the popup cannot resolve _base and "Go to Base" hits the + null-base path that Fix A/B guard.""" + for gc in (host, client): + geo.drain_popups(gc) + + host.ok({"cmd": "shared_alert", "cls": "ItemsArrivingState", + "rows": [{"type": 0, "name": "Ghost Soldier", "qty": 1, + "base": "Nowhere", "baseIdx": 999, "ownerSeat": -1}]}) + _wait_popup(client, "ItemsArrivingState") + print("PASS setup: arrival popup (unresolved base) reached the client via hostAlert") + + _drive_goto_base(host, client, "alert") + + +def main(): + js = shared_fixture.bring_up("jarrcrash", (48932, 48933, 48232)) + host, client = js.host, js.client + try: + scenario_hire_arrival(host, client) + scenario_alert(host, client) + js.finish() + print("SHARED SOLDIER-ARRIVAL CRASH TEST PASSED") + finally: + js.shutdown() + + +if __name__ == "__main__": + main()