From 0b3761fab8e1098be84c00b126611e9cfa181c92 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 15:58:30 -0700 Subject: [PATCH 1/9] Keep a race's untaken and late messages out of the next race Session::resetReplication cleared the snapshot, ambient and traffic queues but not the players' inputs, the own-car states, the prop states or the rules states, so whatever a race left untaken was handed to the next one. And ENet holds unreliable packets under load, so an input could arrive after the next race had started: car states and inputs number their samples from 1 each race and carried no race number, so a host that took an input numbered 5000 dropped the client's real inputs (from 1) as already applied for about 83 s. The queues are now cleared between races, and Session stamps every race-time message (inputs, car states, traffic, props) with its race number and drops one from another race (protocol 19; the rules' messages already carried theirs). Found by the multiplayer code review. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/net/AmbientState.h | 2 ++ src/net/PlayerCarState.h | 4 ++++ src/net/PropState.h | 4 ++++ src/net/Protocol.h | 5 +++- src/net/Session.cpp | 34 +++++++++++++++++----------- src/net/Session.h | 10 ++++++++ src/net/TrafficFull.h | 2 ++ tests/net/test_ambient_state.cpp | 2 ++ tests/net/test_player_cars.cpp | 5 ++-- tests/net/test_prop_state.cpp | 5 ++-- tests/net/test_session_lobby.cpp | 39 ++++++++++++++++++++++++++++++++ tests/net/test_traffic_full.cpp | 2 +- 12 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/net/AmbientState.h b/src/net/AmbientState.h index fd01092..e87e7af 100644 --- a/src/net/AmbientState.h +++ b/src/net/AmbientState.h @@ -111,6 +111,7 @@ struct AmbientEntity { // Host -> one client. struct AmbientStateMsg { static constexpr MsgType kType = MsgType::AmbientState; + std::uint32_t race = 0; // the race it belongs to (net::Session stamps and checks it) std::uint32_t time = 0; // session time the cars were sampled at std::uint32_t lightSteps = 0; // the host's traffic light steps (1/30 s) since its AI reset std::uint16_t catalog = 0; // checksum of the host's model catalog @@ -214,6 +215,7 @@ bool serializeAmbientEntity(S& s, AmbientEntity& e, const Vec3& origin) { template bool serialize(S& s, AmbientStateMsg& m) { + s.varU32(m.race); s.u32(m.time); s.u32(m.lightSteps); s.u16(m.catalog); diff --git a/src/net/PlayerCarState.h b/src/net/PlayerCarState.h index 1922cbd..83a41db 100644 --- a/src/net/PlayerCarState.h +++ b/src/net/PlayerCarState.h @@ -108,6 +108,7 @@ inline constexpr float kMaxCommandAngle = 64.0f; struct PlayerInputMsg { static constexpr MsgType kType = MsgType::PlayerInput; + std::uint32_t race = 0; // the race it belongs to (net::Session stamps and checks it) std::uint32_t first = 0; // the number of frames[0] (samples count from 1) std::vector frames; std::vector commands; @@ -168,6 +169,7 @@ bool serialize(S& s, CarCommand& c) { template bool serialize(S& s, PlayerInputMsg& m) { + s.varU32(m.race); s.u32(m.first); auto count = static_cast(std::min(m.frames.size(), kMaxInputFrames)); s.ranged(count, 1, static_cast(kMaxInputFrames)); @@ -388,6 +390,7 @@ inline constexpr std::size_t kMaxUpcomingInputs = 16; struct CarStatesMsg { static constexpr MsgType kType = MsgType::CarStates; + std::uint32_t race = 0; // the race it belongs to (net::Session stamps and checks it) std::uint32_t time = 0; // session ms of the host's sample the states belong to // The receiving client's car: the number of the last input the host // applied to it (0: none yet), how many of its inputs were waiting @@ -408,6 +411,7 @@ inline constexpr std::int32_t kMaxReportedWaiting = 63; template bool serialize(S& s, CarStatesMsg& m) { + s.varU32(m.race); s.u32(m.time); s.u32(m.ack); m.waiting = std::clamp(m.waiting, -kMaxReportedWaiting, kMaxReportedWaiting); diff --git a/src/net/PropState.h b/src/net/PropState.h index f35e372..95248d1 100644 --- a/src/net/PropState.h +++ b/src/net/PropState.h @@ -108,6 +108,7 @@ struct PropSlot { // Host -> one client. struct PropStateMsg { static constexpr MsgType kType = MsgType::PropState; + std::uint32_t race = 0; // the race it belongs to (net::Session stamps and checks it) std::uint32_t time = 0; // session time the states belong to std::uint32_t catalog = 0; // checksum of the host's placed props (game::propCatalog) std::vector slots; @@ -185,6 +186,7 @@ bool serializePropSlot(S& s, PropSlot& p, std::int32_t previous = -1) { template bool serialize(S& s, PropStateMsg& m) { + s.varU32(m.race); s.u32(m.time); s.u32(m.catalog); auto count = static_cast(std::min(m.slots.size(), kMaxPropSlots)); @@ -291,6 +293,7 @@ inline constexpr std::size_t kMaxPropFullMessages = 3; // per state struct PropFullMsg { static constexpr MsgType kType = MsgType::PropFull; + std::uint32_t race = 0; // the race it belongs to (net::Session stamps and checks it) std::uint32_t time = 0; // session ms of the host's physics sample (its CarStates' time) // Which of the state's messages (0 .. kMaxPropFullMessages - 1): the // pieces come in the host's world's order, the first message's first. @@ -394,6 +397,7 @@ bool serialize(S& s, PropBodyState& b, bool moving) { template bool serialize(S& s, PropFullMsg& m) { + s.varU32(m.race); s.u32(m.time); std::int32_t part = m.part; s.ranged(part, 0, static_cast(kMaxPropFullMessages) - 1); diff --git a/src/net/Protocol.h b/src/net/Protocol.h index 187821a..715e1e2 100644 --- a/src/net/Protocol.h +++ b/src/net/Protocol.h @@ -60,7 +60,10 @@ inline constexpr std::uint16_t kProtocolMagic = 0x4D32; // "M2" // car's full state. (11, 13 and 15 on its branch.) // 18: the pieces round a client's car in full with its CarStates // (net/PropState.h: PropFull), which it simulates with its car. -inline constexpr std::uint16_t kProtocolVersion = 18; +// 19: the race-time messages (inputs, car states, traffic, props) carry +// their race number, as the rules' already did; a packet ENet held +// across a race's end no longer reaches the next race. +inline constexpr std::uint16_t kProtocolVersion = 19; inline constexpr std::uint32_t kConnectData = (std::uint32_t{kProtocolMagic} << 16) | kProtocolVersion; // ENet channels. diff --git a/src/net/Session.cpp b/src/net/Session.cpp index d00b2ff..1d2960f 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -769,7 +769,7 @@ void Session::hostHandle(Remote& r, MsgType type, std::span dat // what a dropped one carried. PlayerInputMsg msg; if (!replicating() || !r.inputs.take(monotonicMs(), kInputRate, kInputBurst) || - !decodeMessage(data, msg)) + !decodeMessage(data, msg) || msg.race != m_race) return; if (m_playerInputs.size() >= kMaxQueuedPlayerInputs) m_playerInputs.erase(m_playerInputs.begin()); @@ -1106,7 +1106,7 @@ void Session::clientHandle(MsgType type, std::span data) { // The host's states of the players' cars: this machine's own with // the last input it applied, the others into their buffers. CarStatesMsg m; - if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m) || m.race != m_race) return; for (auto& [id, state] : m.cars) { state.time = m.time; @@ -1148,7 +1148,7 @@ void Session::clientHandle(MsgType type, std::span data) { case MsgType::AmbientState: { // As the cars' states: only during a race. AmbientStateMsg m; - if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m) || m.race != m_race) return; if (m_ambientStates.size() >= kMaxQueuedAmbientStates) m_ambientStates.erase(m_ambientStates.begin()); @@ -1158,7 +1158,7 @@ void Session::clientHandle(MsgType type, std::span data) { case MsgType::TrafficFull: { // As the cars' states: only during a race. TrafficFullMsg m; - if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m) || m.race != m_race) return; if (m_trafficFull.size() >= kMaxQueuedTrafficFull) m_trafficFull.erase(m_trafficFull.begin()); @@ -1168,7 +1168,7 @@ void Session::clientHandle(MsgType type, std::span data) { case MsgType::PropFull: { // As the cars' states: only during a race. PropFullMsg m; - if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m) || m.race != m_race) return; if (m_propFull.size() >= kMaxQueuedPropFull) m_propFull.erase(m_propFull.begin()); @@ -1178,7 +1178,7 @@ void Session::clientHandle(MsgType type, std::span data) { case MsgType::PropState: { // As the cars' states: only during a race. PropStateMsg m; - if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m)) + if (m_phase == SessionPhase::Lobby || !decodeMessage(data, m) || m.race != m_race) return; if (m_propStates.size() >= kMaxQueuedPropStates) m_propStates.erase(m_propStates.begin()); @@ -1337,6 +1337,14 @@ void Session::resetReplication() { m_lastSentStateTime = 0; m_ambientStates.clear(); m_trafficFull.clear(); + // Whatever a race left untaken belongs to it: car states and inputs + // number their samples from 1 every race and carry no race number, so + // a leftover would be read as the next race's. + m_playerInputs.clear(); + m_ownCarStates.clear(); + m_propStates.clear(); + m_propFull.clear(); + m_rulesStates.clear(); } SnapshotBuffer::Result Session::sampleRemoteAt(std::uint8_t playerId, double sessionTime, @@ -1386,7 +1394,7 @@ void Session::sendGameEvent(std::uint16_t type, std::vector payload, void Session::sendPlayerInput(const PlayerInputMsg& msg) { if (m_role != Role::Client || m_state != State::Active || !replicating() || msg.frames.empty()) return; - sendTo(m_hostPeer, Channel::State, msg); + sendTo(m_hostPeer, Channel::State, raced(msg)); } std::size_t Session::sendCarStates(std::uint8_t playerId, const CarStatesMsg& msg) { @@ -1396,7 +1404,7 @@ std::size_t Session::sendCarStates(std::uint8_t playerId, const CarStatesMsg& ms Remote* r = remoteForPlayer(playerId); if (!r) return 0; - const auto packet = encodeMessage(msg); + const auto packet = encodeMessage(raced(msg)); return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; } @@ -1407,7 +1415,7 @@ std::size_t Session::sendAmbientState(std::uint8_t playerId, const AmbientStateM Remote* r = remoteForPlayer(playerId); if (!r) return 0; - const auto packet = encodeMessage(msg); + const auto packet = encodeMessage(raced(msg)); return m_transport->send(r->peer, Channel::Ambient, packet) ? packet.size() : 0; } @@ -1418,7 +1426,7 @@ std::size_t Session::sendTrafficFull(std::uint8_t playerId, const TrafficFullMsg Remote* r = remoteForPlayer(playerId); if (!r) return 0; - const auto packet = encodeMessage(msg); + const auto packet = encodeMessage(raced(msg)); // Unsequenced: each message stands alone (the client keys it by time). return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; } @@ -1430,7 +1438,7 @@ std::size_t Session::sendPropFull(std::uint8_t playerId, const PropFullMsg& msg) Remote* r = remoteForPlayer(playerId); if (!r) return 0; - const auto packet = encodeMessage(msg); + const auto packet = encodeMessage(raced(msg)); // Unsequenced: each message stands alone (the client keys it by time). return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; } @@ -1442,7 +1450,7 @@ std::size_t Session::sendPropState(std::uint8_t playerId, const PropStateMsg& ms Remote* r = remoteForPlayer(playerId); if (!r) return 0; - const auto packet = encodeMessage(msg); + const auto packet = encodeMessage(raced(msg)); // Unsequenced: a late message still fills the client's buffers (it // orders them by their time). return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; @@ -1455,7 +1463,7 @@ std::size_t Session::sendRulesState(std::uint8_t playerId, const RulesStateMsg& Remote* r = remoteForPlayer(playerId); if (!r) return 0; - const auto packet = encodeMessage(msg); + const auto packet = encodeMessage(msg); // RulesMsg carries its race (game::NetRules checks it) return m_transport->send(r->peer, Channel::State, packet) ? packet.size() : 0; } diff --git a/src/net/Session.h b/src/net/Session.h index 91b5eb0..6f39adf 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -264,6 +264,7 @@ class Session { PlayerInputMsg msg; }; std::vector takePlayerInputs() { return std::exchange(m_playerInputs, {}); } + std::size_t playerInputsQueued() const { return m_playerInputs.size(); } static constexpr std::size_t kMaxQueuedPlayerInputs = 256; // Host: one client's states (its own car and the others'); returns the // bytes sent. @@ -283,6 +284,7 @@ class Session { std::vector near; }; std::vector takeOwnCarStates() { return std::exchange(m_ownCarStates, {}); } + std::size_t ownCarStatesQueued() const { return m_ownCarStates.size(); } static constexpr std::size_t kMaxQueuedOwnCarStates = 16; // --- Shared ambient traffic (multiplayer cruise) --- @@ -369,6 +371,14 @@ class Session { void updatePlayout(); bool replicating() const { return m_phase != SessionPhase::Lobby; } void resetReplication(); + // A race-time message stamped with this session's race: the receiver + // drops one from another race (a packet ENet held across the boundary). + template + M raced(const M& msg) const { + M copy = msg; + copy.race = m_race; + return copy; + } PlayerInfo* findPlayer(std::uint8_t id); Remote* remoteForPlayer(std::uint8_t id); std::uint8_t allocatePlayerId() const; diff --git a/src/net/TrafficFull.h b/src/net/TrafficFull.h index 47eba6b..875da11 100644 --- a/src/net/TrafficFull.h +++ b/src/net/TrafficFull.h @@ -60,6 +60,7 @@ struct TrafficFullCar { struct TrafficFullMsg { static constexpr MsgType kType = MsgType::TrafficFull; + std::uint32_t race = 0; // the race it belongs to (net::Session stamps and checks it) std::uint32_t time = 0; // session ms of the host's physics sample (its CarStates' time) std::vector cars; }; @@ -117,6 +118,7 @@ bool serialize(S& s, TrafficBodyState& b) { template bool serialize(S& s, TrafficFullMsg& m) { + s.varU32(m.race); s.u32(m.time); auto count = static_cast(std::min(m.cars.size(), kMaxTrafficFullCars)); s.ranged(count, 0, static_cast(kMaxTrafficFullCars)); diff --git a/tests/net/test_ambient_state.cpp b/tests/net/test_ambient_state.cpp index 0aec4a3..e030415 100644 --- a/tests/net/test_ambient_state.cpp +++ b/tests/net/test_ambient_state.cpp @@ -177,6 +177,7 @@ TEST(AmbientState, MalformedMessagesAreRejected) { // A count beyond the limit. BitWriter w; w.writeU8(static_cast(MsgType::AmbientState)); + w.writeVarU32(0); // race w.writeU32(1); w.writeU32(2); w.writeU16(3); @@ -188,6 +189,7 @@ TEST(AmbientState, MalformedMessagesAreRejected) { auto copWithTarget = [](std::uint32_t target) { BitWriter p; p.writeU8(static_cast(MsgType::AmbientState)); + p.writeVarU32(0); // race p.writeU32(1); p.writeU32(2); p.writeU16(3); diff --git a/tests/net/test_player_cars.cpp b/tests/net/test_player_cars.cpp index e62ecc2..a512c2c 100644 --- a/tests/net/test_player_cars.cpp +++ b/tests/net/test_player_cars.cpp @@ -341,7 +341,7 @@ TEST(PlayerCars, RepeatedInputsCostABitEach) { f.flags = kInputAutomatic; m.frames.assign(kMaxInputFrames, f); const auto bytes = encodeMessage(m); - EXPECT_LE(bytes.size(), 20u); + EXPECT_LE(bytes.size(), 21u); // with the race number (1 byte) PlayerInputMsg out; ASSERT_TRUE(decodeMessage(bytes, out)); EXPECT_EQ(out.frames, m.frames); @@ -595,7 +595,8 @@ TEST(PlayerCars, MutatedTrafficLeavesBothSidesWithinLimits) { checkInput(r.msg); for (const auto& u : p.client.takeOwnCarStates()) if (u.hasOwn) - checkStates({u.time, u.ack, u.waiting, true, u.own, {}, u.nearIds, u.near}); + checkStates({.race = p.client.raceNumber(), .time = u.time, .ack = u.ack, .waiting = u.waiting, .hasOwn = true, + .own = u.own, .cars = {}, .nearIds = u.nearIds, .near = u.near}); } } EXPECT_EQ(p.host.state(), Session::State::Active); diff --git a/tests/net/test_prop_state.cpp b/tests/net/test_prop_state.cpp index 5bf8523..bbed2d0 100644 --- a/tests/net/test_prop_state.cpp +++ b/tests/net/test_prop_state.cpp @@ -110,7 +110,7 @@ TEST(PropState, AGrownRingListsCheaply) { m.slots.push_back(p); } const auto bytes = encodeMessage(m); - EXPECT_LE(bytes.size(), 14u + kMaxPropSlots * 6u / 8u + 1u); // 6 bits a slot + EXPECT_LE(bytes.size(), 15u + kMaxPropSlots * 6u / 8u + 1u); // 6 bits a slot (and the race) PropStateMsg back; ASSERT_TRUE(decodeMessage(bytes, back)); ASSERT_EQ(back.slots.size(), kMaxPropSlots); @@ -135,8 +135,9 @@ TEST(PropState, RefusesSlotsOutOfOrder) { // By hand: slot 5, then slot 5 again (not "the next one"). WriteStream s; std::uint8_t type = static_cast(MsgType::PropState); - std::uint32_t time = 1, catalog = 2, count = 2; + std::uint32_t race = 0, time = 1, catalog = 2, count = 2; s.u8(type); + s.varU32(race); s.u32(time); s.u32(catalog); s.varU32(count); diff --git a/tests/net/test_session_lobby.cpp b/tests/net/test_session_lobby.cpp index 263914c..cc09bc2 100644 --- a/tests/net/test_session_lobby.cpp +++ b/tests/net/test_session_lobby.cpp @@ -93,6 +93,45 @@ VehicleSnapshot at(float x) { // its last state and kept sending it at the snapshot rate through the lobby; // the host stored it and passed it on, so the next race began with the car // drawn (and collided with) where it had stopped in the last one. +// Messages of a race that nobody took before it ended (its last frame, or +// one service with the return to the lobby) are not handed to the next race: +// car states and inputs number their samples from 1 every race. +TEST(SessionLobby, NoUntakenRaceMessagesReachTheNextRace) { + Peer host, a, b; + ASSERT_TRUE(host.session->host(hostParams())); + ASSERT_TRUE(a.session->join(joinParams(host, "A"))); + ASSERT_TRUE(b.session->join(joinParams(host, "B"))); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + return a.session->phase() == SessionPhase::Lobby && b.session->players().size() == 3 && + a.session->clockSynced() && b.session->clockSynced(); + })); + const std::uint8_t aId = a.session->localId(); + startRace(host, a, b); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.session->phase() == SessionPhase::InGame; })); + + // Late in the race: an input from A and car states for A, never taken. + PlayerInputMsg input; + input.first = 5000; + input.frames.resize(1); + CarStatesMsg states; + states.ack = 5000; + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + a.session->sendPlayerInput(input); + host.session->sendCarStates(aId, states); + return host.session->playerInputsQueued() > 0 && a.session->ownCarStatesQueued() > 0; + })); + + host.session->returnToLobby(); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.session->phase() == SessionPhase::Lobby; })); + startRace(host, a, b); + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { return a.session->phase() == SessionPhase::InGame; })); + EXPECT_TRUE(host.session->takePlayerInputs().empty()); + EXPECT_TRUE(a.session->takeOwnCarStates().empty()); + EXPECT_TRUE(a.session->takePropStates().empty()); + EXPECT_TRUE(a.session->takePropFull().empty()); + EXPECT_TRUE(a.session->takeRulesStates().empty()); +} + TEST(SessionLobby, NoVehicleStatesBetweenRaces) { Peer host, a, b; ASSERT_TRUE(host.session->host(hostParams())); diff --git a/tests/net/test_traffic_full.cpp b/tests/net/test_traffic_full.cpp index 5a97690..69cb67e 100644 --- a/tests/net/test_traffic_full.cpp +++ b/tests/net/test_traffic_full.cpp @@ -309,7 +309,7 @@ TEST(TrafficFull, DecoderSurvivesMutatedMessages) { for (std::uint16_t i = 0; i < 4; ++i) big.cars.push_back(body(static_cast(500 + i))); const std::vector corpus{encodeMessage(sample()), encodeMessage(big), - encodeMessage(TrafficFullMsg{7, {police(511)}})}; + encodeMessage(TrafficFullMsg{.time = 7, .cars = {police(511)}})}; std::mt19937 rng(20261012); for (int i = 0; i < 40000; ++i) { const Bytes b = mutate(corpus, rng); From c0d19bdf81b317278b1d9a46933edf910599569f Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 16:06:02 -0700 Subject: [PATCH 2/9] Fix the rest of the multiplayer review's findings - The joiner's password field takes 25 characters, as the host's does (both are UIMenu::AddTextField with 0x19 in MM2): a host's 25-character password could not be typed, and five tries locked the joiner out. - A change to the shared cruise traffic (on/off, traffic and police density) clears a joiner's READY like every other host setting. - A player who quit the race is no longer sent the host's prop states: with no car it counted as near every slot and got them all, in full. - The debug autopilot keeps a destination it cannot route to instead of rebuilding its driver and logging every frame. - A client's input message is encoded once: Session::sendPlayerInput returns the bytes it sent, as the other send calls do. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/RaceScreen.cpp | 4 ++-- src/app/frontend/PagesMulti.cpp | 13 ++++++++----- src/game/net/Autopilot.cpp | 4 +++- src/game/net/NetGame.cpp | 5 ++--- src/game/net/NetGame.h | 2 +- src/game/net/NetProps.cpp | 2 +- src/game/net/NetProps.h | 4 ++++ src/net/Session.cpp | 10 ++++++---- src/net/Session.h | 2 +- 9 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/app/RaceScreen.cpp b/src/app/RaceScreen.cpp index c051f79..410bf9b 100644 --- a/src/app/RaceScreen.cpp +++ b/src/app/RaceScreen.cpp @@ -3728,6 +3728,7 @@ class RaceScreen final : public Screen { *ctx.netGame, *m_bangers, [this](const phys::Instance& other) { return propMover(&other); }, [this](std::uint8_t id) { return netCarAt(id); }); + m_netProps.setInRace([this](std::uint8_t id) { return !m_netLeft.contains(id); }); m_netProps.setClientCars([this](const phys::Instance* b) { return propMoverCar(b); }, [this](int id) { return hostCarState(id); }, [this]() -> std::optional { @@ -6103,9 +6104,8 @@ class RaceScreen final : public Screen { } } else if (m_frameSteps > 0) { if (const auto msg = m_prediction.message()) { - ctx.netGame->sendPlayerInput(*msg); + m_netInputBytes += ctx.netGame->sendPlayerInput(*msg); ++m_netInputsSent; - m_netInputBytes += net::encodeMessage(*msg).size(); } // The session time this machine's car's newest sample ended at, // for the lead over the host (reconcileNetCar). diff --git a/src/app/frontend/PagesMulti.cpp b/src/app/frontend/PagesMulti.cpp index 4e24a99..aa5545e 100644 --- a/src/app/frontend/PagesMulti.cpp +++ b/src/app/frontend/PagesMulti.cpp @@ -456,8 +456,10 @@ class PasswordDialog final : public Page { dialogPicture = "jpg/pass_dlg.jpg"; menuId = kPasswordDialog; origin = centredOrigin(fe, "jpg/pass_dlg.jpg", {400, 330}); - auto& entry = - menu.add(fe.layout.widget(kPasswordDialog, 0, {72, 91, 203, 22}, origin), &m_password, 24); + // UIMenu::AddTextField with 0x19, the host dialog's length too: a + // host's 25-character password can be typed here. + auto& entry = menu.add(fe.layout.widget(kPasswordDialog, 0, {72, 91, 203, 22}, origin), + &m_password, 25); auto join = [this, &fe] { const auto target = m_address; // Trimmed as the host's is (HostOptionsDialog::host). @@ -864,10 +866,11 @@ class LobbyPage final : public Page { // What the joiners' ready depends on: the host's race settings. static std::string settingsKey(const game::RaceConfig& c, int goldMass) { - return std::format("{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}", c.city, static_cast(c.mode), c.raceIndex, c.laps, - static_cast(c.timeOfDay), static_cast(c.weather), + // OpenMM2's shared cruise traffic too (netTraffic and its densities). + return std::format("{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}", c.city, static_cast(c.mode), + c.raceIndex, c.laps, static_cast(c.timeOfDay), static_cast(c.weather), static_cast(c.copsAndRobbers), c.timeLimitMinutes, c.pointLimit, goldMass, - c.pedestrianDensity); + c.pedestrianDensity, c.netTraffic, c.trafficDensity, c.copDensity); } // NetArena::LoadRaceMap: "_map" + dgGameModeNames[mode] with the diff --git a/src/game/net/Autopilot.cpp b/src/game/net/Autopilot.cpp index c0280b4..4ee8d23 100644 --- a/src/game/net/Autopilot.cpp +++ b/src/game/net/Autopilot.cpp @@ -82,7 +82,9 @@ std::unique_ptr NetAutopilot::createFree(const city::CityData& cit void NetAutopilot::setTarget(phys::CarSim& car, const Vec3& target, const phys::GroundQuery* ground) { // A new destination: the racer AI drives the city's roads to it. - if (m_target && m_target->dist2(target) < 25.0f * 25.0f && m_driver) + // The same destination again: kept, also when the AI could not route to + // it (tried once, not every frame). + if (m_target && m_target->dist2(target) < 25.0f * 25.0f) return; m_target = target; const Vec3& from = car.body.ics.matrix.m3; diff --git a/src/game/net/NetGame.cpp b/src/game/net/NetGame.cpp index 31c3f60..7d45c0d 100644 --- a/src/game/net/NetGame.cpp +++ b/src/game/net/NetGame.cpp @@ -861,9 +861,8 @@ std::vector NetGame::takeGameEvents() { return std::exchange(m_gam // --- The players' cars ---------------------------------------------------------------------- -void NetGame::sendPlayerInput(const net::PlayerInputMsg& msg) { - if (m_impl->session) - m_impl->session->sendPlayerInput(msg); +std::size_t NetGame::sendPlayerInput(const net::PlayerInputMsg& msg) { + return m_impl->session ? m_impl->session->sendPlayerInput(msg) : 0; } std::vector NetGame::takePlayerInputs() { diff --git a/src/game/net/NetGame.h b/src/game/net/NetGame.h index d6f0a25..770bc39 100644 --- a/src/game/net/NetGame.h +++ b/src/game/net/NetGame.h @@ -318,7 +318,7 @@ class NetGame { // --- The players' cars, simulated by the host (net/PlayerCarState.h) ------------------- // Client: this machine's car's inputs. - void sendPlayerInput(const net::PlayerInputMsg& msg); + std::size_t sendPlayerInput(const net::PlayerInputMsg& msg); // bytes sent // Host: the clients' inputs received since the last call. std::vector takePlayerInputs(); // Host: one client's states; returns the bytes sent. diff --git a/src/game/net/NetProps.cpp b/src/game/net/NetProps.cpp index 8375845..d122785 100644 --- a/src/game/net/NetProps.cpp +++ b/src/game/net/NetProps.cpp @@ -223,7 +223,7 @@ void NetProps::afterStep(NetGame& net, double now, std::uint64_t nowMs, const Cl sendCatchUps(net, time); std::vector viewers; for (const auto& p : net.players()) - if (p.id != net.localId() && net.playerLoaded(p.id)) + if (p.id != net.localId() && net.playerLoaded(p.id) && (!m_inRace || m_inRace(p.id))) viewers.push_back({p.id, m_carOf ? m_carOf(p.id) : std::nullopt}); for (const auto& [id, msg] : m_host->build(*m_set, time, nowMs, m_catalog, viewers)) { const std::size_t bytes = net.sendPropState(id, msg); diff --git a/src/game/net/NetProps.h b/src/game/net/NetProps.h index 0186454..60772f4 100644 --- a/src/game/net/NetProps.h +++ b/src/game/net/NetProps.h @@ -93,6 +93,9 @@ class NetProps { m_hostCar = std::move(hostCar); m_ownCarAt = std::move(ownCarAt); } + // Host: whether a player is still in the race (one who quit it has no + // car and is sent nothing). + void setInRace(std::function inRace) { m_inRace = std::move(inRace); } bool host() const { return m_host.has_value(); } bool client() const { return m_client.has_value(); } // A client takes the cars' thrown parts from the host's ring (except @@ -146,6 +149,7 @@ class NetProps { std::uint32_t m_catalog = 0; std::set m_caughtUp; // host: players sent every knock so far CarOf m_carOf; + std::function m_inRace; PropClient::CarOfToucher m_carOfToucher; PropClient::HostCar m_hostCar; OwnCarAt m_ownCarAt; diff --git a/src/net/Session.cpp b/src/net/Session.cpp index 1d2960f..64dc662 100644 --- a/src/net/Session.cpp +++ b/src/net/Session.cpp @@ -1391,10 +1391,12 @@ void Session::sendGameEvent(std::uint16_t type, std::vector payload, sendTo(m_hostPeer, Channel::Events, std::move(msg)); } -void Session::sendPlayerInput(const PlayerInputMsg& msg) { - if (m_role != Role::Client || m_state != State::Active || !replicating() || msg.frames.empty()) - return; - sendTo(m_hostPeer, Channel::State, raced(msg)); +std::size_t Session::sendPlayerInput(const PlayerInputMsg& msg) { + if (m_role != Role::Client || !m_transport || m_state != State::Active || !replicating() || + msg.frames.empty()) + return 0; + const auto packet = encodeMessage(raced(msg)); + return m_transport->send(m_hostPeer, Channel::State, packet) ? packet.size() : 0; } std::size_t Session::sendCarStates(std::uint8_t playerId, const CarStatesMsg& msg) { diff --git a/src/net/Session.h b/src/net/Session.h index 6f39adf..e1a0ed5 100644 --- a/src/net/Session.h +++ b/src/net/Session.h @@ -254,7 +254,7 @@ class Session { // --- The players' cars, simulated by the host (net/PlayerCarState.h) --- // Client: this machine's inputs to the host (during a race only). - void sendPlayerInput(const PlayerInputMsg& msg); + std::size_t sendPlayerInput(const PlayerInputMsg& msg); // bytes sent, 0 if not // Host: the clients' inputs received since the last call, oldest first // (at most kMaxQueuedPlayerInputs; a joiner beyond its budget of // kInputRate messages a second loses the excess, which the next ones From bde070a1b253ada6780b71fa9124b3c031498076 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 16:07:40 -0700 Subject: [PATCH 3/9] Check that the next race's own messages still come through The race number test now also sends race 2's input and car states and checks they arrive, so a mismatch between the stamping and the check would show as lost states rather than pass unnoticed. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/net/test_session_lobby.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/net/test_session_lobby.cpp b/tests/net/test_session_lobby.cpp index cc09bc2..e262e81 100644 --- a/tests/net/test_session_lobby.cpp +++ b/tests/net/test_session_lobby.cpp @@ -130,6 +130,21 @@ TEST(SessionLobby, NoUntakenRaceMessagesReachTheNextRace) { EXPECT_TRUE(a.session->takePropStates().empty()); EXPECT_TRUE(a.session->takePropFull().empty()); EXPECT_TRUE(a.session->takeRulesStates().empty()); + + // The new race's own messages come through (both sides stamp race 2). + input.first = 1; + states.ack = 1; + ASSERT_TRUE(pumpUntil({&host, &a, &b}, [&] { + a.session->sendPlayerInput(input); + host.session->sendCarStates(aId, states); + return host.session->playerInputsQueued() > 0 && a.session->ownCarStatesQueued() > 0; + })); + const auto inputs = host.session->takePlayerInputs(); + ASSERT_FALSE(inputs.empty()); + EXPECT_EQ(inputs.front().msg.first, 1u); + const auto own = a.session->takeOwnCarStates(); + ASSERT_FALSE(own.empty()); + EXPECT_EQ(own.front().ack, 1u); } TEST(SessionLobby, NoVehicleStatesBetweenRaces) { From 99352a232e7978420c21d7decd47e22bc31fe67d Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 16:27:21 -0700 Subject: [PATCH 4/9] Call this version 0.4.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 2 +- packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 93d4f62..6395728 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25...4.0) project(OpenMM2 - VERSION 0.4.0 + VERSION 0.4.1 DESCRIPTION "Open source reimplementation of Midtown Madness 2" HOMEPAGE_URL "https://github.com/lowlevelmetal/OpenMM2" LANGUAGES C CXX) diff --git a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml index fb736dc..4abba44 100644 --- a/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml +++ b/packaging/linux/io.github.lowlevelmetal.OpenMM2.metainfo.xml @@ -44,6 +44,11 @@ 768 + + +

Multiplayer fixes: a late network message from one race can no longer stop a player's car responding in the next, a host's 25-character password can be typed by joiners, and changing the shared traffic settings clears the joiners' ready.

+
+

Multiplayer rebuilt around the host: the host decides every car, collision, traffic and police car, prop, damage and rule, while each player's own car and the cars near it are predicted so they stay responsive. Players see the same game on every screen, and Windows and Linux players simulate identically.

From 6288b1ab38b80703089245f98b70c41746a174a2 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 17:28:23 -0700 Subject: [PATCH 5/9] Keep the Windows build off vcruntime140_threads.dll The bundled DirectMusic library used C11 wherever the compiler had it, and MSVC's C11 threads live in vcruntime140_threads.dll (VS 2022 17.8+). The packages ship the other VC++ runtime DLLs but not that one, so openmm2.exe failed to load before main on a PC without a recent VC++ redistributable (found by unpacking the 0.4.0 installer and running it under Wine: status c0000135). MSVC now uses the library's Win32 threads, as MinGW already did. The Windows CI job could not see this, since its runner has Visual Studio: a new step reads every packaged program's and DLL's imports with dumpbin and fails when a VC++ runtime DLL is not in the package, or any other DLL is neither there nor part of Windows. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ cmake/Dependencies.cmake | 5 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cbb3d4..0e956e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,31 @@ jobs: - name: Build portable ZIP run: cpack --preset windows-portable + # The runner has Visual Studio, so a missing runtime DLL would never fail + # a test here. Every VC++ runtime DLL the package's programs and DLLs + # import must be in the package, and every other DLL there or in Windows. + - name: Check the package's DLL imports + shell: pwsh + run: | + $zip = Get-ChildItem out/build/windows-msvc -Filter 'OpenMM2-*-portable.zip' | Select-Object -First 1 + $dir = Join-Path $env:RUNNER_TEMP 'portable-check' + Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue + Expand-Archive $zip.FullName -DestinationPath $dir + $bad = @() + foreach ($exe in Get-ChildItem $dir -Recurse -Include *.exe, *.dll) { + $here = $exe.DirectoryName + $deps = (dumpbin /nologo /dependents $exe.FullName) | + ForEach-Object { if ($_ -match '^\s+(\S+\.dll)\s*$') { $Matches[1] } } + foreach ($dll in $deps) { + $packaged = Test-Path (Join-Path $here $dll) + $runtime = $dll -match '^(vcruntime|msvcp|concrt|vccorlib)' + $system = (Test-Path (Join-Path "$env:WINDIR\System32" $dll)) -or $dll -match '^(api|ext)-ms-' + Write-Host "$($exe.Name): $dll packaged=$packaged" + if (-not $packaged -and ($runtime -or -not $system)) { $bad += "$($exe.Name) imports $dll, which the package does not ship" } + } + } + if ($bad.Count -gt 0) { $bad | ForEach-Object { Write-Error $_ }; exit 1 } + - name: Upload packages uses: actions/upload-artifact@v7 with: diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index e9e19c6..29f5c41 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -235,7 +235,10 @@ check_c_source_compiles(" #include int main(void) { mtx_t m; if (mtx_init(&m, mtx_recursive) != thrd_success) return 1; mtx_destroy(&m); return 0; }" OPENMM2_HAVE_C11_THREADS) -if(OPENMM2_HAVE_C11_THREADS) +# Not on MSVC: its C11 threads live in vcruntime140_threads.dll (VS 2022 17.8+), +# which older VC++ redistributables lack and the packages did not ship, so the +# game failed to start on such PCs. The library's Win32 threads, as on MinGW. +if(OPENMM2_HAVE_C11_THREADS AND NOT MSVC) target_compile_definitions(openmm2_dmusic PRIVATE _DM_USE_NATIVE_THREAD=1) else() target_sources(openmm2_dmusic PRIVATE "${_dm}/src/thread/Thread.Posix.c" "${_dm}/src/thread/Thread.Win32.c") From bb079b17647d13adeeb83b9d925b02502bb656df Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 17:36:23 -0700 Subject: [PATCH 6/9] Never delete an earlier game data copy when copying fails When copying the game data failed, setup removed all of \gamedata. On a reinstall or update over an installation that had copied the data already, a copy that then failed (a bad disc, a full drive) deleted the good copy too. Setup now notes whether gamedata\ was there before it copies. If it was not, everything in it came from this copy and is removed as before. If it was, nothing is removed (--import-source deletes its own .part files on failure and replaces an archive only once it is complete), and when the earlier copy still passes --check-source, openmm2-install.ini keeps pointing at it rather than at the source that just failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- packaging/windows/openmm2.nsi | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packaging/windows/openmm2.nsi b/packaging/windows/openmm2.nsi index dc56d3f..f340dee 100644 --- a/packaging/windows/openmm2.nsi +++ b/packaging/windows/openmm2.nsi @@ -20,7 +20,9 @@ ; openmm2.exe --check-source exit 0 when usable; prints a ; one-line reason otherwise ; openmm2.exe --import-source copies the archives, printing -; "progress <0-100>" lines +; "progress <0-100>" lines; on +; failure it removes its partial +; (.part) files itself ; and the installer writes $INSTDIR\openmm2-install.ini (UTF-8): ; [GameData] ; Source= @@ -270,6 +272,12 @@ Function InstallGameData StrCpy $R2 $GameSource ${If} $CopyData == 1 StrCpy $R3 "$INSTDIR\gamedata" + ; A folder that is already there holds an earlier copy (an update or + ; repair): a failed copy must never remove it. + StrCpy $R5 0 + ${If} ${FileExists} "$R3\*.*" + StrCpy $R5 1 + ${EndIf} DetailPrint "Copying game data to $R3" Push $R3 Call QuoteArg @@ -282,8 +290,26 @@ Function InstallGameData StrCpy $R2 $R3 ${Else} DetailPrint "Copying failed (exit code $0)" - RMDir /r "$R3" - MessageBox MB_ICONEXCLAMATION|MB_OK "Copying the game data failed. ${PRODUCT} will read it from $GameSource instead." /SD IDOK + ${If} $R5 == 0 + ; Everything in the folder came from this copy. + RMDir /r "$R3" + MessageBox MB_ICONEXCLAMATION|MB_OK "Copying the game data failed. ${PRODUCT} will read it from $GameSource instead." /SD IDOK + ${Else} + ; The import removed its partial files; the earlier copy is + ; untouched. Keep using it if it is complete. + Push $R3 + Call QuoteArg + Pop $R4 + nsExec::ExecToStack '"$INSTDIR\openmm2.exe" --check-source $R4' + Pop $0 + Pop $1 + ${If} $0 == 0 + StrCpy $R2 $R3 + MessageBox MB_ICONEXCLAMATION|MB_OK "Copying the game data failed. ${PRODUCT} keeps using the copy that is already in $R3." /SD IDOK + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_OK "Copying the game data failed. ${PRODUCT} will read it from $GameSource instead." /SD IDOK + ${EndIf} + ${EndIf} ${EndIf} ${EndIf} From dbb8f07178267a54c80fd24a9af59d3542ceff8c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 17:37:03 -0700 Subject: [PATCH 7/9] Let the Windows installer update an existing installation Setup now finds the installed version (HKLM\Software\OpenMM2: InstallDir, Version) and updates it in its folder: - The welcome page says what will happen: a new installation, an update from X to Y, a repair (the same version again) or a downgrade. An update skips the licence and directory pages, and the game data page, pre-filled from openmm2-install.ini as before, reads Install: Next, Next, Install. /D= still installs elsewhere. - An older setup asks before it replaces a newer version. A silent one refuses with exit code 3 unless given /ALLOWDOWNGRADE. - Before it changes anything, setup checks that openmm2.exe and mm2tool.exe are not running, by opening them for writing (a running program's executable cannot be), and asks to close OpenMM2 with Retry/Cancel. A silent install stops with exit code 4. The uninstaller checks too. - Setup writes install-manifest.txt, the list of the files it installed (generated with the File lists). Before copying the new files, an update removes the previous version's: those in its manifest, or, for 0.4.1 and earlier, which wrote none, every name those releases installed (LegacyFiles.nsh, from the release installers 0.1.0 to 0.4.0, which include the MSVC runtime DLLs) and this version installs. The uninstaller removes what the manifest lists, so files a newer version dropped no longer stay behind, and neither does the folder. Manifest entries that could reach outside the install folder, into gamedata\ or openmm2-install.ini are skipped. gamedata\, openmm2-install.ini and the per-user settings are never removed. - The choice of shortcuts and developer tools is recorded (StartMenuShortcut, DesktopShortcut, Tools) and preselected on the next update. For 0.4.1 and earlier, setup keeps what is there: a deleted shortcut is not created again. A shortcut that is switched off is removed. - /D= naming another folder moves the installation: the old folder loses its program files, uninstaller and firewall rule, keeps gamedata\ and openmm2-install.ini (whose game data the new installation keeps using), and is removed if nothing else is left. The registration stays single. - A file that cannot be written (another program holds it) stops setup (AllowSkipFiles off) instead of being skipped, which left an old file in place and still reported success. Such a failure ends with exit code 1, not NSIS's 2, which here means the /SOURCE game data was rejected. - A silent update that re-checks the last installation's game data (say, a disc that is not in the drive) keeps the setting instead of failing with exit code 2. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 30 +- packaging/windows/GameDataPage.nsh | 8 + packaging/windows/GenerateFileList.cmake | 31 +- packaging/windows/Helpers.nsh | 245 ++++++++++++++ packaging/windows/LegacyFiles.nsh | 51 +++ packaging/windows/openmm2.nsi | 410 +++++++++++++++++++++-- 6 files changed, 739 insertions(+), 36 deletions(-) create mode 100644 packaging/windows/LegacyFiles.nsh diff --git a/README.md b/README.md index a0ca011..f314b79 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ Run `OpenMM2--windows-x86_64-setup.exe` (64-bit Windows 7 or later). The installer asks where your Midtown Madness 2 files are and checks them before continuing. You can also skip that step; OpenMM2 asks on first start. +To update, run the new version's installer. It finds the installed version +and updates it in its folder, keeping your settings, driver profiles, +replays and game data; the game data and shortcut choices are filled in from +last time, so an update is Next, Next, Install. Files that the new version no +longer ships are removed. Running the same version again repairs the +installation, and an older version asks before it replaces a newer one. Close +OpenMM2 first: setup waits (Retry/Cancel) until it is closed. + Silent installs accept: | Option | Meaning | @@ -53,9 +61,27 @@ Silent installs accept: | `/D=C:\Games\OpenMM2` | install directory (must be last, unquoted) | | `/SOURCE=` | game data: disc image, drive root such as `D:\`, or install folder | | `/COPYDATA` | copy the game archives to `\gamedata` | +| `/ALLOWDOWNGRADE` | replace a newer installed version (refused otherwise) | -The exit code is 0 on success and 2 if the program was installed but the -`/SOURCE` was rejected. +| Exit code | Meaning | +| --- | --- | +| 0 | success | +| 1 | cancelled or failed (for example, another program held a file open) | +| 2 | installed, but the `/SOURCE` game data was rejected (OpenMM2 asks on first start) | +| 3 | nothing changed: a newer version is installed and `/ALLOWDOWNGRADE` was not given | +| 4 | nothing changed: OpenMM2 is running | + +The uninstaller also stops while OpenMM2 is running (exit code 4 when run as +`uninstall.exe /S _?=`; otherwise it restarts itself from a +temporary copy and returns at once). + +A silent update keeps the last installation's choices: game data, shortcuts +and developer tools. `/D=` naming another folder moves the installation: +setup removes the old folder's program files and uninstaller but keeps its +`gamedata` folder and `openmm2-install.ini` (and keeps using that game data +unless `/SOURCE` is given), so the old folder stays only if it holds them. +The installer lists what it installed in `install-manifest.txt`, which the +next update and the uninstaller use to remove exactly those files. The portable ZIP (`OpenMM2--windows-x86_64-portable.zip`) needs no installation: unpack it anywhere. Because it contains `portable.txt`, settings diff --git a/packaging/windows/GameDataPage.nsh b/packaging/windows/GameDataPage.nsh index 2f83c3b..d0ea121 100644 --- a/packaging/windows/GameDataPage.nsh +++ b/packaging/windows/GameDataPage.nsh @@ -9,6 +9,9 @@ ; Inputs/outputs (declared in openmm2.nsi): ; $GameSource chosen path, "" to skip ; $CopyData 1 to import the archives into $INSTDIR\gamedata +; Input (declared here): +; $GD_NextIsInstall 1 when installation starts right after this page (an +; update skips the directory page), so Next reads Install ; Requires the section index ${SecGameData} and the define STAGE_DIR. !include nsDialogs.nsh @@ -38,6 +41,7 @@ Var GD_Folder Var GD_CopyState ; 0/1 Var GD_Initialized Var GD_ExeReady +Var GD_NextIsInstall ; Fills the state from $GameSource (previous install or /SOURCE=) or from ; whatever can be found on this machine. @@ -124,6 +128,10 @@ Function GameDataPageCreate ${If} $GD_Dialog == error Abort ${EndIf} + ${If} $GD_NextIsInstall == 1 + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${WM_SETTEXT} 0 "STR:$(^InstallBtn)" + ${EndIf} ${NSD_CreateLabel} 0 0 100% 18u "OpenMM2 contains no game content. It uses the files from your original Midtown Madness 2 disc. Where are they?" Pop $0 diff --git a/packaging/windows/GenerateFileList.cmake b/packaging/windows/GenerateFileList.cmake index 28c8000..df16d0e 100644 --- a/packaging/windows/GenerateFileList.cmake +++ b/packaging/windows/GenerateFileList.cmake @@ -1,8 +1,12 @@ -# Writes an NSIS include with two macros for a staged install tree: -# !macro _INSTALL SetOutPath/File lines for every staged file -# !macro _UNINSTALL matching Delete lines, then RMDir for the -# subdirectories (deepest first, never recursive) -# so the uninstaller removes exactly what was installed. +# Writes an NSIS include with three macros for a staged install tree: +# !macro _INSTALL SetOutPath/File lines for every staged file +# !macro _UNINSTALL matching Delete lines under , then +# RMDir for the subdirectories (deepest +# first, never recursive) +# !macro _MANIFEST FileWrite lines naming every staged file, +# relative to the install directory, for +# install-manifest.txt (see openmm2.nsi) +# so setup and the uninstaller remove exactly what was installed. # # cmake -DSTAGE= -DOUTPUT= -DMACRO=OPENMM2_APP # [-DWINE_PATHS=ON] -P GenerateFileList.cmake @@ -38,10 +42,17 @@ list(SORT files) set(install "; Generated by packaging/windows/GenerateFileList.cmake. Do not edit.\n") string(APPEND install "!macro ${MACRO}_INSTALL\n") -set(uninstall "!macro ${MACRO}_UNINSTALL\n") +set(uninstall "!macro ${MACRO}_UNINSTALL base\n") +set(manifest "!macro ${MACRO}_MANIFEST handle\n") set(dirs) set(current_dir "") foreach(rel IN LISTS files) + # install-manifest.txt is written with FileWrite, which converts to the + # ANSI code page: keep the names plain ASCII so the file reads the same + # everywhere. The names are ours (programs, licences, fonts). + if(NOT rel MATCHES "^[A-Za-z0-9 ._+/-]+$") + message(FATAL_ERROR "GenerateFileList.cmake: unsupported characters in staged file name '${rel}'") + endif() cmake_path(GET rel PARENT_PATH dir) if(NOT dir STREQUAL current_dir) set(current_dir "${dir}") @@ -63,7 +74,8 @@ foreach(rel IN LISTS files) string(APPEND install " File \"${src}\"\n") string(REPLACE "/" "\\" rel_win "${rel}") _nsis_escape(rel_win "${rel_win}") - string(APPEND uninstall " Delete \"$INSTDIR\\${rel_win}\"\n") + string(APPEND uninstall " Delete \"\${base}\\${rel_win}\"\n") + string(APPEND manifest " FileWrite \${handle} \"${rel_win}$\\r$\\n\"\n") endforeach() # Deepest directories first. @@ -80,9 +92,10 @@ foreach(entry IN LISTS keyed) string(REGEX REPLACE "^[0-9]+\\|" "" d "${entry}") string(REPLACE "/" "\\" d_win "${d}") _nsis_escape(d_win "${d_win}") - string(APPEND uninstall " RMDir \"$INSTDIR\\${d_win}\"\n") + string(APPEND uninstall " RMDir \"\${base}\\${d_win}\"\n") endforeach() string(APPEND install "!macroend\n") string(APPEND uninstall "!macroend\n") -file(WRITE "${OUTPUT}" "${install}\n${uninstall}") +string(APPEND manifest "!macroend\n") +file(WRITE "${OUTPUT}" "${install}\n${uninstall}\n${manifest}") diff --git a/packaging/windows/Helpers.nsh b/packaging/windows/Helpers.nsh index a14cb4a..b3a80a6 100644 --- a/packaging/windows/Helpers.nsh +++ b/packaging/windows/Helpers.nsh @@ -2,6 +2,7 @@ ; ; All functions preserve the registers they use, except for documented ; outputs. They rely only on plug-ins shipped with NSIS (System, nsExec). +; Functions named "un.X" are the uninstaller's copies of X. !ifndef OPENMM2_HELPERS_NSH !define OPENMM2_HELPERS_NSH @@ -41,6 +42,250 @@ FunctionEnd !macroend !insertmacro OPENMM2_QUOTEARG "" +; --------------------------------------------------------------------------- +; SameDir: 1 when two folder paths name the same folder, comparing without +; case and without a trailing backslash; otherwise 0. (Short 8.3 names or +; "..\" components are not resolved.) +; Push +; Push +; Call SameDir / un.SameDir +; Pop <1 or 0> +!macro OPENMM2_SAMEDIR un +Function ${un}SameDir + Exch $1 ; b + Exch + Exch $0 ; a + Push $2 + StrCpy $2 $0 1 -1 + ${If} $2 == "\" + StrCpy $0 $0 -1 + ${EndIf} + StrCpy $2 $1 1 -1 + ${If} $2 == "\" + StrCpy $1 $1 -1 + ${EndIf} + ${If} $0 == $1 ; case-insensitive + ${AndIf} $0 != "" + StrCpy $0 1 + ${Else} + StrCpy $0 0 + ${EndIf} + Pop $2 + Pop $1 + Exch $0 +FunctionEnd +!macroend +!insertmacro OPENMM2_SAMEDIR "" +!insertmacro OPENMM2_SAMEDIR "un." + +; --------------------------------------------------------------------------- +; IsFileInUse: 1 when another process has the file open without letting +; others write to it, which is the case for the executable of a running +; program (and for a program that holds the file open, such as a scanner); +; otherwise 0, also for a missing file. Asks Windows by opening the file for +; writing (without changing it) instead of looking for process names. +; Push +; Call IsFileInUse / un.IsFileInUse +; Pop <1 or 0> +!define OPENMM2_GENERIC_WRITE 0x40000000 +!define OPENMM2_FILE_SHARE_ALL 7 ; read, write and delete +!define OPENMM2_OPEN_EXISTING 3 +!define OPENMM2_ERROR_SHARING_VIOLATION 32 +!define OPENMM2_ERROR_LOCK_VIOLATION 33 +!macro OPENMM2_ISFILEINUSE un +Function ${un}IsFileInUse + Exch $0 + Push $1 + Push $2 + StrCpy $2 0 + ${If} ${FileExists} "$0" + System::Call 'kernel32::CreateFileW(w r0, i ${OPENMM2_GENERIC_WRITE}, i ${OPENMM2_FILE_SHARE_ALL}, p 0, i ${OPENMM2_OPEN_EXISTING}, i 0, p 0) p .r1 ?e' + Pop $2 ; GetLastError() + ${If} $1 = -1 ; INVALID_HANDLE_VALUE + ${If} $2 = ${OPENMM2_ERROR_SHARING_VIOLATION} + ${OrIf} $2 = ${OPENMM2_ERROR_LOCK_VIOLATION} + StrCpy $2 1 + ${Else} + StrCpy $2 0 ; e.g. access denied: not a running program + ${EndIf} + ${Else} + System::Call 'kernel32::CloseHandle(p r1)' + StrCpy $2 0 + ${EndIf} + ${EndIf} + StrCpy $0 $2 + Pop $2 + Pop $1 + Exch $0 +FunctionEnd +!macroend +!insertmacro OPENMM2_ISFILEINUSE "" +!insertmacro OPENMM2_ISFILEINUSE "un." + +; --------------------------------------------------------------------------- +; IsSafeManifestEntry: 1 when a line of install-manifest.txt is a plain path +; relative to the install directory that stays inside it and is not the game +; data or setup's game data setting; otherwise 0. setup only ever writes plain +; ASCII names (GenerateFileList.cmake), so anything unusual is refused rather +; than interpreted: drive letters and streams (":"), wildcards, "/", "~" +; (8.3 short names such as GAMEDA~1), "." or ".." components, and leading or +; trailing dots, spaces or backslashes (Windows strips trailing dots and +; spaces, so "gamedata.\x" would name gamedata\x). +; Push +; Call IsSafeManifestEntry / un.IsSafeManifestEntry +; Pop <1 or 0> +!macro OPENMM2_ISSAFEMANIFESTENTRY un +Function ${un}IsSafeManifestEntry + Exch $0 ; entry + Push $1 ; result + Push $2 + Push $3 + Push $4 + StrCpy $1 1 + StrLen $3 $0 + ${If} $3 == 0 + StrCpy $1 0 + ${EndIf} + StrCpy $2 $0 1 + ${If} $2 == "\" + ${OrIf} $2 == "." + ${OrIf} $2 == " " + StrCpy $1 0 + ${EndIf} + StrCpy $2 $0 1 -1 + ${If} $2 == "\" + ${OrIf} $2 == "." + ${OrIf} $2 == " " + StrCpy $1 0 + ${EndIf} + StrCpy $4 0 + ${DoWhile} $4 < $3 + StrCpy $2 $0 1 $4 + ${If} $2 == ":" + ${OrIf} $2 == "*" + ${OrIf} $2 == "?" + ${OrIf} $2 == "/" + ${OrIf} $2 == "~" + ${OrIf} $2 == "<" + ${OrIf} $2 == ">" + ${OrIf} $2 == "|" + ${OrIf} $2 == '"' + StrCpy $1 0 + ${EndIf} + StrCpy $2 $0 2 $4 + ${If} $2 == ".." + ${OrIf} $2 == "\\" + ${OrIf} $2 == "\." + ${OrIf} $2 == ".\" + ${OrIf} $2 == " \" + ${OrIf} $2 == "\ " + StrCpy $1 0 + ${EndIf} + IntOp $4 $4 + 1 + ${Loop} + ; Never the game data or the files setup manages itself (comparisons + ; ignore case). + StrCpy $2 $0 9 + ${If} $0 == "gamedata" + ${OrIf} $2 == "gamedata\" + ${OrIf} $0 == "openmm2-install.ini" + ${OrIf} $0 == "install-manifest.txt" + StrCpy $1 0 + ${EndIf} + StrCpy $0 $1 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Exch $0 +FunctionEnd +!macroend +!insertmacro OPENMM2_ISSAFEMANIFESTENTRY "" +!insertmacro OPENMM2_ISSAFEMANIFESTENTRY "un." + +; --------------------------------------------------------------------------- +; RemoveListedFiles: removes what \install-manifest.txt lists: one path +; relative to per line, ";" starts a comment line. After each file the +; folders it was in are removed if they are empty (never recursively), so +; folders with anything else in them stay. Unsafe entries are skipped (see +; IsSafeManifestEntry). Finally the manifest itself is deleted. Does nothing +; when there is no manifest. +; Push +; Call RemoveListedFiles / un.RemoveListedFiles +!macro OPENMM2_REMOVELISTEDFILES un +Function ${un}RemoveListedFiles + Exch $0 ; dir + Push $1 ; file handle + Push $2 ; entry + Push $3 + Push $4 + Push $5 + ClearErrors + FileOpen $1 "$0\install-manifest.txt" r + ${IfNot} ${Errors} + ${Do} + ClearErrors + FileRead $1 $2 + ${If} ${Errors} + ${ExitDo} ; end of file + ${EndIf} + ${Do} ; strip the line ending + StrCpy $3 $2 1 -1 + ${If} $3 == "$\r" + ${OrIf} $3 == "$\n" + StrCpy $2 $2 -1 + ${Else} + ${ExitDo} + ${EndIf} + ${Loop} + StrCpy $3 $2 1 + ${If} $2 == "" + ${OrIf} $3 == ";" + ${Continue} + ${EndIf} + Push $2 + Call ${un}IsSafeManifestEntry + Pop $3 + ${If} $3 != 1 + DetailPrint "Skipped unexpected entry in install-manifest.txt: $2" + ${Continue} + ${EndIf} + Delete "$0\$2" + ; Its folders, innermost first, while they are empty. + StrCpy $3 $2 + ${Do} + StrLen $4 $3 + ${Do} ; $4 = position of the last backslash, or -1 + IntOp $4 $4 - 1 + ${If} $4 < 0 + ${ExitDo} + ${EndIf} + StrCpy $5 $3 1 $4 + ${If} $5 == "\" + ${ExitDo} + ${EndIf} + ${Loop} + ${If} $4 <= 0 + ${ExitDo} + ${EndIf} + StrCpy $3 $3 $4 + RMDir "$0\$3" + ${Loop} + ${Loop} + FileClose $1 + Delete "$0\install-manifest.txt" + ${EndIf} + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd +!macroend +!insertmacro OPENMM2_REMOVELISTEDFILES "" +!insertmacro OPENMM2_REMOVELISTEDFILES "un." + ; --------------------------------------------------------------------------- ; WriteUtf8File: writes a string to a file as UTF-8 without BOM. (WriteINIStr ; would encode non-ASCII paths in the ANSI code page, which OpenMM2's INI diff --git a/packaging/windows/LegacyFiles.nsh b/packaging/windows/LegacyFiles.nsh new file mode 100644 index 0000000..7d785f9 --- /dev/null +++ b/packaging/windows/LegacyFiles.nsh @@ -0,0 +1,51 @@ +; Files that the installers of OpenMM2 0.4.1 and earlier put in the install +; directory. Those versions wrote no install-manifest.txt, so this list is how +; an update from them (and an uninstaller that finds no manifest) knows what +; to remove. It is the union of the release installers' contents (0.1.0 to +; 0.4.0, all MSVC builds with the same file set) and of the packaging rules in +; git history; 0.4.1 ships the same rules. Never add gamedata\ or +; openmm2-install.ini: they belong to the user's game data setup. +; +; !insertmacro OPENMM2_LEGACY_UNINSTALL + +!ifndef OPENMM2_LEGACY_FILES_NSH +!define OPENMM2_LEGACY_FILES_NSH + +!macro OPENMM2_LEGACY_UNINSTALL base + Delete "${base}\openmm2.exe" + Delete "${base}\mm2tool.exe" + Delete "${base}\uninstall.exe" + Delete "${base}\LICENSE.txt" + Delete "${base}\README.md" + ; MSVC runtime (InstallRequiredSystemLibraries, Visual Studio 2022). + Delete "${base}\concrt140.dll" + Delete "${base}\msvcp140.dll" + Delete "${base}\msvcp140_1.dll" + Delete "${base}\msvcp140_2.dll" + Delete "${base}\msvcp140_atomic_wait.dll" + Delete "${base}\msvcp140_codecvt_ids.dll" + Delete "${base}\vcruntime140.dll" + Delete "${base}\vcruntime140_1.dll" + ; Fallback fonts (cmake/Fonts.cmake). + Delete "${base}\fonts\GilliusADF-COPYING.txt" + Delete "${base}\fonts\GilliusADFNo2-Bold.otf" + Delete "${base}\fonts\GilliusADFNo2-Regular.otf" + Delete "${base}\fonts\LiberationSans-Bold.ttf" + Delete "${base}\fonts\LiberationSans-LICENSE.txt" + Delete "${base}\fonts\LiberationSans-Regular.ttf" + ; Licences of the bundled dependencies (cmake/Packaging.cmake). + Delete "${base}\licenses\dmusic.txt" + Delete "${base}\licenses\enet.txt" + Delete "${base}\licenses\imgui.txt" + Delete "${base}\licenses\miniupnpc.txt" + Delete "${base}\licenses\miniz.txt" + Delete "${base}\licenses\sdl3.txt" + Delete "${base}\licenses\stb.txt" + Delete "${base}\licenses\volk.txt" + Delete "${base}\licenses\vulkanheaders.txt" + Delete "${base}\licenses\vulkanmemoryallocator.txt" + RMDir "${base}\fonts" + RMDir "${base}\licenses" +!macroend + +!endif ; OPENMM2_LEGACY_FILES_NSH diff --git a/packaging/windows/openmm2.nsi b/packaging/windows/openmm2.nsi index f340dee..c2664e3 100644 --- a/packaging/windows/openmm2.nsi +++ b/packaging/windows/openmm2.nsi @@ -4,17 +4,45 @@ ; which stages the install tree and runs makensis with: ; -DVERSION= product version ; -DSTAGE_DIR= staged "app" component (openmm2.exe, DLLs, licences) -; -DAPP_FILES_NSH= generated File/Delete lists for STAGE_DIR +; -DAPP_FILES_NSH= generated File/Delete/manifest lists for STAGE_DIR ; -DTOOLS_FILES_NSH= (optional) same for the "tools" component ; -DOUTFILE= installer to write ; -DSOURCE_DIR= repository root (licence, icons) ; ; Command line options, in addition to NSIS's /S (silent) and /D=: -; /SOURCE= game data for silent installs: a disc image, a drive -; root such as D:\ or a Midtown Madness 2 installation folder -; /COPYDATA copy the game archives to \gamedata -; Exit codes: 0 success, 1 cancelled or failed, 2 installed but the game data -; given with /SOURCE was rejected (OpenMM2 will ask for it on first start). +; /SOURCE= game data for silent installs: a disc image, a drive +; root such as D:\ or a Midtown Madness 2 installation folder +; /COPYDATA copy the game archives to \gamedata +; /ALLOWDOWNGRADE replace a newer installed version without asking +; Exit codes: +; 0 success +; 1 cancelled or failed (e.g. a file that another program holds open) +; 2 installed, but the game data given with /SOURCE was rejected (OpenMM2 +; will ask for it on first start) +; 3 a newer version is installed and a silent install was not given +; /ALLOWDOWNGRADE; nothing was changed +; 4 OpenMM2 is running and a silent install cannot ask to close it; +; nothing was changed. The uninstaller stops the same way (its exit +; code is only seen with "uninstall.exe /S _?=": otherwise it +; restarts from a copy in %TEMP% and returns at once). +; +; Updates. The registry (HKLM\Software\OpenMM2: InstallDir, Version) names +; the installed version; setup then updates it in its folder: the directory +; page is skipped, the welcome page says what will happen, and the game data +; page and the components are pre-filled from the last installation. The same +; version again repairs; an older one asks first (/ALLOWDOWNGRADE when +; silent). Before copying anything, setup removes the previous version's files +; (RemovePreviousVersion): those in its install-manifest.txt, or for 0.4.1 and +; earlier, which wrote none, the names those versions installed +; (LegacyFiles.nsh). It never removes gamedata\, openmm2-install.ini or the +; per-user settings. /D= naming another folder moves the installation: the old +; folder's program files, uninstaller and firewall rule are removed, its +; gamedata\ and openmm2-install.ini stay, and the folder goes only if nothing +; else is left in it. +; +; $INSTDIR\install-manifest.txt lists every file setup installed, one path +; per line relative to $INSTDIR (ASCII, CRLF, ";" comment lines). The next +; update and the uninstaller remove exactly those files. ; ; Contract with openmm2.exe: ; openmm2.exe --check-source exit 0 when usable; prints a @@ -57,10 +85,17 @@ RequestExecutionLevel admin !define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\OpenMM2" !define ICONS "${SOURCE_DIR}\packaging\icons" +!define EXIT_FAILED 1 +!define EXIT_SOURCE_REJECTED 2 +!define EXIT_NEWER_INSTALLED 3 +!define EXIT_RUNNING 4 + !include MUI2.nsh !include LogicLib.nsh +!include Sections.nsh !include FileFunc.nsh !include TextFunc.nsh +!include WordFunc.nsh !include x64.nsh !include WinVer.nsh !include "${APP_FILES_NSH}" @@ -68,6 +103,7 @@ RequestExecutionLevel admin !include "${TOOLS_FILES_NSH}" !endif !include "${__FILEDIR__}\Helpers.nsh" +!include "${__FILEDIR__}\LegacyFiles.nsh" Name "${PRODUCT}" OutFile "${OUTFILE}" @@ -75,6 +111,10 @@ InstallDir "$PROGRAMFILES64\${PRODUCT}" BrandingText "${PRODUCT} ${VERSION}" ShowInstDetails show ShowUninstDetails show +; A file that cannot be written (another program holds it) stops the +; installation (Retry/Cancel; a silent install fails with exit code 1) instead +; of being skipped, which would leave a file of the old version behind. +AllowSkipFiles off ; Version resource: VIProductVersion needs four numeric parts. !searchparse /noerrors "${VERSION}" "" _V_MAJOR "." _V_MINOR "." _V_PATCH @@ -89,9 +129,14 @@ VIAddVersionKey "FileDescription" "${PRODUCT} Setup" VIAddVersionKey "CompanyName" "${PUBLISHER}" VIAddVersionKey "LegalCopyright" "GNU General Public License v3 or later" -Var GameSource ; chosen game data location, "" to configure later -Var CopyData ; 1 to copy the archives into $INSTDIR\gamedata +Var GameSource ; chosen game data location, "" to configure later +Var CopyData ; 1 to copy the archives into $INSTDIR\gamedata +Var SourceGiven ; 1 when /SOURCE= named the game data Var DefaultInstDir +Var OldInstDir ; folder of the installed OpenMM2, "" when there is none +Var OldVersion ; its version, "" if unknown +Var InstallKind ; new | update | repair | downgrade +Var WelcomeText ; --- Modern UI ------------------------------------------------------------- !define MUI_ICON "${ICONS}\openmm2.ico" @@ -105,9 +150,13 @@ Var DefaultInstDir !define MUI_ABORTWARNING !define MUI_COMPONENTSPAGE_SMALLDESC -!define MUI_WELCOMEPAGE_TEXT "This will install ${PRODUCT} ${VERSION}, an open source reimplementation of Midtown Madness 2.$\r$\n$\r$\n${PRODUCT} does not include any of the original game's content. You need your own copy of Midtown Madness 2 (the CD, a disc image, or an existing installation); setup asks where it is.$\r$\n$\r$\n$_CLICK" +; $WelcomeText is set in .onInit: a new installation, an update, a repair or +; a downgrade. +!define MUI_WELCOMEPAGE_TEXT "$WelcomeText$\r$\n$\r$\n$_CLICK" !insertmacro MUI_PAGE_WELCOME +; An update shows only the pages with choices: welcome, components, game data. +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipPageOnUpdate !define MUI_LICENSEPAGE_TEXT_TOP "${PRODUCT} is free software, licensed under the GNU General Public License." !define MUI_LICENSEPAGE_TEXT_BOTTOM "You do not need to accept this licence to use ${PRODUCT}; it describes your rights to copy, modify and share it. Click Next to continue." !define MUI_LICENSEPAGE_BUTTON "$(^NextBtn)" @@ -115,6 +164,8 @@ Var DefaultInstDir !insertmacro MUI_PAGE_COMPONENTS Page custom GameDataPageCreate GameDataPageLeave +; An update installs into the existing folder (or the one /D= names). +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipPageOnUpdate !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES @@ -134,10 +185,15 @@ Page custom GameDataPageCreate GameDataPageLeave Section "${PRODUCT} (required)" SecGame SectionIn RO + Call CheckNotRunning + Call RemovePreviousVersion SetOutPath "$INSTDIR" + ; The uninstaller and the manifest first, so that an installation that + ; stops halfway can still be repaired or removed. + WriteUninstaller "$INSTDIR\uninstall.exe" + Call WriteManifest !insertmacro OPENMM2_APP_INSTALL SetOutPath "$INSTDIR" - WriteUninstaller "$INSTDIR\uninstall.exe" ; Let the game accept multiplayer connections and LAN discovery (UDP; see ; docs/multiplayer.md). Scoped to the executable, not to fixed ports, so a @@ -172,6 +228,28 @@ Section "-Register" WriteRegStr HKLM "${REG_KEY}" "InstallDir" "$INSTDIR" WriteRegStr HKLM "${REG_KEY}" "Version" "${VERSION}" + ; Remember the optional parts for the next update (see .onInit), and take + ; away a shortcut that was switched off. + ${If} ${SectionIsSelected} ${SecStartMenu} + WriteRegDWORD HKLM "${REG_KEY}" "StartMenuShortcut" 1 + ${Else} + WriteRegDWORD HKLM "${REG_KEY}" "StartMenuShortcut" 0 + Delete "$SMPROGRAMS\${PRODUCT}.lnk" + ${EndIf} + ${If} ${SectionIsSelected} ${SecDesktop} + WriteRegDWORD HKLM "${REG_KEY}" "DesktopShortcut" 1 + ${Else} + WriteRegDWORD HKLM "${REG_KEY}" "DesktopShortcut" 0 + Delete "$DESKTOP\${PRODUCT}.lnk" + ${EndIf} +!ifdef TOOLS_FILES_NSH + ${If} ${SectionIsSelected} ${SecTools} + WriteRegDWORD HKLM "${REG_KEY}" "Tools" 1 + ${Else} + WriteRegDWORD HKLM "${REG_KEY}" "Tools" 0 + ${EndIf} +!endif + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT}" WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${PUBLISHER}" @@ -201,42 +279,142 @@ SectionEnd ; --- Installer functions --------------------------------------------------- +; Selects an optional section as the last installation had it: the choice +; recorded in the registry, or for 0.4.1 and earlier, which recorded none, +; whether its file (a shortcut, mm2tool.exe) is there. +!macro OPENMM2_PRESELECT SECTION VALUE FILE + ClearErrors + ReadRegDWORD $R1 HKLM "${REG_KEY}" "${VALUE}" + ${If} ${Errors} + StrCpy $R1 0 + ${If} ${FileExists} "${FILE}" + StrCpy $R1 1 + ${EndIf} + ${EndIf} + ${If} $R1 == 1 + !insertmacro SelectSection ${SECTION} + ${Else} + !insertmacro UnselectSection ${SECTION} + ${EndIf} +!macroend + Function .onInit ${IfNot} ${RunningX64} MessageBox MB_ICONSTOP|MB_OK "${PRODUCT} requires a 64-bit version of Windows." /SD IDOK - SetErrorLevel 1 + SetErrorLevel ${EXIT_FAILED} Abort ${EndIf} ${IfNot} ${AtLeastWin7} MessageBox MB_ICONSTOP|MB_OK "${PRODUCT} requires Windows 7 or later." /SD IDOK - SetErrorLevel 1 + SetErrorLevel ${EXIT_FAILED} Abort ${EndIf} ; Never show "insert a disk" error boxes while probing optical drives. System::Call 'kernel32::SetErrorMode(i 1)' SetRegView 64 SetShellVarContext all + ${GetParameters} $R0 - ; Upgrade in place unless /D= chose another directory. + ; The installed version. Install into its folder unless /D= chose another + ; directory (NSIS has set $INSTDIR from /D= already). + ReadRegStr $OldInstDir HKLM "${REG_KEY}" "InstallDir" + ReadRegStr $OldVersion HKLM "${REG_KEY}" "Version" StrCpy $DefaultInstDir "$PROGRAMFILES64\${PRODUCT}" - ReadRegStr $0 HKLM "${REG_KEY}" "InstallDir" - ${If} $0 != "" + ${If} $OldInstDir != "" ${AndIf} $INSTDIR == $DefaultInstDir - StrCpy $INSTDIR $0 + StrCpy $INSTDIR $OldInstDir + ${EndIf} + ; A registration whose folder is gone is a new installation. + ${If} $OldInstDir == "" + ${OrIfNot} ${FileExists} "$OldInstDir\*.*" + StrCpy $OldInstDir "" + StrCpy $OldVersion "" + ${EndIf} + + StrCpy $InstallKind "new" + ${If} $OldInstDir != "" + StrCpy $InstallKind "update" + ${If} $OldVersion != "" + ${VersionCompare} "${VERSION}" "$OldVersion" $R1 + ${If} $R1 == 0 + StrCpy $InstallKind "repair" + ${ElseIf} $R1 == 2 + StrCpy $InstallKind "downgrade" + ${EndIf} + ${EndIf} ${EndIf} - ; Previous choice of game data. + ${If} $InstallKind == "downgrade" + ClearErrors + ${GetOptions} $R0 "/ALLOWDOWNGRADE" $R1 + ${If} ${Errors} + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "${PRODUCT} $OldVersion is installed, which is newer than this setup (${VERSION}).$\r$\n$\r$\nReplace it with the older version?" /SD IDNO IDYES downgrade_confirmed + ${If} ${Silent} + SetErrorLevel ${EXIT_NEWER_INSTALLED} + ${Else} + SetErrorLevel ${EXIT_FAILED} + ${EndIf} + Abort + downgrade_confirmed: + ${EndIf} + ${EndIf} + + ${Select} $InstallKind + ${Case} "update" + ${If} $OldVersion == "" + StrCpy $WelcomeText "This will update ${PRODUCT} to ${VERSION}." + ${Else} + StrCpy $WelcomeText "This will update ${PRODUCT} from $OldVersion to ${VERSION}." + ${EndIf} + ${Case} "repair" + StrCpy $WelcomeText "${PRODUCT} ${VERSION} is already installed. Setup will install it again, which repairs missing or damaged files." + ${Case} "downgrade" + StrCpy $WelcomeText "This will replace ${PRODUCT} $OldVersion with the older version ${VERSION}." + ${CaseElse} + StrCpy $WelcomeText "This will install ${PRODUCT} ${VERSION}, an open source reimplementation of Midtown Madness 2.$\r$\n$\r$\n${PRODUCT} does not include any of the original game's content. You need your own copy of Midtown Madness 2 (the CD, a disc image, or an existing installation); setup asks where it is." + ${EndSelect} + ${If} $OldInstDir != "" + Push $OldInstDir + Push $INSTDIR + Call SameDir + Pop $R1 + ${If} $R1 == 1 + StrCpy $WelcomeText "$WelcomeText$\r$\n$\r$\nIt stays in $INSTDIR. Your settings, driver profiles, replays and game data are kept." + ${Else} + StrCpy $WelcomeText "$WelcomeText$\r$\n$\r$\nIt moves from $OldInstDir to $INSTDIR. Your settings, driver profiles and replays are kept, and so is game data copied to the old folder." + ${EndIf} + StrCpy $WelcomeText "$WelcomeText Please close ${PRODUCT} before you continue." + StrCpy $GD_NextIsInstall 1 + + ; Shortcuts and tools as the last installation had them. + !insertmacro OPENMM2_PRESELECT ${SecStartMenu} "StartMenuShortcut" "$SMPROGRAMS\${PRODUCT}.lnk" + !insertmacro OPENMM2_PRESELECT ${SecDesktop} "DesktopShortcut" "$DESKTOP\${PRODUCT}.lnk" +!ifdef TOOLS_FILES_NSH + !insertmacro OPENMM2_PRESELECT ${SecTools} "Tools" "$OldInstDir\mm2tool.exe" +!endif + ${EndIf} + + ; Previous choice of game data (kept in the old folder when /D= moves + ; the installation). StrCpy $CopyData 0 + StrCpy $SourceGiven 0 Push "$INSTDIR\openmm2-install.ini" Push "Source" Call ReadUtf8IniValue Pop $GameSource + ${If} $GameSource == "" + ${AndIf} $OldInstDir != "" + Push "$OldInstDir\openmm2-install.ini" + Push "Source" + Call ReadUtf8IniValue + Pop $GameSource + ${EndIf} - ${GetParameters} $R0 ClearErrors ${GetOptions} $R0 "/SOURCE=" $R1 ${IfNot} ${Errors} StrCpy $GameSource $R1 + StrCpy $SourceGiven 1 ${EndIf} ClearErrors ${GetOptions} $R0 "/COPYDATA" $R1 @@ -246,6 +424,157 @@ Function .onInit ${EndIf} FunctionEnd +; A stopped installation that did not set its own exit code (a file that +; could not be written) ends with 1, not NSIS's 2, which means something else +; here. +Function .onInstFailed + GetErrorLevel $0 + ${If} $0 = -1 + SetErrorLevel ${EXIT_FAILED} + ${EndIf} +FunctionEnd + +Function SkipPageOnUpdate + ${If} $OldInstDir != "" + Abort + ${EndIf} +FunctionEnd + +; Sets $R0 to the first program in that is running (or otherwise held +; open), "" if none. +; Push +; Call FindProgramInUse +!macro OPENMM2_FINDPROGRAMINUSE un +Function ${un}FindProgramInUse + Exch $0 + Push $1 + StrCpy $R0 "" + ${If} $0 != "" + Push "$0\openmm2.exe" + Call ${un}IsFileInUse + Pop $1 + ${If} $1 == 1 + StrCpy $R0 "$0\openmm2.exe" + ${Else} + Push "$0\mm2tool.exe" + Call ${un}IsFileInUse + Pop $1 + ${If} $1 == 1 + StrCpy $R0 "$0\mm2tool.exe" + ${EndIf} + ${EndIf} + ${EndIf} + Pop $1 + Pop $0 +FunctionEnd + +; Waits until OpenMM2 is closed before anything is changed: its files cannot +; be replaced while it runs. Retry checks again; Cancel (and a silent run, +; which cannot ask) stops setup with nothing changed. +Function ${un}CheckNotRunning + Push $R0 +retry: + Push $INSTDIR + Call ${un}FindProgramInUse + ${If} $R0 == "" + ${AndIf} $OldInstDir != "" ; empty in the uninstaller + Push $OldInstDir + Call ${un}FindProgramInUse + ${EndIf} + ${If} $R0 != "" + DetailPrint "In use: $R0" + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT} is running. Close it, then click Retry.$\r$\n$\r$\n($R0 is in use.)" /SD IDCANCEL IDRETRY retry + ${If} ${Silent} + SetErrorLevel ${EXIT_RUNNING} + ${Else} + SetErrorLevel ${EXIT_FAILED} + ${EndIf} + Pop $R0 + Abort "${PRODUCT} is running; nothing was changed." + ${EndIf} + Pop $R0 +FunctionEnd +!macroend +!insertmacro OPENMM2_FINDPROGRAMINUSE "" +!insertmacro OPENMM2_FINDPROGRAMINUSE "un." + +; Removes the installed version's files before the new ones are copied, so +; nothing that this version no longer ships stays behind (and the uninstaller, +; which knows only this version's manifest, leaves nothing either): +; - files listed in $INSTDIR\install-manifest.txt; +; - for an installation of 0.4.1 or earlier (no manifest; the registry names +; this folder), every name those versions and this one install; +; - when /D= moved the installation, the same in the old folder, plus its +; firewall rule and the folder itself if nothing else is left. +; gamedata\ and openmm2-install.ini are never removed, and nothing outside +; these folders is touched. +Function RemovePreviousVersion + Push $0 + Push $R1 + StrCpy $R1 0 ; 1 when the installed version is in $INSTDIR + ${If} $OldInstDir != "" + Push $OldInstDir + Push $INSTDIR + Call SameDir + Pop $R1 + ${EndIf} + + ${If} ${FileExists} "$INSTDIR\install-manifest.txt" + DetailPrint "Removing the files of the installed version" + Push $INSTDIR + Call RemoveListedFiles + ${ElseIf} $R1 == 1 + DetailPrint "Removing the files of ${PRODUCT} $OldVersion" + !insertmacro OPENMM2_LEGACY_UNINSTALL "$INSTDIR" + !insertmacro OPENMM2_APP_UNINSTALL "$INSTDIR" +!ifdef TOOLS_FILES_NSH + !insertmacro OPENMM2_TOOLS_UNINSTALL "$INSTDIR" +!endif + ${EndIf} + + ${If} $OldInstDir != "" + ${AndIf} $R1 != 1 + DetailPrint "Removing ${PRODUCT} $OldVersion from $OldInstDir" + nsExec::ExecToLog 'netsh advfirewall firewall delete rule name="${PRODUCT}" program="$OldInstDir\openmm2.exe"' + Pop $0 + ${If} ${FileExists} "$OldInstDir\install-manifest.txt" + Push $OldInstDir + Call RemoveListedFiles + ${Else} + !insertmacro OPENMM2_LEGACY_UNINSTALL "$OldInstDir" + !insertmacro OPENMM2_APP_UNINSTALL "$OldInstDir" +!ifdef TOOLS_FILES_NSH + !insertmacro OPENMM2_TOOLS_UNINSTALL "$OldInstDir" +!endif + ${EndIf} + RMDir "$OldInstDir" + ${EndIf} + Pop $R1 + Pop $0 +FunctionEnd + +; Writes $INSTDIR\install-manifest.txt for the files this installation copies. +Function WriteManifest + Push $0 + ClearErrors + FileOpen $0 "$INSTDIR\install-manifest.txt" w + ${If} ${Errors} + DetailPrint "Could not write $INSTDIR\install-manifest.txt" + ${Else} + FileWrite $0 "; Files installed by ${PRODUCT} ${VERSION} setup, relative to this folder.$\r$\n" + FileWrite $0 "; The next update and the uninstaller remove them. Do not edit.$\r$\n" + !insertmacro OPENMM2_APP_MANIFEST $0 +!ifdef TOOLS_FILES_NSH + ${If} ${SectionIsSelected} ${SecTools} + !insertmacro OPENMM2_TOOLS_MANIFEST $0 + ${EndIf} +!endif + FileWrite $0 "uninstall.exe$\r$\n" + FileClose $0 + ${EndIf} + Pop $0 +FunctionEnd + ; Validates $GameSource (again: silent installs never saw the page), copies ; the archives when requested and writes openmm2-install.ini. Function InstallGameData @@ -264,8 +593,16 @@ Function InstallGameData ${TrimNewLines} "$1" $1 ${If} $0 != 0 DetailPrint "Game data not usable: $1" + ${If} ${Silent} + ${AndIf} $SourceGiven != 1 + ; A silent update re-checks the last installation's choice, e.g. + ; a disc that is not in the drive now. That is no reason to fail: + ; openmm2-install.ini keeps the choice. + DetailPrint "Keeping the game data setting of the last installation." + Return + ${EndIf} MessageBox MB_ICONEXCLAMATION|MB_OK "The game data at $GameSource cannot be used:$\n$1$\n$\n${PRODUCT} will ask for the game files when it starts." /SD IDOK - SetErrorLevel 2 + SetErrorLevel ${EXIT_SOURCE_REJECTED} Return ${EndIf} @@ -337,16 +674,37 @@ Function un.onInit FunctionEnd Section "Uninstall" + Call un.CheckNotRunning nsExec::ExecToLog 'netsh advfirewall firewall delete rule name="${PRODUCT}" program="$INSTDIR\openmm2.exe"' Pop $0 - !insertmacro OPENMM2_APP_UNINSTALL + ${If} ${FileExists} "$INSTDIR\install-manifest.txt" + Push $INSTDIR + Call un.RemoveListedFiles + ${Else} + ; Installed by 0.4.1 or earlier, or the manifest is gone. + !insertmacro OPENMM2_APP_UNINSTALL "$INSTDIR" !ifdef TOOLS_FILES_NSH - !insertmacro OPENMM2_TOOLS_UNINSTALL + !insertmacro OPENMM2_TOOLS_UNINSTALL "$INSTDIR" !endif + !insertmacro OPENMM2_LEGACY_UNINSTALL "$INSTDIR" + ${EndIf} Delete "$INSTDIR\openmm2-install.ini" Delete "$INSTDIR\uninstall.exe" - Delete "$SMPROGRAMS\${PRODUCT}.lnk" - Delete "$DESKTOP\${PRODUCT}.lnk" + + ; The shortcuts and the registration belong to the registered + ; installation; leave them alone if that is in another folder. + ReadRegStr $1 HKLM "${REG_KEY}" "InstallDir" + StrCpy $2 1 + ${If} $1 != "" + Push $1 + Push $INSTDIR + Call un.SameDir + Pop $2 + ${EndIf} + ${If} $2 == 1 + Delete "$SMPROGRAMS\${PRODUCT}.lnk" + Delete "$DESKTOP\${PRODUCT}.lnk" + ${EndIf} ${If} ${FileExists} "$INSTDIR\gamedata\*.*" MessageBox MB_YESNO|MB_ICONQUESTION "Also delete the copy of the Midtown Madness 2 game data in $INSTDIR\gamedata?$\n$\nKeep it if you plan to reinstall ${PRODUCT}." /SD IDNO IDNO keep_gamedata @@ -366,6 +724,8 @@ Section "Uninstall" SetShellVarContext all RMDir "$INSTDIR" - DeleteRegKey HKLM "${UNINST_KEY}" - DeleteRegKey HKLM "${REG_KEY}" + ${If} $2 == 1 + DeleteRegKey HKLM "${UNINST_KEY}" + DeleteRegKey HKLM "${REG_KEY}" + ${EndIf} SectionEnd From c63ee13e8a0db88ce0ef518529c1583b6ce2930c Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 17:53:23 -0700 Subject: [PATCH 8/9] Fix a heap overrun in setup while it copies the game data RunWithProgress read the copy's output with System::Call '*$7(&m4096 .r3)'. For such an output the System plug-in allocates NSIS_MAX_STRLEN (1024) bytes and then copies all 4096 into them, so each chunk of output overran the heap by 3 KB. Under Wine this made setup crash on exit (access violation, exit code 0xC0000005) depending only on where things landed in memory: the same installer crashed every time from one path and never from another, and the 0.4.0 scripts do the same. Windows' heap is as likely to be hit. Read at most NSIS_MAX_STRLEN - 1 bytes per chunk and convert them with MultiByteToWideChar into a buffer of the right size, as UTF-8, which is what OpenMM2 prints. Co-Authored-By: Claude Opus 5.5 (1M context) --- packaging/windows/Helpers.nsh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packaging/windows/Helpers.nsh b/packaging/windows/Helpers.nsh index b3a80a6..2519f5c 100644 --- a/packaging/windows/Helpers.nsh +++ b/packaging/windows/Helpers.nsh @@ -471,7 +471,9 @@ Function RunWithProgress System::Free $4 System::Call 'kernel32::CloseHandle(p r6)' - System::Alloc 4096 + ; Read at most NSIS_MAX_STRLEN - 1 bytes at a time, so that a chunk always + ; fits an NSIS string. + System::Alloc ${NSIS_MAX_STRLEN} Pop $7 ${Do} System::Call 'kernel32::WaitForSingleObject(p r5, i 100) i .r0' @@ -483,14 +485,19 @@ Function RunWithProgress ${OrIf} $2 == 0 ${ExitDo} ${EndIf} - System::Call 'kernel32::ReadFile(p r1, p r7, i 4095, *i .r2, p 0) i .r3' + IntOp $4 ${NSIS_MAX_STRLEN} - 1 + System::Call 'kernel32::ReadFile(p r1, p r7, i r4, *i .r2, p 0) i .r3' ${If} $3 == 0 ${OrIf} $2 == 0 ${ExitDo} ${EndIf} IntOp $3 $7 + $2 System::Call '*$3(&i1 0)' - System::Call '*$7(&m4096 .r3)' + ; UTF-8 (what OpenMM2 prints) to an NSIS string. Not with + ; '*$7(&m .r3)': System gives that output a buffer of + ; NSIS_MAX_STRLEN bytes whatever the size, and copies bytes + ; into it (with 4096 it overran the heap and setup could crash). + System::Call 'kernel32::MultiByteToWideChar(i ${CP_UTF8}, i 0, p r7, i -1, w .r3, i ${NSIS_MAX_STRLEN})' StrCpy $RWP_Pending "$RWP_Pending$3" Call RWP_ProcessLines ${Loop} From c23a2c6d3a7513dd1cdd3cbbc9fb64442f3e5b78 Mon Sep 17 00:00:00 2001 From: lowlevelmetal Date: Sat, 10 Oct 2026 17:55:44 -0700 Subject: [PATCH 9/9] Test the Windows installer under Wine, in CI too packaging/windows/test-installer.sh stages a Windows build's app and tools components, packs them into installers of made-up versions (one with extra files the next one drops) and builds the v0.4.0 installer scripts as the last release without update support. With synthetic game data (three small PKZIP .AR archives that pass --check-source) it checks, in fresh Wine prefixes, the installed files, install-manifest.txt, openmm2-install.ini, the registry and the shortcuts for: - a new installation, and uninstalling it; - an update from 0.4.0 (MSVC runtime DLLs removed, game data and settings kept, a deleted desktop shortcut not re-created, the tools kept), then through a version that drops files, a repair, a silent downgrade with and without /ALLOWDOWNGRADE, openmm2.exe held open and running (setup and uninstaller), another file held open, a copy of the game data that fails over an earlier copy, and a manifest with entries that point outside the folder or into the game data; - updates without game data, then uninstalling: nothing is left; - /D= moving the installation to another folder; - the wizard, driven by a small helper that reads and clicks it: the pages of a new installation and of an update (Next, Next, Install), the repair and downgrade texts, the downgrade question and the Retry/Cancel question while OpenMM2 runs. Wine uses its null graphics driver, so nothing appears on a desktop. The MinGW CI job runs it with the Windows makensis.exe 3.13 through Wine (NSIS is not packaged for Arch Linux), pinned by hash and cached. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 32 +- README.md | 12 + packaging/windows/openmm2.nsi | 2 + packaging/windows/test-installer.sh | 910 ++++++++++++++++++++++++++++ 4 files changed, 954 insertions(+), 2 deletions(-) create mode 100755 packaging/windows/test-installer.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cbb3d4..e5a0821 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,10 +159,11 @@ jobs: steps: - name: Install build dependencies # Wine runs the determinism tests (the toolchain file makes it the - # tests' emulator when it is installed at configure time). + # tests' emulator when it is installed at configure time) and the + # installer tests (which also need diffutils). run: | pacman -Syu --noconfirm --needed \ - base-devel git cmake ninja mingw-w64-gcc shaderc glslang wine + base-devel git cmake ninja mingw-w64-gcc shaderc glslang wine diffutils - uses: actions/checkout@v7 @@ -194,6 +195,33 @@ jobs: wineboot --init ctest --preset mingw-cross-release -R '^Determinism[.]' + # NSIS is not packaged for Arch Linux: use the Windows makensis.exe + # through Wine (pinned by hash). + - name: Cache NSIS + uses: actions/cache@v6 + with: + path: nsis-3.13 + key: nsis-3.13-windows + + # Installs, updates (also from the 0.4.0 installer), repair, + # downgrades, a running game, a failed game data copy, moving and + # uninstalling, and the wizard's pages, all under Wine with synthetic + # game data (packaging/windows/test-installer.sh). + - name: Test the installer under Wine + run: | + if [ ! -f nsis-3.13/makensis.exe ]; then + curl -fsSL --retry 3 -o nsis.zip \ + https://downloads.sourceforge.net/project/nsis/NSIS%203/3.13/nsis-3.13.zip + echo "ba63dffc4410ee89193e1cb5a41989991bd77c61068da17e3156d136b7b0b3d8 nsis.zip" | sha256sum -c - + cmake -E tar xf nsis.zip + rm nsis.zip + fi + # The update tests start from the last release's installer scripts. + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git fetch --no-tags --depth=1 origin tag v0.4.0 + MAKENSIS="$PWD/nsis-3.13/makensis.exe" MAKENSIS_WINE=1 \ + packaging/windows/test-installer.sh out/build/mingw-cross-release + - name: Package run: cpack --preset mingw-portable diff --git a/README.md b/README.md index f314b79..4bdbfbe 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,18 @@ The installer is built from `packaging/windows/openmm2.nsi` and needs NSIS 3 `makensis` also works when cross-compiling. The icon artwork is in `packaging/icons` (`render-icons.sh` regenerates the bitmaps from the SVG). +`packaging/windows/test-installer.sh ` tests the installer under +Wine: new installations, updates (also from the last release's installer), +repair, downgrades, a running game, a failed game data copy, moving with +`/D=`, uninstalling, and the wizard's pages. It uses synthetic game data, so +it needs no game files. It needs Wine, a MinGW-w64 C compiler and makensis +(`MAKENSIS=`, or `MAKENSIS_WINE=1` with the Windows `makensis.exe`); +the script's header lists its options. CI runs it on the MinGW build. + +```sh +packaging/windows/test-installer.sh out/build/mingw-cross-release +``` + ## mm2tool `mm2tool` is a developer tool for looking inside the game data: diff --git a/packaging/windows/openmm2.nsi b/packaging/windows/openmm2.nsi index c2664e3..a80bdb7 100644 --- a/packaging/windows/openmm2.nsi +++ b/packaging/windows/openmm2.nsi @@ -8,6 +8,8 @@ ; -DTOOLS_FILES_NSH= (optional) same for the "tools" component ; -DOUTFILE= installer to write ; -DSOURCE_DIR= repository root (licence, icons) +; packaging/windows/test-installer.sh builds it the same way and tests +; installs, updates and removal under Wine. ; ; Command line options, in addition to NSIS's /S (silent) and /D=: ; /SOURCE= game data for silent installs: a disc image, a drive diff --git a/packaging/windows/test-installer.sh b/packaging/windows/test-installer.sh new file mode 100755 index 0000000..936882b --- /dev/null +++ b/packaging/windows/test-installer.sh @@ -0,0 +1,910 @@ +#!/usr/bin/env bash +# Tests the Windows installer (openmm2.nsi) under Wine: a new installation, +# updates (also from the previous release's installer script), a file removed +# between versions, repair, downgrades, a running game, a failed game data +# copy, a hand-edited manifest, moving with /D= and uninstalling. Each check +# looks at the installed files, install-manifest.txt, openmm2-install.ini, +# the registry and the shortcuts. The wizard itself is driven too: which +# pages a new installation, an update, a repair and a downgrade show, what +# they say, and the questions about a newer version and a running game. +# Wine uses its null graphics driver, so no window ever appears. +# +# packaging/windows/test-installer.sh +# +# is a configured and built Windows build (the MinGW cross build, +# or MSVC with INSTALL_CONFIG set); its "app" and "tools" install components +# are staged with cmake --install and packed into installers of made-up +# versions. Game data is synthetic (three small PKZIP archives that pass +# --check-source); no original game files are used. +# +# Needs bash, cmake, git, wine (wineboot, wineserver, winepath), a MinGW-w64 +# C compiler for two small helper programs, and makensis. Environment: +# MAKENSIS= makensis to run (default: makensis) +# MAKENSIS_WINE=1 MAKENSIS is a Windows makensis.exe to run through Wine +# MINGW_CC= default: x86_64-w64-mingw32-gcc +# LEGACY_REV= git revision whose installer scripts stand for the last +# release without update support (default: v0.4.0; +# "none" skips those checks) +# INSTALL_CONFIG= --config for cmake --install (multi-config builds) +# WORK_DIR= scratch directory without spaces (default: mktemp -d) +# WINE= default: wine +# SCENARIOS="" some of: new updates clean move wizard (default: all) +# Exit status 0 when every check passes. The installers, a log +# (test-installer.log) and the Wine prefixes of failed scenarios stay in the +# work directory. + +set -uo pipefail + +if [[ $# -ne 1 || ! -f "$1/CMakeCache.txt" ]]; then + sed -n '2,/^$/s/^# \{0,1\}//p' "$0" + exit 2 +fi + +BUILD_DIR=$(cd "$1" && pwd) +SOURCE_DIR=$(cd "$(dirname "$0")/../.." && pwd) +MAKENSIS=${MAKENSIS:-makensis} +MAKENSIS_WINE=${MAKENSIS_WINE:-0} +MINGW_CC=${MINGW_CC:-x86_64-w64-mingw32-gcc} +LEGACY_REV=${LEGACY_REV:-v0.4.0} +LEGACY_VERSION=0.4.0 +WINE=${WINE:-wine} +WORK=${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/openmm2-installer-test.XXXXXX")} +mkdir -p "$WORK" +WORK=$(cd "$WORK" && pwd) +LOG=$WORK/test-installer.log +TIMEOUT=300 +: >"$LOG" + +if [[ "$WORK" == *" "* ]]; then + echo "WORK_DIR must not contain spaces: $WORK" >&2 + exit 2 +fi + +# Never show windows (see init_prefix); a dialog that a silent run did not +# expect then fails the check by timing out instead of waiting for someone. +unset DISPLAY WAYLAND_DISPLAY +export WINEDEBUG=-all +# No Mono/Gecko installers, and no menu entries on the host for the shortcuts. +export WINEDLLOVERRIDES="mscoree,mshtml=;winemenubuilder.exe=d" + +CURRENT_VERSION=$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt") +DUMMY_VERSION=99.0.0 # ships extra files +NEXT_VERSION=99.1.0 # no longer ships them + +FAILURES=0 +CHECKS=0 +pass() { + CHECKS=$((CHECKS + 1)) + echo " ok $*" +} +fail() { + CHECKS=$((CHECKS + 1)) + FAILURES=$((FAILURES + 1)) + echo " FAIL $*" +} +section() { + echo + echo "== $*" + echo "== $*" >>"$LOG" +} +die() { + echo "error: $*" >&2 + exit 1 +} +# check +check() { + local what=$1 + shift + if "$@" >>"$LOG" 2>&1; then pass "$what"; else fail "$what"; fi +} +# check_eq +check_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1: got '$2', expected '$3'"; fi +} +# check_same_lines +check_same_lines() { + if diff -u "$3" "$2" >"$WORK/diff.txt"; then + pass "$1" + else + fail "$1" + sed 's/^/ /' "$WORK/diff.txt" + fi +} + +# --- Staging and building installers -------------------------------------- + +section "Building installers (OpenMM2 $CURRENT_VERSION)" +STAGE=$WORK/stage +rm -rf "$STAGE" +install_args=() +[[ -n "${INSTALL_CONFIG:-}" ]] && install_args=(--config "$INSTALL_CONFIG") +cmake --install "$BUILD_DIR" "${install_args[@]}" --component app --prefix "$STAGE/app" --strip >>"$LOG" || + die "cmake --install (app) failed" +cmake --install "$BUILD_DIR" "${install_args[@]}" --component tools --prefix "$STAGE/tools" --strip >>"$LOG" || + die "cmake --install (tools) failed" +[[ -f "$STAGE/app/openmm2.exe" ]] || die "no openmm2.exe in the app component" +HAVE_TOOLS=0 +[[ -f "$STAGE/tools/mm2tool.exe" ]] && HAVE_TOOLS=1 + +# init_prefix : a fresh Wine prefix whose windows go nowhere: the null +# graphics driver, not the desktop of whoever runs the tests (Wine would +# otherwise find a Wayland session even without WAYLAND_DISPLAY). +init_prefix() { + export WINEPREFIX=$1 + rm -rf "$WINEPREFIX" + "$WINE" wineboot --init >>"$LOG" 2>&1 || die "wineboot failed" + "$WINE" reg add 'HKCU\Software\Wine\Drivers' /v Graphics /d null /f >>"$LOG" 2>&1 || + die "cannot select Wine's null graphics driver" + # A crash writes its backtrace to the log instead of waiting at a dialog. + "$WINE" reg add 'HKCU\Software\Wine\WineDbg' /v ShowCrashDialog /t REG_DWORD /d 0 /f >>"$LOG" 2>&1 + wineserver -w +} + +# A prefix only for winepath and makensis.exe. +init_prefix "$WORK/wine-tools" + +# Path for makensis. +nsis_path() { + if [[ "$MAKENSIS_WINE" == 1 ]]; then winepath -w "$1"; else echo "$1"; fi +} + +# build_installer +build_installer() { + local scripts=$1 version=$2 app=$3 tools=$4 out=$5 + local name + name=$(basename "$out" .exe) + local wine_paths=OFF + [[ "$MAKENSIS_WINE" == 1 ]] && wine_paths=ON + cmake "-DSTAGE=$app" "-DOUTPUT=$WORK/$name-app.nsh" -DMACRO=OPENMM2_APP "-DWINE_PATHS=$wine_paths" \ + -P "$scripts/GenerateFileList.cmake" >>"$LOG" 2>&1 || return 1 + local defines=("-DVERSION=$version" "-DSTAGE_DIR=$(nsis_path "$app")" + "-DAPP_FILES_NSH=$(nsis_path "$WORK/$name-app.nsh")" + "-DSOURCE_DIR=$(nsis_path "$SOURCE_DIR")" "-DOUTFILE=$(nsis_path "$out")") + if [[ -n "$tools" ]]; then + cmake "-DSTAGE=$tools" "-DOUTPUT=$WORK/$name-tools.nsh" -DMACRO=OPENMM2_TOOLS "-DWINE_PATHS=$wine_paths" \ + -P "$scripts/GenerateFileList.cmake" >>"$LOG" 2>&1 || return 1 + defines+=("-DTOOLS_FILES_NSH=$(nsis_path "$WORK/$name-tools.nsh")") + fi + local cmd=($MAKENSIS) + [[ "$MAKENSIS_WINE" == 1 ]] && cmd=("$WINE" $MAKENSIS) + # zlib instead of the script's solid LZMA: the same installer, built in a + # fraction of the time. + (cd "$WORK" && "${cmd[@]}" -V2 -INPUTCHARSET UTF8 "-XSetCompressor /FINAL zlib" "${defines[@]}" \ + "$(nsis_path "$scripts/openmm2.nsi")") >>"$LOG" 2>&1 || return 1 + [[ -f "$out" ]] +} + +TOOLS_STAGE="" +[[ $HAVE_TOOLS == 1 ]] && TOOLS_STAGE=$STAGE/tools +SCRIPTS=$SOURCE_DIR/packaging/windows +SETUP_CURRENT=$WORK/setup-$CURRENT_VERSION.exe +SETUP_DUMMY=$WORK/setup-$DUMMY_VERSION.exe +SETUP_NEXT=$WORK/setup-$NEXT_VERSION.exe +SETUP_LEGACY=$WORK/setup-legacy-$LEGACY_VERSION.exe + +check "build $CURRENT_VERSION" build_installer "$SCRIPTS" "$CURRENT_VERSION" "$STAGE/app" "$TOOLS_STAGE" "$SETUP_CURRENT" +# The same files plus a few that the next version drops. +rm -rf "$STAGE/app-dummy" +cp -r "$STAGE/app" "$STAGE/app-dummy" +echo "dropped in $NEXT_VERSION" >"$STAGE/app-dummy/obsolete.txt" +mkdir -p "$STAGE/app-dummy/obsolete-dir/nested" +echo "dropped in $NEXT_VERSION" >"$STAGE/app-dummy/obsolete-dir/nested/old.dat" +check "build $DUMMY_VERSION (with extra files)" \ + build_installer "$SCRIPTS" "$DUMMY_VERSION" "$STAGE/app-dummy" "$TOOLS_STAGE" "$SETUP_DUMMY" +check "build $NEXT_VERSION" build_installer "$SCRIPTS" "$NEXT_VERSION" "$STAGE/app" "$TOOLS_STAGE" "$SETUP_NEXT" + +HAVE_LEGACY=0 +if [[ "$LEGACY_REV" != none ]]; then + # The release installers up to 0.4.1 used the same scripts; build that + # one with today's program files plus the MSVC runtime DLLs the releases + # shipped (stand-ins), which the MinGW build does not have. + rm -rf "$WORK/legacy" "$STAGE/app-legacy" + mkdir -p "$WORK/legacy" + if git -C "$SOURCE_DIR" archive "$LEGACY_REV" packaging/windows 2>>"$LOG" | tar -x -C "$WORK/legacy"; then + cp -r "$STAGE/app" "$STAGE/app-legacy" + echo "stand-in" >"$STAGE/app-legacy/vcruntime140.dll" + echo "stand-in" >"$STAGE/app-legacy/msvcp140.dll" + check "build the $LEGACY_REV installer as $LEGACY_VERSION" build_installer \ + "$WORK/legacy/packaging/windows" "$LEGACY_VERSION" "$STAGE/app-legacy" "$TOOLS_STAGE" "$SETUP_LEGACY" && + HAVE_LEGACY=1 + else + fail "git archive $LEGACY_REV (fetch the tag, or set LEGACY_REV=none)" + fi +fi +if [[ $FAILURES -ne 0 ]]; then + echo "Building failed; see $LOG" >&2 + exit 1 +fi + +# --- Helpers: test data, Wine ---------------------------------------------- + +# Synthetic game data: PKZIP archives (which OpenMM2 reads like the DAVE +# ones) with the two files --check-source looks for. +make_gamedata() { # + local out=$1 extra=$2 tmp=$1.src + rm -rf "$out" "$tmp" + mkdir -p "$tmp/core/tune" "$tmp/core/city" "$tmp/tex/texture" "$tmp/aud/aud" "$out" + echo "synthetic test data" >"$tmp/core/tune/vpbug.info" + echo "synthetic test data" >"$tmp/core/city/london.psdl" + [[ -n "$extra" ]] && echo "$extra" >"$tmp/core/tune/extra.txt" + echo "synthetic test data" >"$tmp/tex/texture/dummy.tex" + echo "synthetic test data" >"$tmp/aud/aud/dummy.wav" + (cd "$tmp/core" && cmake -E tar cf "$out/MM2CORE.AR" --format=zip tune city) && + (cd "$tmp/tex" && cmake -E tar cf "$out/MM2TEX.AR" --format=zip texture) && + (cd "$tmp/aud" && cmake -E tar cf "$out/MM2AUD.AR" --format=zip aud) || die "making test data failed" + rm -rf "$tmp" +} +make_gamedata "$WORK/data-v1" "" +make_gamedata "$WORK/data-v2" "this MM2CORE.AR differs in size from data-v1's" + +# holder.exe: holds a file open the way Windows holds a running program's +# executable (others may read it, not write it), or, copied over +# openmm2.exe and started, is a running openmm2.exe itself. +cat >"$WORK/holder.c" <<'EOF' +// holder open sharing only reads, wait while exists +// holder - just wait while exists +// Creates .ready once it holds the file. +#include +#include +#include +int wmain(int argc, wchar_t** argv) { + if (argc != 3) + return 2; + HANDLE h = INVALID_HANDLE_VALUE; + if (wcscmp(argv[1], L"-") != 0) { + h = CreateFileW(argv[1], GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) + return 3; + } + wchar_t ready[MAX_PATH + 8]; + swprintf(ready, MAX_PATH + 8, L"%ls.ready", argv[2]); + HANDLE r = CreateFileW(ready, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, 0, NULL); + if (r != INVALID_HANDLE_VALUE) + CloseHandle(r); + for (int i = 0; i < 3000 && GetFileAttributesW(argv[2]) != INVALID_FILE_ATTRIBUTES; ++i) + Sleep(100); + if (h != INVALID_HANDLE_VALUE) + CloseHandle(h); + return 0; +} +EOF +"$MINGW_CC" -municode -static -O2 -o "$WORK/holder.exe" "$WORK/holder.c" >>"$LOG" 2>&1 || die "compiling holder.c failed" + +# uidriver.exe: reads and clicks the wizard. +cat >"$WORK/uidriver.c" <<'EOF' +// uidriver: reads and clicks the OpenMM2 setup wizard (test-installer.sh). +// uidriver dump [title] the window title, then the visible +// controls of the setup window (or of a +// message box in front of it), one +// "||" line each; +// line breaks in texts become spaces +// uidriver click [title] clicks a button (if it is enabled) +// uidriver uncheck [title] unchecks the check box with this text +// title is a prefix and defaults to "OpenMM2 Setup". Exit status 1 when there +// is no such window or button. +#include +#include +#include +#include + +static void put(const wchar_t* s) { + char buf[16384]; + int n = WideCharToMultiByte(CP_UTF8, 0, s, -1, buf, sizeof buf, NULL, NULL); + if (n > 0) + fwrite(buf, 1, (size_t)n - 1, stdout); +} + +static BOOL CALLBACK dumpChild(HWND w, LPARAM unused) { + (void)unused; + if (!IsWindowVisible(w)) + return TRUE; + wchar_t cls[64], text[4096], line[4200]; + GetClassNameW(w, cls, 64); + DWORD_PTR len = 0; + text[0] = 0; + if (!SendMessageTimeoutW(w, WM_GETTEXT, 4096, (LPARAM)text, SMTO_ABORTIFHUNG, 2000, &len)) + text[0] = 0; + for (wchar_t* p = text; *p; ++p) + if (*p == L'\r' || *p == L'\n') + *p = L' '; + swprintf(line, 4200, L"%d|%ls|%ls\n", GetDlgCtrlID(w), cls, text); + put(line); + return TRUE; +} + +// The frontmost visible dialog whose title starts with g_prefix: setup's +// window ("OpenMM2 Setup", "OpenMM2 Setup: License Agreement", ...) or a +// message box in front of it. +static const wchar_t* g_prefix; +static HWND g_top; +static BOOL CALLBACK findTop(HWND w, LPARAM unused) { + (void)unused; + wchar_t cls[64], text[256]; + GetClassNameW(w, cls, 64); + GetWindowTextW(w, text, 256); + if (IsWindowVisible(w) && wcscmp(cls, L"#32770") == 0 && wcsncmp(text, g_prefix, wcslen(g_prefix)) == 0) { + g_top = w; + return FALSE; + } + return TRUE; +} + +static const wchar_t* g_match; +static HWND g_found; +static BOOL CALLBACK findText(HWND w, LPARAM unused) { + (void)unused; + wchar_t text[512]; + GetWindowTextW(w, text, 512); + if (IsWindowVisible(w) && wcscmp(text, g_match) == 0) { + g_found = w; + return FALSE; + } + return TRUE; +} + +int wmain(int argc, wchar_t** argv) { + if (argc < 2) + return 2; + const int dump = wcscmp(argv[1], L"dump") == 0; + g_prefix = L"OpenMM2 Setup"; + if (dump && argc > 2) + g_prefix = argv[2]; + else if (!dump && argc > 3) + g_prefix = argv[3]; + g_top = NULL; + EnumWindows(findTop, 0); + HWND w = g_top; + if (!w) + return 1; + if (dump) { + wchar_t title[256], line[300]; + GetWindowTextW(w, title, 256); + swprintf(line, 300, L"title|%ls\n", title); + put(line); + EnumChildWindows(w, dumpChild, 0); + return 0; + } + if (wcscmp(argv[1], L"click") == 0 && argc > 2) { + // Posted, not sent: a page's code (nsDialogs::Show) runs inside the + // handler of the click that showed it. + const int id = _wtoi(argv[2]); + HWND b = GetDlgItem(w, id); + if (!b || !IsWindowEnabled(b)) + return 1; + PostMessageW(w, WM_COMMAND, MAKEWPARAM(id, BN_CLICKED), (LPARAM)b); + return 0; + } + if (wcscmp(argv[1], L"uncheck") == 0 && argc > 2) { + g_match = argv[2]; + g_found = NULL; + EnumChildWindows(w, findText, 0); + if (!g_found) + return 1; + DWORD_PTR r; + SendMessageTimeoutW(g_found, BM_SETCHECK, BST_UNCHECKED, 0, SMTO_ABORTIFHUNG, 5000, &r); + return 0; + } + return 2; +} +EOF +"$MINGW_CC" -municode -static -O2 -o "$WORK/uidriver.exe" "$WORK/uidriver.c" >>"$LOG" 2>&1 || + die "compiling uidriver.c failed" + +# new_prefix : a fresh Wine prefix for one scenario. +new_prefix() { + init_prefix "$WORK/wine-$1" + C=$WINEPREFIX/drive_c + INSTDIR=$C/Program\ Files/OpenMM2 + INSTDIR_WIN='C:\Program Files\OpenMM2' + START_LNK=$C/ProgramData/Microsoft/Windows/Start\ Menu/Programs/OpenMM2.lnk + DESKTOP_LNK=$C/users/Public/Desktop/OpenMM2.lnk +} + +# setup : runs it and prints its exit code. (Setup +# waits for everything it starts; wineserver -w would also wait for holder.exe.) +setup() { + local exe=$1 code=0 + shift + echo "--- $(basename "$exe") $*" >>"$LOG" + timeout "$TIMEOUT" "$WINE" "$exe" "$@" >>"$LOG" 2>&1 || code=$? + echo "$code" +} + +# uninstall : the uninstaller restarts itself from %TEMP%, +# so wait for Wine rather than for the process. +uninstall() { + echo "--- uninstall $1" >>"$LOG" + timeout "$TIMEOUT" "$WINE" "$(winepath -w "$1/uninstall.exe")" /S >>"$LOG" 2>&1 + timeout "$TIMEOUT" wineserver -w +} + +# reg_value : the data of a registry value, "" if missing. +reg_value() { + "$WINE" reg query "$1" /v "$2" /reg:64 2>/dev/null | tr -d '\r' | + sed -n "s/^ *$2 *REG_[A-Z_]* *//p" +} +reg_key_exists() { + "$WINE" reg query "$1" /reg:64 >/dev/null 2>&1 +} +check_no_key() { # + if reg_key_exists "$2"; then fail "$1"; else pass "$1"; fi +} +REG_KEY='HKLM\Software\OpenMM2' +UNINST_KEY='HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\OpenMM2' + +# Files under (relative, sorted), without gamedata\. +installed_files() { + (cd "$1" && find . -type f ! -path './gamedata/*' | sed 's|^\./||' | LC_ALL=C sort) +} +# The files a component stage holds. +stage_files() { + (cd "$1" && find . -type f | sed 's|^\./||') +} +# expected_files +expected_files() { + local app=$1 tools=$2 + shift 2 + { + stage_files "$app" + [[ $tools == 1 && $HAVE_TOOLS == 1 ]] && stage_files "$STAGE/tools" + printf '%s\n' "$@" + } | LC_ALL=C sort +} +# The manifest's entries as Unix paths (sorted). +manifest_entries() { + grep -v '^;' "$1/install-manifest.txt" | tr -d '\r' | tr '\\' '/' | grep -v '^$' | LC_ALL=C sort +} +# check_installed [ini] +# The files are exactly the program, the uninstaller, the manifest (and the +# game data setting), and the manifest lists exactly what setup copied. +check_installed() { + local what=$1 dir=$2 app=$3 tools=$4 ini=${5:-} + expected_files "$app" "$tools" uninstall.exe install-manifest.txt $ini >"$WORK/expected.txt" + installed_files "$dir" >"$WORK/actual.txt" + check_same_lines "$what: installed files" "$WORK/actual.txt" "$WORK/expected.txt" + expected_files "$app" "$tools" uninstall.exe >"$WORK/expected.txt" + if [[ -f "$dir/install-manifest.txt" ]]; then + manifest_entries "$dir" >"$WORK/actual.txt" + check_same_lines "$what: install-manifest.txt" "$WORK/actual.txt" "$WORK/expected.txt" + else + fail "$what: install-manifest.txt is missing" + fi +} +# The names directly in a folder, sorted, on one line. +top_level() { + (cd "$1" && find . -mindepth 1 -maxdepth 1 | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ' | sed 's/ $//') +} +ini_source() { + tr -d '\r' <"$1/openmm2-install.ini" 2>/dev/null | sed -n 's/^Source=//p' +} +# A snapshot of a folder (names, sizes, contents) to show nothing changed. +snapshot() { + (cd "$1" 2>/dev/null && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r md5sum) +} +state() { # files and registry + snapshot "$INSTDIR" + reg_value "$REG_KEY" Version + reg_value "$REG_KEY" InstallDir +} + +# hold : starts holder.exe and waits until it +# holds; release stops it. +HOLD_PID="" +hold() { + HOLD_FLAG=$WORK/hold-flag + rm -f "$HOLD_FLAG" "$HOLD_FLAG.ready" + touch "$HOLD_FLAG" + "$WINE" "$2" "$1" "$(winepath -w "$HOLD_FLAG")" >>"$LOG" 2>&1 & + HOLD_PID=$! + local i + for ((i = 0; i < 300; ++i)); do + [[ -f "$HOLD_FLAG.ready" ]] && return 0 + sleep 0.1 + done + return 1 +} +release() { + rm -f "$HOLD_FLAG" + wait "$HOLD_PID" 2>/dev/null + HOLD_PID="" +} + +DATA_V1=$(winepath -w "$WORK/data-v1") +DATA_V2=$(winepath -w "$WORK/data-v2") + +# --- Scenarios ----------------------------------------------------------------- +# Each starts from a fresh Wine prefix. SCENARIOS (space separated) picks some. + +scenario_new() { + section "New installation ($CURRENT_VERSION, silent, with /SOURCE and /COPYDATA)" + new_prefix new + check_eq "exit code" "$(setup "$SETUP_CURRENT" /S "/SOURCE=$DATA_V1" /COPYDATA)" 0 + check_installed "new" "$INSTDIR" "$STAGE/app" 0 openmm2-install.ini + check "game data copied" cmp "$WORK/data-v1/MM2CORE.AR" "$INSTDIR/gamedata/MM2CORE.AR" + check_eq "openmm2-install.ini Source" "$(ini_source "$INSTDIR")" "$INSTDIR_WIN\\gamedata" + check_eq "registry Version" "$(reg_value "$REG_KEY" Version)" "$CURRENT_VERSION" + check_eq "registry InstallDir" "$(reg_value "$REG_KEY" InstallDir)" "$INSTDIR_WIN" + check_eq "registry StartMenuShortcut" "$(reg_value "$REG_KEY" StartMenuShortcut)" 0x1 + check_eq "registry DesktopShortcut" "$(reg_value "$REG_KEY" DesktopShortcut)" 0x1 + [[ $HAVE_TOOLS == 1 ]] && check_eq "registry Tools" "$(reg_value "$REG_KEY" Tools)" 0x0 + check_eq "uninstall entry DisplayVersion" "$(reg_value "$UNINST_KEY" DisplayVersion)" "$CURRENT_VERSION" + check "Start menu shortcut" test -f "$START_LNK" + check "desktop shortcut" test -f "$DESKTOP_LNK" + + section "Uninstall (silent: keeps the game data copy)" + uninstall "$INSTDIR" + check_eq "left in the install folder" "$(top_level "$INSTDIR")" "gamedata" + check_no_key "registry key removed" "$REG_KEY" + check_no_key "uninstall entry removed" "$UNINST_KEY" + check "Start menu shortcut removed" test ! -e "$START_LNK" + check "desktop shortcut removed" test ! -e "$DESKTOP_LNK" +} + +# One installation through updates, repair, downgrades, a running game, a +# failed copy and a damaged manifest, then uninstalled. +scenario_updates() { + local tools_now=0 + if [[ $HAVE_LEGACY == 1 ]]; then + section "Update from $LEGACY_VERSION ($LEGACY_REV installer: no manifest, nothing remembered)" + new_prefix updates + check_eq "install $LEGACY_VERSION" "$(setup "$SETUP_LEGACY" /S "/SOURCE=$DATA_V1" /COPYDATA)" 0 + check "$LEGACY_VERSION has the MSVC runtime stand-ins" test -f "$INSTDIR/vcruntime140.dll" + # The user had the tools installed and deleted the desktop shortcut. + if [[ $HAVE_TOOLS == 1 ]]; then cp "$STAGE/tools/mm2tool.exe" "$INSTDIR/"; fi + tools_now=1 + rm -f "$DESKTOP_LNK" + snapshot "$INSTDIR/gamedata" >"$WORK/gamedata-before.txt" + local ini_before + ini_before=$(ini_source "$INSTDIR") + check_eq "update to $CURRENT_VERSION" "$(setup "$SETUP_CURRENT" /S)" 0 + check_installed "update" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + check "MSVC runtime stand-ins removed" test ! -e "$INSTDIR/vcruntime140.dll" -a ! -e "$INSTDIR/msvcp140.dll" + snapshot "$INSTDIR/gamedata" >"$WORK/gamedata-after.txt" + check_same_lines "game data untouched" "$WORK/gamedata-after.txt" "$WORK/gamedata-before.txt" + check_eq "openmm2-install.ini kept" "$(ini_source "$INSTDIR")" "$ini_before" + check_eq "registry Version" "$(reg_value "$REG_KEY" Version)" "$CURRENT_VERSION" + check "Start menu shortcut kept" test -f "$START_LNK" + check "deleted desktop shortcut not re-created" test ! -e "$DESKTOP_LNK" + check_eq "registry StartMenuShortcut" "$(reg_value "$REG_KEY" StartMenuShortcut)" 0x1 + check_eq "registry DesktopShortcut" "$(reg_value "$REG_KEY" DesktopShortcut)" 0x0 + [[ $HAVE_TOOLS == 1 ]] && + check_eq "registry Tools (mm2tool.exe was there)" "$(reg_value "$REG_KEY" Tools)" 0x1 + else + section "Update from a release installer: skipped (LEGACY_REV=$LEGACY_REV)" + new_prefix updates + check_eq "install $CURRENT_VERSION" "$(setup "$SETUP_CURRENT" /S "/SOURCE=$DATA_V1" /COPYDATA)" 0 + fi + + section "Update to $DUMMY_VERSION, then to $NEXT_VERSION, which no longer ships some files" + check_eq "update to $DUMMY_VERSION" "$(setup "$SETUP_DUMMY" /S)" 0 + check_installed "$DUMMY_VERSION" "$INSTDIR" "$STAGE/app-dummy" $tools_now openmm2-install.ini + check_eq "update to $NEXT_VERSION" "$(setup "$SETUP_NEXT" /S)" 0 + check_installed "$NEXT_VERSION" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + check "dropped folder removed" test ! -e "$INSTDIR/obsolete-dir" + check_eq "registry Version" "$(reg_value "$REG_KEY" Version)" "$NEXT_VERSION" + [[ $HAVE_LEGACY == 1 ]] && check "desktop shortcut still off" test ! -e "$DESKTOP_LNK" + + section "Repair: $NEXT_VERSION again" + rm -f "$INSTDIR/README.md" "$INSTDIR/fonts/LiberationSans-Bold.ttf" + echo "mine" >"$INSTDIR/notes.txt" + echo "mine" >"$INSTDIR/fonts/my-font.ttf" + check_eq "exit code (no downgrade question)" "$(setup "$SETUP_NEXT" /S)" 0 + check "deleted files restored" test -f "$INSTDIR/README.md" -a -f "$INSTDIR/fonts/LiberationSans-Bold.ttf" + check "the user's own files kept" test -f "$INSTDIR/notes.txt" -a -f "$INSTDIR/fonts/my-font.ttf" + rm -f "$INSTDIR/notes.txt" "$INSTDIR/fonts/my-font.ttf" + check_installed "repair" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + + section "Downgrade to $CURRENT_VERSION (silent)" + state >"$WORK/state-before.txt" + check_eq "without /ALLOWDOWNGRADE: exit code" "$(setup "$SETUP_CURRENT" /S)" 3 + state >"$WORK/state-after.txt" + check_same_lines "without /ALLOWDOWNGRADE: nothing changed" "$WORK/state-after.txt" "$WORK/state-before.txt" + check_eq "with /ALLOWDOWNGRADE: exit code" "$(setup "$SETUP_CURRENT" /S /ALLOWDOWNGRADE)" 0 + check_eq "registry Version" "$(reg_value "$REG_KEY" Version)" "$CURRENT_VERSION" + check_installed "downgrade" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + + section "openmm2.exe in use" + state >"$WORK/state-before.txt" + if hold "$INSTDIR_WIN\\openmm2.exe" "$WORK/holder.exe"; then + check_eq "held open by another program: exit code" "$(setup "$SETUP_NEXT" /S)" 4 + state >"$WORK/state-after.txt" + check_same_lines "held open: nothing changed" "$WORK/state-after.txt" "$WORK/state-before.txt" + release + else + fail "holder.exe did not start" + fi + # The real case: openmm2.exe is the running program (a stand-in). + cp "$WORK/holder.exe" "$INSTDIR/openmm2.exe" + state >"$WORK/state-before.txt" + if hold - "$INSTDIR_WIN\\openmm2.exe"; then + check_eq "running: exit code" "$(setup "$SETUP_NEXT" /S)" 4 + state >"$WORK/state-after.txt" + check_same_lines "running: nothing changed" "$WORK/state-after.txt" "$WORK/state-before.txt" + # "_?=" runs the uninstaller in place instead of from a copy in + # %TEMP%, so its exit code is visible. It must come last, unquoted: + # use the 8.3 name of "Program Files". + local short code=0 + short=$(winepath -s "$INSTDIR_WIN" 2>/dev/null | tr -d '\r') + if [[ -n "$short" && "$short" != *" "* ]]; then + timeout "$TIMEOUT" "$WINE" "$(winepath -w "$INSTDIR/uninstall.exe")" /S "_?=$short" >>"$LOG" 2>&1 || + code=$? + check_eq "running: uninstaller exit code" "$code" 4 + state >"$WORK/state-after.txt" + check_same_lines "running: the uninstaller removed nothing" "$WORK/state-after.txt" \ + "$WORK/state-before.txt" + else + fail "no 8.3 name for $INSTDIR_WIN" + fi + release + else + fail "openmm2.exe stand-in did not start" + fi + check_eq "closed: exit code" "$(setup "$SETUP_NEXT" /S)" 0 + check "openmm2.exe replaced" cmp "$STAGE/app/openmm2.exe" "$INSTDIR/openmm2.exe" + check_installed "after closing" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + + section "Another file held open by another program" + # Not the game, so setup does not ask first; the file cannot be replaced, + # which must fail the installation rather than leave the old file. + if hold "$INSTDIR_WIN\\fonts\\LiberationSans-Bold.ttf" "$WORK/holder.exe"; then + check_eq "exit code" "$(setup "$SETUP_NEXT" /S)" 1 + release + else + fail "holder.exe did not start" + fi + check_eq "released: exit code" "$(setup "$SETUP_NEXT" /S)" 0 + check_installed "after releasing it" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + + section "Copying game data fails over an earlier copy" + # data-v2's MM2CORE.AR differs in size from the copy in gamedata\, which + # is made read-only, so --import-source fails when it would replace it, + # after copying (here: skipping) the archives before it. + rm -rf "$WORK/gamedata-probe" + cp -r "$INSTDIR/gamedata" "$WORK/gamedata-probe" + chmod a-w "$WORK/gamedata-probe/MM2CORE.AR" + local code=0 + timeout "$TIMEOUT" "$WINE" "$INSTDIR/openmm2.exe" --import-source "$DATA_V2" \ + "$(winepath -w "$WORK/gamedata-probe")" >>"$LOG" 2>&1 || code=$? + check "the simulated failure makes --import-source fail" test "$code" -ne 0 + chmod -R u+w "$WORK/gamedata-probe" + rm -rf "$WORK/gamedata-probe" + snapshot "$INSTDIR/gamedata" >"$WORK/gamedata-before.txt" + chmod a-w "$INSTDIR/gamedata/MM2CORE.AR" + check_eq "exit code (installed; the failed copy is reported, not fatal)" \ + "$(setup "$SETUP_NEXT" /S "/SOURCE=$DATA_V2" /COPYDATA)" 0 + chmod u+w "$INSTDIR/gamedata/MM2CORE.AR" + snapshot "$INSTDIR/gamedata" >"$WORK/gamedata-after.txt" + check_same_lines "earlier copy untouched, no partial files" "$WORK/gamedata-after.txt" \ + "$WORK/gamedata-before.txt" + check_eq "openmm2-install.ini keeps the earlier copy" "$(ini_source "$INSTDIR")" "$INSTDIR_WIN\\gamedata" + + section "A damaged install-manifest.txt cannot remove anything else" + mkdir -p "$C/Program Files/Bystander" + echo "not OpenMM2's" >"$C/Program Files/Bystander/keep.txt" + echo "not OpenMM2's" >"$C/keep.txt" + cat >>"$INSTDIR/install-manifest.txt" <<'EOF' +..\Bystander\keep.txt +C:\keep.txt +\keep.txt +gamedata\MM2CORE.AR +GAMEDATA\MM2TEX.AR +.\gamedata\MM2AUD.AR +gamedata.\MM2AUD.AR +GAMEDA~1\MM2AUD.AR +gamedata +*.* +openmm2-install.ini +EOF + snapshot "$INSTDIR/gamedata" >"$WORK/gamedata-before.txt" + check_eq "repair: exit code" "$(setup "$SETUP_NEXT" /S)" 0 + check "files outside the install folder kept" test -f "$C/Program Files/Bystander/keep.txt" -a -f "$C/keep.txt" + snapshot "$INSTDIR/gamedata" >"$WORK/gamedata-after.txt" + check_same_lines "game data kept" "$WORK/gamedata-after.txt" "$WORK/gamedata-before.txt" + check "openmm2-install.ini kept" test -f "$INSTDIR/openmm2-install.ini" + check_installed "after the damaged manifest" "$INSTDIR" "$STAGE/app" $tools_now openmm2-install.ini + + section "Uninstall after the updates" + uninstall "$INSTDIR" + check_eq "left in the install folder (the game data copy)" "$(top_level "$INSTDIR")" "gamedata" + check_no_key "registry key removed" "$REG_KEY" + check_no_key "uninstall entry removed" "$UNINST_KEY" + check "Start menu shortcut removed" test ! -e "$START_LNK" +} + +scenario_clean() { + section "Updates without game data, then uninstall: nothing left" + new_prefix clean + if [[ $HAVE_LEGACY == 1 ]]; then + check_eq "install $LEGACY_VERSION" "$(setup "$SETUP_LEGACY" /S)" 0 + else + check_eq "install $CURRENT_VERSION" "$(setup "$SETUP_CURRENT" /S)" 0 + fi + check_eq "update to $DUMMY_VERSION" "$(setup "$SETUP_DUMMY" /S)" 0 + check_eq "update to $NEXT_VERSION" "$(setup "$SETUP_NEXT" /S)" 0 + check_installed "$NEXT_VERSION" "$INSTDIR" "$STAGE/app" 0 + uninstall "$INSTDIR" + check "install folder removed" test ! -e "$INSTDIR" + check_no_key "registry key removed" "$REG_KEY" + check_no_key "uninstall entry removed" "$UNINST_KEY" + check "shortcuts removed" test ! -e "$START_LNK" -a ! -e "$DESKTOP_LNK" +} + +scenario_move() { + section "Installing to another folder with /D= moves the installation" + new_prefix move + check_eq "install $CURRENT_VERSION" "$(setup "$SETUP_CURRENT" /S "/SOURCE=$DATA_V1" /COPYDATA)" 0 + local old=$INSTDIR new=$C/Games/OpenMM2 + check_eq "update to $NEXT_VERSION with /D=C:\\Games\\OpenMM2" "$(setup "$SETUP_NEXT" /S '/D=C:\Games\OpenMM2')" 0 + check_installed "new folder" "$new" "$STAGE/app" 0 openmm2-install.ini + check_eq "new openmm2-install.ini uses the old folder's game data" "$(ini_source "$new")" \ + "$INSTDIR_WIN\\gamedata" + check_eq "old folder keeps only the game data and its setting" "$(top_level "$old")" \ + "gamedata openmm2-install.ini" + check_eq "registry InstallDir" "$(reg_value "$REG_KEY" InstallDir)" 'C:\Games\OpenMM2' + check_eq "uninstall entry InstallLocation" "$(reg_value "$UNINST_KEY" InstallLocation)" 'C:\Games\OpenMM2' + check "Start menu shortcut" test -f "$START_LNK" + uninstall "$new" + check "new folder removed" test ! -e "$new" + check_no_key "registry key removed" "$REG_KEY" + check "old folder's game data is still there" test -f "$old/gamedata/MM2CORE.AR" +} + +# The wizard, driven through uidriver.exe: which pages an installation, an +# update, a repair and a downgrade show, what they say, and the questions +# about a newer version and a running game. +ui() { + timeout 30 "$WINE" "$WORK/uidriver.exe" "$@" 2>>"$LOG" +} +# wait_for [seconds]: waits until the wizard (or a message box in front +# of it) shows a line containing ; leaves its texts in $WORK/page.txt. +wait_for() { + local text=$1 limit=${2:-60} + local end=$((SECONDS + limit)) + while ((SECONDS < end)); do + if ui dump >"$WORK/page.txt" && grep -Fq -- "$text" "$WORK/page.txt"; then + { + echo "--- saw: $text" + cat "$WORK/page.txt" + } >>"$LOG" + return 0 + fi + sleep 0.2 + done + { + echo "--- did not see: $text; last texts:" + cat "$WORK/page.txt" + } >>"$LOG" + return 1 +} +# shown : the last texts contain . +shown() { + grep -Fq -- "$1" "$WORK/page.txt" +} +wizard_start() { # [arguments...] + echo "--- $(basename "$1") (wizard) ${*:2}" >>"$LOG" + timeout "$TIMEOUT" "$WINE" "$@" >>"$LOG" 2>&1 & + WIZARD_PID=$! +} +# wizard_exit: waits for setup to end; its exit code goes to $WIZARD_CODE +# (not printed: $(...) would run "wait" in a subshell). +wizard_exit() { + WIZARD_CODE=0 + wait "$WIZARD_PID" || WIZARD_CODE=$? +} +# From the instfiles page to the end; the game is not started. (The wizard +# moves on to the finish page by itself once the installation is complete.) +wizard_finish() { + check "installation complete: finish page" wait_for "Completing OpenMM2 Setup" 120 + ui uncheck "Start OpenMM2" + ui click 1 + wizard_exit + check_eq "exit code" "$WIZARD_CODE" 0 +} +# The game data page shows its header before it has extracted the program +# it checks the choice with; wait for its own controls. +GAME_DATA_PAGE="|&Copy the game data to this computer" +# Cancel and confirm. +wizard_cancel() { + ui click 2 + wait_for "Are you sure you want to quit" 30 && ui click 6 + wizard_exit + check_eq "cancelled: exit code" "$WIZARD_CODE" 1 +} + +scenario_wizard() { + section "Wizard: new installation" + new_prefix wizard + wizard_start "$SETUP_CURRENT" "/SOURCE=$DATA_V1" + check "welcome page" wait_for "This will install OpenMM2 $CURRENT_VERSION, an open source" + ui click 1 + check "licence page" wait_for "1037|Static|License Agreement" + ui click 1 + check "components page" wait_for "1037|Static|Choose Components" + ui click 1 + check "game data page" wait_for "$GAME_DATA_PAGE" + check " pre-filled from /SOURCE" shown "|$DATA_V1" + check " button: Next" shown "1|Button|&Next >" + ui click 1 + check "directory page" wait_for "1037|Static|Choose Install Location" + check " button: Install" shown "1|Button|&Install" + ui click 1 + wizard_finish + check_eq "registry Version" "$(reg_value "$REG_KEY" Version)" "$CURRENT_VERSION" + check_eq "openmm2-install.ini Source" "$(ini_source "$INSTDIR")" "$DATA_V1" + + section "Wizard: update $CURRENT_VERSION -> $NEXT_VERSION (Next, Next, Install)" + wizard_start "$SETUP_NEXT" + check "welcome page" wait_for "This will update OpenMM2 from $CURRENT_VERSION to $NEXT_VERSION." + check " names the folder" shown "It stays in $INSTDIR_WIN." + ui click 1 + check "components page (licence skipped)" wait_for "1037|Static|Choose Components" + ui click 1 + check "game data page" wait_for "$GAME_DATA_PAGE" + check " pre-filled from the last installation" shown "|$DATA_V1" + check " button: Install (directory page skipped)" shown "1|Button|&Install" + ui click 1 + wizard_finish + check_eq "registry Version" "$(reg_value "$REG_KEY" Version)" "$NEXT_VERSION" + check_installed "wizard update" "$INSTDIR" "$STAGE/app" 0 openmm2-install.ini + + section "Wizard: repair" + wizard_start "$SETUP_NEXT" + check "welcome page" wait_for "OpenMM2 $NEXT_VERSION is already installed. Setup will install it again" + wizard_cancel + + section "Wizard: downgrade" + wizard_start "$SETUP_CURRENT" + check "question" wait_for "OpenMM2 $NEXT_VERSION is installed, which is newer than this setup ($CURRENT_VERSION)." + ui click 7 # No + wizard_exit + check_eq "answered No: exit code" "$WIZARD_CODE" 1 + check_eq " registry Version unchanged" "$(reg_value "$REG_KEY" Version)" "$NEXT_VERSION" + wizard_start "$SETUP_CURRENT" + check "question again" wait_for "is newer than this setup" + ui click 6 # Yes + check "welcome page" wait_for "This will replace OpenMM2 $NEXT_VERSION with the older version $CURRENT_VERSION." + wizard_cancel + + section "Wizard: OpenMM2 running" + if hold "$INSTDIR_WIN\\openmm2.exe" "$WORK/holder.exe"; then + wizard_start "$SETUP_NEXT" + check "welcome page" wait_for "1|Button|&Next >" + ui click 1 + wait_for "1037|Static|Choose Components" && ui click 1 + wait_for "$GAME_DATA_PAGE" && ui click 1 + check "asks to close it" wait_for "OpenMM2 is running. Close it, then click Retry." + check " with Retry and Cancel" shown "4|Button|&Retry" + release + ui click 4 # Retry + wizard_finish + else + fail "holder.exe did not start" + fi +} + +run_scenario() { + case $1 in + new | updates | clean | move | wizard) "scenario_$1" ;; + *) die "unknown scenario '$1'" ;; + esac +} +# A prefix takes a few hundred MB: keep only those of failed scenarios. +for scenario in ${SCENARIOS:-new updates clean move wizard}; do + failures_before=$FAILURES + run_scenario "$scenario" + if [[ $FAILURES -eq $failures_before ]]; then + wineserver -k 2>/dev/null + rm -rf "$WINEPREFIX" + fi +done +export WINEPREFIX=$WORK/wine-tools +wineserver -k 2>/dev/null +[[ $FAILURES -eq 0 ]] && rm -rf "$WINEPREFIX" + +echo +if [[ $FAILURES -eq 0 ]]; then + echo "All $CHECKS checks passed (work directory: $WORK)." +else + echo "$FAILURES of $CHECKS checks failed; log: $LOG" +fi +[[ $FAILURES -eq 0 ]]